chore: initial project skeleton

Acauã: Network Detection & Threat Hunting, heuristic analysis over a
rolling 7-day window using deterministic math (time series, graph
theory, information theory) instead of opaque ML models. Third
product in the Ciberfauna ecosystem, alongside Guará and Sabiá.

Follows the ecosystem's established conventions: OpenWolf project
memory, bilingual PT-BR/EN docs and UI, Gitea Actions CI/CD on the
same self-hosted runners, Conventional Commits, SemVer, logo and
productName handling, MIT OR Apache-2.0 licensing.

Architecture decision (made with the project owner after a dedicated
consultation, not the original blueprint's suggested stack): Rust
throughout, not Python/FastAPI/React. Most of the statistics (Bowley
skew, MADM, Shannon entropy, PCR, Z-score, novel-edge detection,
pivot chains) reduces to SQL over an embedded DuckDB/Parquet store;
only the spectral analysis and graph centrality need host-language
code, and both have workable Rust crates. Documented in full, including
six corrections to the original blueprint's math (Rayleigh/Schuster
instead of a degenerate Lomb-Scargle over bare timestamps, an explicit
sentinel for Bowley skew's Q3=Q1 case, Jensen-Shannon instead of
unbounded KL, a dedicated long-connection detector, JA4 vs. JA4+
licensing caution, deterministic per-alert evidence), in
docs/architecture.md.

Skeleton only: crates/acaua-core has the four detection pillars,
fusion, storage, zeek log parsing and the updater as typed stubs that
compile and return a clear "skeleton" status rather than real
detection logic — that is separate, future work. crates/acaua-cli has
serve/analyze/doctor/update. apps/gui is a minimal Tauri shell. Also
already includes the one-click desktop self-update mechanism shared
with Guará and Sabiá (AppImage/.deb/Windows installer), even with no
release published yet, for consistency across the ecosystem.

No Gitea repository exists for this project yet; this is a local-only
commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
This commit is contained in:
Lucas RayanandClaude Sonnet 5 committed 2026-10-31 10:20:03 -03:00
commit 1b974bb90d
95 files changed
+12016

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Installs apt packages in CI without depending on fast mirrors: reuses cached .deb
# archives when present and retries with per-attempt timeouts if apt stalls or fails.
#
# Usage: scripts/apt-install.sh <cache-dir> <package>...
set -euo pipefail
[ $# -ge 2 ] || { echo "usage: $0 <cache-dir> <package>..." >&2; exit 2; }
cache="$1"; shift
pkgs=("$@")
mkdir -p "$cache/partial"
# apt runs as root and leaves files (lock, .deb) unreadable by non-root runner users;
# restore ownership on exit so cache directories remain accessible.
trap 'sudo chown -R "$(id -u):$(id -g)" "$cache" 2>/dev/null || true; rm -f "$cache/lock"' EXIT
opts=(-o "Dir::Cache::archives=$cache" -y)
# 1) With cached .deb files and package index present: install directly without updating apt lists
if [ -z "$(dpkg --print-foreign-architectures)" ] && ls "$cache"/*.deb >/dev/null 2>&1 && ls /var/lib/apt/lists/*Packages* >/dev/null 2>&1 && timeout 300 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
echo "installed from cache"
exit 0
fi
# 2) Cache miss (or multiarch / incomplete): update and install, up to 3 attempts with timeout
for attempt in 1 2 3; do
if timeout 240 sudo apt-get update && { [ -z "$(dpkg --print-foreign-architectures)" ] || timeout 600 sudo apt-get "${opts[@]}" upgrade; } && timeout 600 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
echo "installed (attempt $attempt)"
exit 0
fi
echo "apt failed or timed out (attempt $attempt of 3)" >&2
sleep $((attempt * 10))
done
echo "failed to install: ${pkgs[*]}" >&2
exit 1
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# Builds the desktop app (Tauri) and places the resulting bundles in dist-gui/.
#
# scripts/build-gui.sh linux # AppImage and .deb (native architecture)
# scripts/build-gui.sh linux-aarch64 # AppImage and .deb for aarch64
# scripts/build-gui.sh linux-x86_64 # AppImage and .deb for x86_64 (native or cross-built from aarch64)
# scripts/build-gui.sh windows # NSIS installer, cross-built from Linux via cargo-xwin
# scripts/build-gui.sh windows-docker # Same as above inside an isolated container
#
# Windows builds use cargo-xwin and NSIS (requires: nsis, lld, llvm, clang, and
# `cargo install cargo-xwin`).
set -euo pipefail
cd "$(dirname "$0")/.."
platform=${1:?usage: build-gui.sh <linux|linux-x86_64|linux-aarch64|windows|windows-docker>}
tauri() { npx --yes @tauri-apps/cli@2 "$@"; }
if [ "$platform" = windows-docker ]; then
docker volume create acaua-cargo >/dev/null
docker volume create acaua-rustup >/dev/null
mkdir -p dist-gui
exec docker run --rm -v "$PWD":/src -v acaua-cargo:/root/.cargo -v acaua-rustup:/root/.rustup \
-w /src -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \
ubuntu:24.04 bash -c '
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl ca-certificates build-essential pkg-config nsis lld llvm clang nodejs npm unzip >/dev/null
[ -x /root/.cargo/bin/rustup ] || curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal >/dev/null
export PATH=/root/.cargo/bin:$PATH
command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked
scripts/build-gui.sh windows
chown -R "$HOST_UID:$HOST_GID" dist-gui apps/gui/src-tauri/target 2>/dev/null || true'
fi
rm -rf dist-gui
mkdir -p dist-gui
cd apps/gui/src-tauri
# Generate derived icons if missing.
[ -f icons/icon.ico ] || tauri icon icons/source.png -o icons
tauri_blob_cache="$HOME/.cache/tauri"
if [ -d /opt/hostedtoolcache ] && [ -w /opt/hostedtoolcache ]; then
tauri_blob_cache="/opt/hostedtoolcache/tauri-tools"
fi
mkdir -p "$tauri_blob_cache" "$HOME/.cache/tauri"
fetch_cached_tool() {
local name=$1 url=$2 patch_elf=${3:-0}
local cached="$tauri_blob_cache/$name"
if [ ! -s "$cached" ]; then
local tmp="$cached.tmp.$$"
curl -fsSL "$url" -o "$tmp"
if [ "$patch_elf" = 1 ]; then
dd if=/dev/zero of="$tmp" bs=1 count=3 seek=8 conv=notrunc 2>/dev/null
fi
chmod +x "$tmp"
mv -f "$tmp" "$cached"
fi
if [ "$cached" != "$HOME/.cache/tauri/$name" ]; then
cp -f "$cached" "$HOME/.cache/tauri/$name"
chmod +x "$HOME/.cache/tauri/$name"
fi
}
case "$platform" in
linux|linux-aarch64)
bundle="${CARGO_TARGET_DIR:-target}/release/bundle"
rm -rf "$bundle/appimage" "$bundle/deb"
tauri build --bundles appimage,deb
cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/
;;
linux-x86_64)
if [ "$(uname -m)" = x86_64 ]; then
bundle="${CARGO_TARGET_DIR:-target}/release/bundle"
rm -rf "$bundle/appimage" "$bundle/deb"
tauri build --bundles appimage,deb
else
rustup target add x86_64-unknown-linux-gnu >/dev/null
mkdir -p /tmp/x86_64-cross-tools "$HOME/.cache/tauri"
cat >/tmp/x86_64-cross-tools/ldd <<'EOF'
#!/usr/bin/env bash
exec qemu-x86_64 /lib64/ld-linux-x86-64.so.2 --list "$@"
EOF
chmod +x /tmp/x86_64-cross-tools/ldd
ln -sf /usr/bin/x86_64-linux-gnu-strip /tmp/x86_64-cross-tools/strip
for tool_rel in glib-2.0/gio-querymodules gdk-pixbuf-2.0/gdk-pixbuf-query-loaders libgtk-3-0t64/gtk-query-immodules-3.0 libgtk-3-0/gtk-query-immodules-3.0; do
if [ -x "/usr/lib/x86_64-linux-gnu/$tool_rel" ]; then
tname=$(basename "$tool_rel")
printf '#!/usr/bin/env bash\nexec qemu-x86_64 "/usr/lib/x86_64-linux-gnu/%s" "$@"\n' "$tool_rel" >"/tmp/x86_64-cross-tools/$tname"
chmod +x "/tmp/x86_64-cross-tools/$tname"
fi
done
fetch_cached_tool "linuxdeploy-07333c6-aarch64.AppImage" \
"https://github.com/tauri-apps/binary-releases/releases/download/linuxdeploy-07333c6/linuxdeploy-aarch64.AppImage" 1
# tauri-bundler looks for this exact cache name (no arch suffix); the
# architecture only appears in the download URL.
fetch_cached_tool "linuxdeploy-plugin-appimage.AppImage" \
"https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-aarch64.AppImage" 1
fetch_cached_tool "AppRun-x86_64" \
"https://github.com/tauri-apps/binary-releases/releases/download/apprun-old/AppRun-x86_64" 0
fetch_cached_tool "runtime-x86_64" \
"https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64" 0
# tauri-bundler runs `dd ... seek=8` on this path to hide linuxdeploy from
# AppImage integration tools. A shell-script wrapper loses its shebang and
# then fails with ENOENT, so use a native ELF launcher: the dd writes into
# ELF padding and the launcher stays executable.
launcher_src=/tmp/x86_64-cross-tools/linuxdeploy-x86_64.c
launcher_bin="$HOME/.cache/tauri/linuxdeploy-07333c6-x86_64.AppImage"
cat >"$launcher_src" <<'EOF'
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char **argv) {
const char *home = getenv("HOME");
if (home == NULL) { fputs("HOME is not set\n", stderr); return 127; }
char target[4096];
char runtime[4096];
char path[8192];
const char *old_path = getenv("PATH");
snprintf(target, sizeof target, "%s/.cache/tauri/linuxdeploy-07333c6-aarch64.AppImage", home);
snprintf(runtime, sizeof runtime, "%s/.cache/tauri/runtime-x86_64", home);
snprintf(path, sizeof path, "/tmp/x86_64-cross-tools:%s",
old_path ? old_path : "/usr/local/bin:/usr/bin:/bin");
if (setenv("ARCH", "x86_64", 1) ||
setenv("NO_STRIP", "true", 1) ||
setenv("APPIMAGE_EXTRACT_AND_RUN", "1", 1) ||
setenv("LDAI_RUNTIME_FILE", runtime, 1) ||
setenv("LD_GTK_LIBRARY_PATH", "/usr/lib/x86_64-linux-gnu", 1) ||
setenv("DEB_HOST_MULTIARCH", "x86_64-linux-gnu", 1) ||
setenv("PKG_CONFIG_ALLOW_CROSS", "1", 1) ||
setenv("PKG_CONFIG_LIBDIR", "/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig", 1) ||
setenv("PATH", path, 1)) {
perror("setenv");
return 127;
}
char **args = calloc((size_t)argc + 1, sizeof *args);
if (args == NULL) { perror("calloc"); return 127; }
args[0] = target;
for (int i = 1; i < argc; i++) args[i] = argv[i];
execv(target, args);
perror(target);
return 127;
}
EOF
cc -O2 -Wall -Wextra -o "$launcher_bin" "$launcher_src"
export PKG_CONFIG_ALLOW_CROSS=1
export PKG_CONFIG_LIBDIR=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc
export CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc
export CXX_x86_64_unknown_linux_gnu=x86_64-linux-gnu-g++
bundle="${CARGO_TARGET_DIR:-target}/x86_64-unknown-linux-gnu/release/bundle"
rm -rf "$bundle/appimage" "$bundle/deb"
tauri build --target x86_64-unknown-linux-gnu --bundles appimage,deb
fi
cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/
;;
windows)
rustup target add x86_64-pc-windows-msvc >/dev/null
bundle="${CARGO_TARGET_DIR:-target}/x86_64-pc-windows-msvc/release/bundle/nsis"
rm -rf "$bundle"
tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
cp "$bundle"/*-setup.exe ../../../dist-gui/
;;
*) echo "unknown platform: $platform" >&2; exit 2 ;;
esac
(
cd ../../../dist-gui
for asset in *; do
[ -f "$asset" ] || continue
case "$asset" in
*.sha256) ;;
*)
sha256sum "$asset" > "$asset.sha256"
;;
esac
done
)
ls -1 ../../../dist-gui
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env node
// Verifies that the English dictionary covers all user-facing interface strings.
//
// Static zero-dependency scanner: reads the UI files (JS and HTML) and the
// pt-BR messages emitted by the Rust core (errors, diagnostics, engine status),
// failing whenever a string looks like Portuguese and lacks a translation. Also
// rejects duplicate keys in the dictionary (in a JS object literal the last key wins silently).
//
// Usage: node scripts/check-ui-i18n.mjs
import { readFileSync, readdirSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
const rel = (p) => relative(root, p).replaceAll('\\', '/');
// ---------------------------------------------------------------- dictionary
const dictPath = join(root, 'apps/gui/ui/i18n-en.js');
const dictSource = readFileSync(dictPath, 'utf8');
const keyRe = /^\s*(['"])((?:(?!\1).)*)\1\s*:/gm;
const seen = new Map();
const duplicates = [];
let m;
while ((m = keyRe.exec(dictSource))) {
const key = m[2];
const line = dictSource.slice(0, m.index).split('\n').length;
if (seen.has(key)) duplicates.push({ key, first: seen.get(key), again: line });
else seen.set(key, line);
}
if (duplicates.length) {
console.error('Duplicate keys in apps/gui/ui/i18n-en.js:');
for (const d of duplicates) console.error(` ${JSON.stringify(d.key)} (lines ${d.first} and ${d.again})`);
process.exitCode = 1;
}
const { EN } = await import(new URL('../apps/gui/ui/i18n-en.js', import.meta.url).href);
const exact = new Set(Object.keys(EN).filter((k) => !k.includes('{n}')));
const patterns = Object.keys(EN)
.filter((k) => k.includes('{n}'))
.map((k) => {
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp('^' + parts.join('(.+?)') + '$', 's');
});
function translated(text) {
if (exact.has(text)) return true;
return patterns.some((re) => re.test(text));
}
// ---------------------------------------------------------------- heuristic
const ACCENT = /[áàâãäéèêëíìîïóòôõöúùûüçÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ]/;
const WORDS = [
'pilar', 'pilares', 'ameaças', 'escopo', 'autorizado', 'tema', 'idioma', 'esqueleto', 'analisar',
'atualizar', 'status', 'alerta', 'alertas', 'registros', 'detector', 'protocolo', 'anomalias',
'fusão', 'evidências', 'conexões', 'sessões', 'persistentes', 'desenvolvimento', 'prova', 'opaca',
'pesquisa', 'determinística', 'armazenamento', 'implementados', 'matemáticos', 'quatro', 'pacote',
'enviado', 'fora', 'próprias', 'autorização', 'explícita', 'serviço', 'indisponível', 'selecionado',
'analisando', 'concluída', 'falha', 'verificando', 'versão', 'recente', 'disponível', 'possível',
'carregados', 'navegação', 'principal', 'recentes', 'futuro', 'carregará', 'sustenta', 'motor',
'detecção', 'próximas', 'specs', 'fechar', 'claro', 'escuro', 'rede', 'redes', 'interface', 'interfaces', 'captura', 'capturar', 'modo', 'ativar', 'ativa',
'desativar', 'iniciar', 'parar', 'nenhum', 'nenhuma', 'selecione', 'selecionada', 'canal',
'sinal', 'segurança', 'clientes', 'cliente', 'oculta', 'oculto', 'privilégio', 'administrador',
'necessário', 'pra', 'própria', 'próprias', 'autorização', 'responsável', 'sessão', 'trilha',
'auditoria', 'registrada', 'registrado', 'local', 'adaptador', 'compatível', 'recarregue',
'escutando', 'levar', 'segundos', 'enquanto', 'canais', 'varridos', 'erro', 'desconhecido',
'desconhecida', 'não', 'sim', 'mais', 'menos', 'uma', 'uns', 'umas', 'aqui', 'onde', 'quando',
'como', 'qual', 'para', 'com', 'sem', 'dos', 'das', 'por', 'que', 'checando', 'checar',
'atualização', 'atualizado', 'trocar', 'possível', 'quebrável', 'quebrado', 'rompida',
'corrompida', 'inválido', 'inválida', 'entrada', 'dicionário', 'quadros', 'quantidade',
'injetar', 'encontrado', 'encontrada', 'suportada', 'permissão', 'insuficiente', 'instale',
'root', 'indisponível', 'plataforma', 'criada', 'ainda', 'ferramenta', 'ferramentas',
'exportar', 'importar', 'imprimir', 'baixar', 'recomendado', 'recomendada', 'aberta',
'aberto', 'inconclusivo', 'inconclusiva', 'capturado', 'capturada', 'conhecido',
'conhecida', 'opcional', 'opcionais', 'fechar', 'motivo', 'linha', 'tempo', 'quadro',
'tentativas', 'ambas',
];
const BRAND = /Acauã|Português|English/gi;
const isPortuguese = (raw) => {
const s = raw.replace(BRAND, ' ');
return ACCENT.test(s) || WORDS.some((w) => new RegExp(`(^|[^a-zà-ú])${w}([^a-zà-ú]|$)`, 'i').test(s));
};
const ALLOW = new Set([
'Acauã', 'Português', 'English', 'Auto', 'Wi-Fi', 'Zeek', 'DuckDB', 'API', 'JSON', 'CLI',
'MIT', 'Apache-2.0', 'OK',
]);
const ALLOW_PREFIX = ['http://', 'https://', 'data:', 'file:', '#', '.', '/'];
const ALLOW_EXACT = new Set(['—', '-', '·']);
function suspicious(text) {
const s = text.replace(/\s+/g, ' ').trim();
if (!s || s.length < 2) return false;
if (ALLOW.has(s) || ALLOW_EXACT.has(s)) return false;
if (ALLOW_PREFIX.some((p) => s.startsWith(p))) return false;
if (!isPortuguese(s)) return false;
// Technical tokens: CSS selectors, classes, extensions, expressions
if (/^[.#\w-]+(\s*[,>+~]\s*[.#\w-]+)+$/.test(s)) return false;
if (/^[a-z0-9_.:/@%{}$-]+$/i.test(s) && !ACCENT.test(s)) return false;
return true;
}
// ---------------------------------------------------------------- extractors
function jsStrings(source, file, baseLine = 1) {
const out = [];
let i = 0;
let line = baseLine;
let prev = '';
const skipRegex = () => {
i++;
let inClass = false;
while (i < source.length) {
const ch = source[i];
if (ch === '\\') { i += 2; continue; }
if (ch === '[') inClass = true;
else if (ch === ']') inClass = false;
else if (ch === '/' && !inClass) { i++; break; }
else if (ch === '\n') line++;
i++;
}
while (i < source.length && /[a-z]/i.test(source[i])) i++;
};
while (i < source.length) {
const c = source[i];
if (c === '\n') { line++; i++; prev = '\n'; continue; }
if (c === ' ' || c === '\t' || c === '\r') { i++; continue; }
if (c === '/' && source[i + 1] === '/') {
while (i < source.length && source[i] !== '\n') i++;
continue;
}
if (c === '/' && source[i + 1] === '*') {
i += 2;
while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { if (source[i] === '\n') line++; i++; }
i += 2;
continue;
}
if (c === '/' && (/[=(:,[!&|?{};+\-*%^~<>]/.test(prev) || prev === 'return')) {
skipRegex();
prev = ')';
continue;
}
if (c === "'" || c === '"') {
const quote = c;
const startLine = line;
let value = '';
i++;
while (i < source.length) {
const ch = source[i];
if (ch === '\\') {
const n = source[i + 1];
if (n === 'n') value += '\n';
else if (n === 't') value += '\t';
else if (n === 'r') value += '\r';
else if (n === '\n') line++;
else value += n ?? '';
i += 2;
continue;
}
if (ch === quote) { i++; break; }
if (ch === '\n') line++;
value += ch;
i++;
}
out.push({ text: value, file, line: startLine });
prev = ')';
continue;
}
if (c === '`') {
const startLine = line;
let value = '';
i++;
while (i < source.length) {
const ch = source[i];
if (ch === '\\') {
const n = source[i + 1];
if (n === 'n') value += '\n';
else if (n === 't') value += '\t';
else if (n === '\n') line++;
else value += n ?? '';
i += 2;
continue;
}
if (ch === '`') { i++; break; }
if (ch === '$' && source[i + 1] === '{') {
value += '{n}';
i += 2;
const exprStart = i;
const exprLine = line;
let depth = 1;
while (i < source.length && depth > 0) {
if (source[i] === '{') depth++;
else if (source[i] === '}') depth--;
else if (source[i] === '\n') line++;
i++;
}
out.push(...jsStrings(source.slice(exprStart, i - 1), file, exprLine));
continue;
}
if (ch === '\n') line++;
value += ch;
i++;
}
out.push({ text: value, file, line: startLine });
prev = ')';
continue;
}
prev = c;
i++;
}
return out;
}
function htmlStrings(source, file, baseLine = 1) {
const out = [];
const lineAt = (idx) => baseLine + source.slice(0, idx).split('\n').length - 1;
// Text between tags (including leading and trailing text nodes)
let last = 0;
for (const m of source.matchAll(/<[^>]*>/g)) {
out.push({ text: source.slice(last, m.index), file, line: lineAt(last) });
last = m.index + m[0].length;
}
out.push({ text: source.slice(last), file, line: lineAt(last) });
// Translatable attributes (single or double quotes)
for (const m of source.matchAll(/\b(placeholder|title|aria-label|alt|data-empty)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
out.push({ text: m[2] ?? m[3], file, line: lineAt(m.index) });
}
return out;
}
// Extracts pt-BR messages from the Rust core: #[error("...")], .push("...".into()),
// "...".into(), and format!("...", ...). Placeholders ({name}, {0}, {} etc.) are
// normalized to {n}, matching the dictionary format.
function rustLabels(source, file) {
const out = [];
const lineAt = (idx) => source.slice(0, idx).split('\n').length;
const norm = (s) => s.replace(/\{[^}]*\}/g, '{n}');
const grab = (re) => {
for (const m of source.matchAll(re)) {
out.push({ text: norm(m[1]), file, line: lineAt(m.index) });
}
};
grab(/#\[error\(\s*"((?:[^"\\]|\\.)*)"\s*\)\]/g);
grab(/"((?:[^"\\]|\\.)*)"\s*\.into\(\)/g);
grab(/format!\(\s*"((?:[^"\\]|\\.)*)"/g);
return out;
}
// ---------------------------------------------------------------- collection
const uiFiles = readdirSync(join(root, 'apps/gui/ui'))
.filter((f) => f.endsWith('.js') && f !== 'i18n-en.js' && f !== 'vendor')
.map((f) => join(root, 'apps/gui/ui', f));
uiFiles.push(join(root, 'apps/gui/ui/index.html'));
const rustFiles = [
'crates/acaua-core/src/error.rs',
'crates/acaua-core/src/doctor.rs',
'crates/acaua-core/src/storage.rs',
'crates/acaua-core/src/zeek.rs',
'crates/acaua-core/src/update.rs',
].map((f) => join(root, f));
const found = [];
for (const file of uiFiles) {
const source = readFileSync(file, 'utf8');
if (file.endsWith('.html')) found.push(...htmlStrings(source, file));
else {
for (const str of jsStrings(source, file)) {
if (/<[a-z!/][^>]*>/i.test(str.text)) found.push(...htmlStrings(str.text, file, str.line));
else found.push(str);
}
}
}
for (const file of rustFiles) found.push(...rustLabels(readFileSync(file, 'utf8'), file));
const missing = [];
const seenMissing = new Set();
for (const { text, file, line } of found) {
if (!suspicious(text)) continue;
const key = text.replace(/\s+/g, ' ').trim();
if (translated(key)) continue;
const id = `${key}\u0000${rel(file)}\u0000${line}`;
if (seenMissing.has(id)) continue;
seenMissing.add(id);
missing.push({ text: key, file: rel(file), line });
}
if (missing.length) {
console.error('Untranslated Portuguese strings in apps/gui/ui/i18n-en.js:\n');
for (const x of missing) console.error(` ${x.file}:${x.line} ${JSON.stringify(x.text)}`);
console.error(`\n${missing.length} untranslated string(s).`);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(`i18n ok: ${found.length} strings checked, ${Object.keys(EN).length} dictionary keys.`);
}
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Local verification: runs the same fast checks as CI before pushing.
#
# scripts/check.sh fast (~1 min): fmt, clippy, i18n, scripts, workflows, semver
# scripts/check.sh --full also runs workspace tests and cargo audit
#
# CI takes 5 to 30 minutes per run; almost everything that breaks CI is caught
# here in seconds.
set -uo pipefail
cd "$(dirname "$0")/.."
full=0
[ "${1:-}" = "--full" ] || [ "${1:-}" = "--completo" ] && full=1
failures=()
step() { # step "name" command...
local name="$1"; shift
printf '\n\033[1m== %s\033[0m\n' "$name"
if "$@"; then printf '\033[32mok\033[0m %s\n' "$name"; else printf '\033[31mFAILED\033[0m %s\n' "$name"; failures+=("$name"); fi
}
step "formatting (cargo fmt)" cargo fmt --all -- --check
# Same flags as CI: warnings are treated as errors across all targets, including tests
step "clippy (workspace, all targets)" env RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets
check_forbidden_terms() {
python3 - <<'PY'
from pathlib import Path
terms = [
'Fast' + 'API',
'Click' + 'House',
'latest' + '.json',
'updater' + '.pubkey',
'createUpdater' + 'Artifacts',
'plugin' + '-updater',
]
skip_dirs = {'.git', 'target', '.wolf', '.claude', 'dist-gui'}
allowed = {Path('docs/architecture.md')}
hits = []
for path in Path('.').rglob('*'):
if not path.is_file() or any(part in skip_dirs for part in path.parts):
continue
if path in allowed:
continue
try:
text = path.read_text(errors='ignore')
except OSError:
continue
for term in terms:
if term in text:
hits.append(f'{path}: {term}')
if hits:
print('\n'.join(hits))
raise SystemExit(1)
PY
}
step "UI translation coverage" node scripts/check-ui-i18n.mjs
step "bash script syntax" bash -c 'for f in scripts/*.sh .githooks/*; do bash -n "$f" || exit 1; done'
step "workflows and templates (valid YAML)" python3 -c "
import glob, yaml
for f in glob.glob('.gitea/**/*.yml', recursive=True):
yaml.safe_load(open(f))
"
step "brand assets" test -s logo.png -a -s apps/gui/src-tauri/icons/source.png -a -s apps/gui/src-tauri/icons/icon.ico
step "consistent Semantic Versioning 2.0.0" python3 -c "
import json, re, sys
from pathlib import Path
semver_re = re.compile(
r'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?'
r'(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$'
)
def cargo_version(path):
m = re.search(r'^version\s*=\s*\"([^\"]+)\"', Path(path).read_text(), re.M)
if not m:
sys.exit(f'version not found in {path}')
return m.group(1)
versions = [
cargo_version('Cargo.toml'),
cargo_version('apps/gui/src-tauri/Cargo.toml'),
json.load(open('apps/gui/src-tauri/tauri.conf.json'))['version'],
]
if len(set(versions)) != 1:
sys.exit(f'version mismatch: Cargo.toml={versions[0]}, src-tauri={versions[1]}, tauri.conf={versions[2]}')
if not semver_re.match(versions[0]):
sys.exit(f'invalid SemVer 2.0.0 version: {versions[0]}')
print(versions[0])
"
step "forbidden stack terms" check_forbidden_terms
if [ -f apps/gui/src-tauri/Cargo.toml ] && pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
step "desktop app (cargo check)" env RUSTFLAGS="" cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
else
printf '\n(desktop app: skipped, WebKitGTK development libraries not installed; built in CI)\n'
fi
if [ "$full" = 1 ]; then
step "tests (cargo test --workspace)" cargo test --workspace
step "dependency audit (cargo audit)" cargo audit
fi
printf '\n'
if [ ${#failures[@]} -eq 0 ]; then
printf '\033[32mall checks passed\033[0m: ready to push.\n'
else
printf '\033[31mfailed:\033[0m %s\n' "${failures[*]}"
exit 1
fi
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Generates the base Tauri icon from the official project logo.
The official logo is `logo.png` in the repository root (RGBA, transparent
background, suitable for both light and dark themes). It is not modified here.
Output:
apps/gui/src-tauri/icons/source.png 1024 px artwork centered over a rounded
dark background; CI and `npx @tauri-apps/cli icon`
derive all remaining icon formats from it.
Usage: python3 scripts/generate-logo.py
"""
import os
from PIL import Image, ImageDraw
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
ICONS_DIR = os.path.join(ROOT, "apps/gui/src-tauri/icons")
ICON_BG = (10, 16, 28) # dark navy background matching the original artwork
def crop_square(im, margin=0.04):
"""Crops to visible pixels (opacity > 16, ignoring faint artifacts) and centers in a square."""
box = im.getchannel("A").point(lambda v: 255 if v > 16 else 0).getbbox()
im = im.crop(box)
w, h = im.size
side = round(max(w, h) * (1 + 2 * margin))
canvas = Image.new("RGBA", (side, side), (0, 0, 0, 0))
canvas.paste(im, ((side - w) // 2, (side - h) // 2), im)
return canvas
def main():
logo = crop_square(Image.open(os.path.join(ROOT, "logo.png")).convert("RGBA"))
side = 1024
bg = Image.new("RGBA", (side, side), ICON_BG + (255,))
art = logo.resize((round(side * 0.82),) * 2, Image.LANCZOS)
bg.paste(art, ((side - art.width) // 2, (side - art.height) // 2), art)
mask = Image.new("L", (side, side), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, side - 1, side - 1), radius=round(side * 0.2237), fill=255)
icon = Image.new("RGBA", (side, side), (0, 0, 0, 0))
icon.paste(bg, (0, 0), mask)
os.makedirs(ICONS_DIR, exist_ok=True)
dest = os.path.join(ICONS_DIR, "source.png")
icon.save(dest, optimize=True)
os.chmod(dest, 0o644)
print("icons/source.png")
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Enables the repository Git hooks (.githooks/pre-push and .githooks/commit-msg).
set -euo pipefail
cd "$(dirname "$0")/.."
git config core.hooksPath .githooks
echo "hooks enabled: git commit validates Conventional Commits in English and git push runs scripts/check.sh (disable with: git config --unset core.hooksPath)"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Builds the CLI for a target platform and packages it as expected by the updater:
# acaua-<tag>-<platform>.tar.gz alongside a .sha256 checksum file.
#
# scripts/package-cli.sh v1.0.3 linux-x86_64 x86_64-unknown-linux-gnu
#
# Supported platforms: linux-x86_64 and linux-aarch64. When cross-compiling
# from a non-x86_64 host, uses gcc-x86-64-linux-gnu. Writes the archives to the
# repository root and prints their filenames.
set -euo pipefail
cd "$(dirname "$0")/.."
tag=${1:?usage: package-cli.sh <tag> <platform> <rust-target>}
platform=${2:?usage: package-cli.sh <tag> <platform> <rust-target>}
target=${3:?usage: package-cli.sh <tag> <platform> <rust-target>}
case "$target" in
x86_64-unknown-linux-gnu)
if [ "$(uname -m)" != x86_64 ]; then
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc
export CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc
fi ;;
esac
rustup target add "$target" >/dev/null 2>&1 || true
cargo build --release --locked -p acaua-cli --target "$target"
name="acaua-${tag}-${platform}"
rm -rf "$name"; mkdir "$name"
cp "${CARGO_TARGET_DIR:-target}/${target}/release/acaua" "$name/"
cp README.md LICENSE-MIT LICENSE-APACHE "$name/"
tar czf "$name.tar.gz" "$name"
asset="$name.tar.gz"
rm -rf "$name"
sha256sum "$asset" > "$asset.sha256"
echo "$asset"
echo "$asset.sha256"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Prints release notes for a given tag: extracts the matching CHANGELOG.md
# section, or falls back to the commit log since the previous tag.
#
# scripts/release-notes.sh v1.2.3
set -euo pipefail
tag=${1:?usage: release-notes.sh <tag>}
version=${tag#v}
section=""
if [ -f CHANGELOG.md ]; then
section=$(awk -v head="## [$version]" '
index($0, head) == 1 { found = 1; next }
found && /^## \[/ { exit }
found && /^\[[^]]+\]: / { next }
found { print }
' CHANGELOG.md)
fi
if [ -n "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
printf '%s\n' "$section"
else
previous=$(git describe --tags --abbrev=0 "$tag^" 2>/dev/null || true)
echo "## Changes${previous:+ since $previous}"
echo
git log --no-merges --format='- %s (%h)' "${previous:+$previous..}$tag"
fi
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Uploads assets to a Gitea release, replacing any existing asset with the same
# filename so re-running a CI job never fails on duplicate attachments.
#
# API=https://git.example.com/api/v1/repos/owner/repo TOKEN=... \
# scripts/release-upload.sh v1.2.3 file1 file2
set -euo pipefail
: "${API:?set API (Gitea repository API URL)}"
: "${TOKEN:?set TOKEN}"
tag=${1:?usage: release-upload.sh <tag> <file>...}
shift
auth=(-H "Authorization: token $TOKEN")
id=$(curl -fsS "${auth[@]}" "$API/releases/tags/$tag" | jq -r .id)
for file in "$@"; do
name=$(basename "$file")
encoded=$(jq -rn --arg n "$name" '$n|@uri')
for old in $(curl -fsS "${auth[@]}" "$API/releases/$id/assets" | jq -r --arg n "$name" '.[] | select(.name == $n) | .id'); do
curl -fsS -X DELETE "${auth[@]}" "$API/releases/$id/assets/$old"
done
curl -fsS -X POST "${auth[@]}" -F "attachment=@$file" "$API/releases/$id/assets?name=$encoded" >/dev/null
echo "uploaded: $name"
done