v0.1.0: logo, i18n pt/en, CI com caches, testes e documentação
- Logo oficial (sabia.png) e conjunto de ícones do app desktop. - Interface em português e inglês: tradutor por observação do DOM, seletor de idioma e checagem de cobertura de tradução no CI. - CI/CD com caches (rust-cache, .deb do Tauri em cache) e pipeline de release multiplataforma (CLI em 5 alvos + app desktop via tauri-action), templates de issue/PR. - Suíte de testes de integração do sabia-core (scan e2e, handshake/crack 4-way e PMKID, pcap, trilha de auditoria) — 46 testes verdes. - Documentação completa pt/en: README, ROADMAP, SECURITY, CONTRIBUTING, CHANGELOG e docs/ (cli, interface, arquitetura), com enquadramento de uso autorizado. - Correções de clippy (-D warnings) e formatação em toda a árvore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df27974359
commit
791538c9d9
112 files changed
+15620
-494
No files matched your search
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Instala pacotes do apt no CI sem depender do espelho estar rápido: usa os .deb em cache quando
|
||||
# existem e, se o apt travar ou falhar, tenta de novo com tempo limite por tentativa.
|
||||
#
|
||||
# Os passos de apt do CI já levaram de 13 a 28 minutos num dia de espelho lento, contra uns 35 s
|
||||
# no normal. O cache evita o download; o tempo limite evita ficar preso.
|
||||
#
|
||||
# Uso: scripts/apt-install.sh <pasta-do-cache> <pacote>...
|
||||
set -euo pipefail
|
||||
[ $# -ge 2 ] || { echo "uso: $0 <pasta-do-cache> <pacote>..." >&2; exit 2; }
|
||||
cache="$1"; shift
|
||||
pkgs=("$@")
|
||||
mkdir -p "$cache/partial"
|
||||
# O apt roda como root e deixa arquivos (lock, .deb) que o usuário do runner não lê; sem devolver a
|
||||
# posse, o actions/cache falha ao empacotar a pasta ("tar failed with exit code 2") e nunca grava.
|
||||
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) com .deb em cache: instala direto, sem nem atualizar o índice do apt
|
||||
if ls "$cache"/*.deb >/dev/null 2>&1 && timeout 300 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
|
||||
echo "instalado a partir do cache"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2) sem cache (ou incompleto): atualiza e instala, até 3 tentativas com tempo limite
|
||||
for tentativa in 1 2 3; do
|
||||
if timeout 240 sudo apt-get update && timeout 600 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
|
||||
echo "instalado (tentativa $tentativa)"
|
||||
exit 0
|
||||
fi
|
||||
echo "apt falhou ou passou do tempo (tentativa $tentativa de 3)" >&2
|
||||
sleep $((tentativa * 10))
|
||||
done
|
||||
echo "não foi possível instalar: ${pkgs[*]}" >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env node
|
||||
// Confere se o dicionário inglês cobre os textos da interface.
|
||||
//
|
||||
// A varredura é estática e sem dependências: lê os arquivos da interface
|
||||
// (JS e HTML) e as mensagens pt-BR que o núcleo em Rust manda pra tela
|
||||
// (erros, diagnóstico, status do engine), e falha quando encontra uma
|
||||
// string que parece português e não tem tradução. Também recusa chaves
|
||||
// duplicadas no dicionário (num objeto JS a última vence em silêncio).
|
||||
//
|
||||
// Uso: 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('\\', '/');
|
||||
|
||||
// ---------------------------------------------------------------- dicionário
|
||||
|
||||
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('Chaves duplicadas em apps/gui/ui/i18n-en.js:');
|
||||
for (const d of duplicates) console.error(` ${JSON.stringify(d.key)} (linhas ${d.first} e ${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));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- heurística
|
||||
|
||||
const ACCENT = /[áàâãäéèêëíìîïóòôõöúùûüçÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ]/;
|
||||
const WORDS = [
|
||||
'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',
|
||||
];
|
||||
|
||||
const BRAND = /Sabiá|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([
|
||||
'Sabiá', 'Português', 'English', 'Auto', 'Wi-Fi', 'SSID', 'BSSID', 'MAC', 'AP', 'WPA', 'WPA2',
|
||||
'WPA3', 'WEP', 'PMKID', 'EAPOL', 'PMK', 'PTK', 'pcap', 'radiotap', '802.11', 'dBm', 'GHz',
|
||||
'MHz', 'RSSI', 'CAP_NET_ADMIN', 'CAP_NET_RAW', 'AF_PACKET', '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;
|
||||
// técnico: seletores, classes, extensões, expressões
|
||||
if (/^[.#\w-]+(\s*[,>+~]\s*[.#\w-]+)+$/.test(s)) return false;
|
||||
if (/^[a-z0-9_.:/@%{}$-]+$/i.test(s) && !ACCENT.test(s)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- extratores
|
||||
|
||||
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;
|
||||
// todo texto entre tags (inclusive começo e fim) — nós de texto reais
|
||||
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) });
|
||||
// atributos traduzíveis (com aspas simples ou duplas)
|
||||
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;
|
||||
}
|
||||
|
||||
// Extrai mensagens pt-BR do núcleo em Rust: #[error("...")], .push("...".into()),
|
||||
// "...".into() e format!("...", ...). Placeholders ({name}, {0}, {} etc.) são
|
||||
// normalizados para {n}, igual ao que o dicionário usa.
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- coleta
|
||||
|
||||
const uiFiles = readdirSync(join(root, 'apps/gui/ui'))
|
||||
.filter((f) => f.endsWith('.js') && f !== 'i18n-en.js')
|
||||
.map((f) => join(root, 'apps/gui/ui', f));
|
||||
uiFiles.push(join(root, 'apps/gui/ui/index.html'));
|
||||
|
||||
const rustFiles = [
|
||||
'crates/sabia-core/src/error.rs',
|
||||
'crates/sabia-core/src/doctor.rs',
|
||||
'crates/sabia-core/src/engine.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('Textos em português sem tradução em 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} texto(s) sem tradução.`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (!process.exitCode) {
|
||||
console.log(`i18n ok: ${found.length} textos verificados, ${Object.keys(EN).length} chaves no dicionário.`);
|
||||
}
|
||||
Reference in new issue
Block a user