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:
commit
1b974bb90d
95 files changed
+12016
No files matched your search
Executable
+32
@@ -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
|
||||
Executable
+185
@@ -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
|
||||
@@ -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.`);
|
||||
}
|
||||
Executable
+110
@@ -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
|
||||
Executable
+51
@@ -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()
|
||||
Executable
+6
@@ -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)"
|
||||
Executable
+39
@@ -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"
|
||||
Executable
+28
@@ -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
|
||||
Executable
+25
@@ -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
|
||||
Reference in new issue
Block a user