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:
Lucas RayanandClaude Opus 5.5 committed 2026-10-23 12:46:19 -03:00
1 parent df27974359
commit 791538c9d9
112 files changed
+15620 -494

No files matched your search

+2
View File
@@ -113,6 +113,8 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.side-foot { margin-top: auto; padding: 14px 10px 4px; font-size: 11.5px; color: var(--text-3); display: flex; justify-content: space-between; gap: 8px; }
.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
.side-foot button:hover { color: var(--text-2); }
.lang-sel { background: transparent; border: 0; color: var(--text-3); font-size: 11.5px; cursor: pointer; }
.lang-sel:hover { color: var(--text-2); }
.main { overflow-y: auto; min-width: 0; }
.page { max-width: 1040px; margin: 0 auto; padding: 30px 36px 100px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); }
+17
View File
@@ -1,3 +1,5 @@
import { setLang, currentLang } from './i18n.js';
const params = new URLSearchParams(location.search);
const token = params.get('token');
const tauri = window.__TAURI__;
@@ -14,8 +16,22 @@ const els = {
wave: document.getElementById('wave'),
verInfo: document.getElementById('verInfo'),
btnUpdate: document.getElementById('btnUpdate'),
langSel: document.getElementById('langSel'),
};
function applyLangPref() {
let pref = 'auto';
try { pref = localStorage.getItem('sabia-lang') || 'auto'; } catch { /* ignora */ }
els.langSel.value = pref;
setLang(pref === 'auto' ? undefined : pref);
}
els.langSel.addEventListener('change', () => {
const pref = els.langSel.value;
try { localStorage.setItem('sabia-lang', pref); } catch { /* ignora */ }
setLang(pref === 'auto' ? undefined : pref);
});
let ifaces = [];
let current = null;
let scanning = false;
@@ -248,6 +264,7 @@ async function checkElevation() {
}
(async function init() {
applyLangPref();
checkElevation();
const status = await api('/api/version');
els.verInfo.textContent = `v${status.version}`;
+98
View File
@@ -0,0 +1,98 @@
// Dicionário português -> inglês da interface. {n} marca um valor variável.
export const EN = {
// index.html / sidebar
'auditoria de Wi-Fi': 'Wi-Fi auditing',
'Interfaces': 'Interfaces',
'nenhuma interface Wi-Fi encontrada': 'no Wi-Fi interface found',
'checar atualização': 'check for update',
'checando…': 'checking…',
'atualizar para {n}': 'update to {n}',
'já atualizado': 'already up to date',
// topbar
'Painel de captura': 'Capture dashboard',
'selecione uma interface': 'select an interface',
'modo monitor': 'monitor mode',
'modo gerenciado': 'managed mode',
// elevação / avisos
'Pedindo privilégio de administrador — necessário pra modo monitor e captura…':
'Requesting administrator privilege — needed for monitor mode and capture…',
'tentar de novo': 'try again',
'Não foi possível obter privilégio de administrador.': 'Could not get administrator privilege.',
'Use somente em redes próprias ou com autorização explícita do responsável. Toda ativação de modo monitor e sessão de captura fica registrada na trilha de auditoria local.':
'Use only on your own networks or with the explicit authorization of the responsible party. Every monitor mode activation and capture session is logged in the local audit trail.',
// controles
'ativar modo monitor': 'enable monitor mode',
'desativar modo monitor': 'disable monitor mode',
'iniciar captura': 'start capture',
'parar captura': 'stop capture',
// resultados
'Redes vistas': 'Networks seen',
'Pronto pra capturar': 'Ready to capture',
'Ative o modo monitor e inicie a captura pra ver as redes no ar.': 'Enable monitor mode and start capturing to see the networks on the air.',
'Nenhuma interface Wi-Fi': 'No Wi-Fi interface',
'Conecte um adaptador compatível com modo monitor e recarregue.': 'Connect an adapter that supports monitor mode and reload.',
'Escutando o ar…': 'Listening to the air…',
'Nenhuma rede vista ainda. Isso pode levar alguns segundos enquanto os canais são varridos.':
'No network seen yet. This can take a few seconds while the channels are scanned.',
'oculta': 'hidden',
'Rede': 'Network',
'Canal': 'Channel',
'Sinal': 'Signal',
'Segurança': 'Security',
'Clientes': 'Clients',
// erros de UI
'Não foi possível trocar o modo:': 'Could not change the mode:',
'erro desconhecido': 'unknown error',
'rota desconhecida': 'unknown route',
// seletor de idioma
'Idioma': 'Language',
'Auto': 'Auto',
'Português': 'Portuguese',
'English': 'English',
// crates/sabia-core/src/error.rs
'interface `{n}` não encontrada': 'interface `{n}` not found',
'interface `{n}` não é uma interface Wi-Fi (sem phy80211)': 'interface `{n}` is not a Wi-Fi interface (no phy80211)',
'falha ao executar `{n}`: {n}': 'failed to run `{n}`: {n}',
'permissão insuficiente — operações de captura e modo monitor exigem root (CAP_NET_ADMIN/CAP_NET_RAW)':
'insufficient permission — capture and monitor mode operations require root (CAP_NET_ADMIN/CAP_NET_RAW)',
'captura crua só é suportada no Linux (AF_PACKET)': 'raw capture is only supported on Linux (AF_PACKET)',
'entrada inválida: {n}': 'invalid input: {n}',
'captura .pcap inválida: {n}': 'invalid .pcap capture: {n}',
'erro de E/S: {n}': 'I/O error: {n}',
'erro ao (de)serializar: {n}': '(de)serialization error: {n}',
// crates/sabia-core/src/doctor.rs
'sem root: modo monitor, captura e injeção vão falhar (use sudo ou o app desktop)':
'no root: monitor mode, capture and injection will fail (use sudo or the desktop app)',
'`iw` não encontrado no PATH — instale iw/iproute2': '`iw` not found in PATH — install iw/iproute2',
'`ip` não encontrado no PATH — instale iproute2': '`ip` not found in PATH — install iproute2',
'nenhuma interface anuncia suporte a modo monitor': 'no interface advertises monitor mode support',
'`curl` não encontrado — a auto-atualização não funciona': '`curl` not found — auto-update does not work',
'a trilha de auditoria existente está corrompida': 'the existing audit trail is corrupted',
'trilha de auditoria ainda não criada em {n}': 'audit trail not created yet at {n}',
'cadeia de auditoria rompida na entrada {n}': 'audit chain broken at entry {n}',
'não foi possível ler a trilha de auditoria: {n}': 'could not read the audit trail: {n}',
'indisponível nesta plataforma': 'unavailable on this platform',
// crates/sabia-core/src/engine.rs
'{n} está em modo managed; ative o modo monitor antes de capturar':
'{n} is in managed mode; enable monitor mode before capturing',
'{n} está em modo managed; ative o modo monitor antes de injetar':
'{n} is in managed mode; enable monitor mode before injecting',
'nenhuma sessão de captura em {n}': 'no capture session on {n}',
'link-type não é 802.11 nem radiotap': 'link-type is neither 802.11 nor radiotap',
'handshake não encontrado': 'handshake not found',
'handshake sem quadros crus (captura antiga)': 'handshake with no raw frames (old capture)',
'nenhum handshake com quadros crus para exportar': 'no handshake with raw frames to export',
'dicionário não encontrado: {n}': 'wordlist not found: {n}',
'nenhum handshake quebrável para os filtros informados': 'no crackable handshake for the given filters',
'quantidade de quadros deve ficar entre 1 e 256': 'frame count must be between 1 and 256',
'MAC inválido: {n}': 'invalid MAC: {n}',
};
+122
View File
@@ -0,0 +1,122 @@
// Idioma da interface. O texto-fonte é em português; no modo inglês um tradutor
// troca os textos exibidos (nós de texto e atributos) usando um dicionário, e
// continua observando a página para traduzir o que for desenhado depois.
// Textos com valores variáveis usam {n} no dicionário (ex.: "{n} redes" -> "{n} networks").
import { EN } from './i18n-en.js';
let lang = 'pt';
export const isEn = () => lang === 'en';
export const currentLang = () => lang;
export const LOC = () => (lang === 'en' ? 'en-US' : 'pt-BR');
export function resolveLang(pref) {
if (pref === 'pt' || pref === 'en') return pref;
return (navigator.language || 'pt').toLowerCase().startsWith('pt') ? 'pt' : 'en';
}
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty'];
// Dados do usuário (SSID, BSSID, MAC, valores mono) nunca são traduzidos.
const SKIP = '.ssid, .bssid, .mono, pre, code, input, textarea, script, style';
const exact = new Map(Object.entries(EN).filter(([k]) => !k.includes('{n}')));
const patterns = Object.entries(EN)
.filter(([k]) => k.includes('{n}'))
.map(([k, v]) => {
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: v };
})
// padrões mais específicos (mais texto fixo) primeiro
.sort((a, b) => b.re.source.length - a.re.source.length);
const cache = new Map();
export function tr(text) {
if (!isEn()) return text;
const lead = text.match(/^\s*/)[0];
const trail = text.match(/\s*$/)[0];
const core = text.trim();
if (!core) return text;
if (cache.has(core)) return lead + cache.get(core) + trail;
let out = exact.get(core);
if (out === undefined) {
for (const p of patterns) {
const m = core.match(p.re);
if (m) {
let i = 0;
// os valores capturados também podem ter partes traduzíveis (ex.: estados)
out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? ''));
break;
}
}
}
if (out === undefined) out = core;
if (cache.size > 5000) cache.clear();
cache.set(core, out);
return lead + out + trail;
}
const orig = new WeakMap(); // nó -> texto original em português
let observer = null;
function translateNode(n) {
if (n.nodeType === 3) {
if (n.parentElement?.closest(SKIP)) return;
const o = orig.get(n) ?? n.nodeValue;
const t = tr(o);
if (t !== n.nodeValue) {
if (!orig.has(n)) orig.set(n, o);
n.nodeValue = t;
}
} else if (n.nodeType === 1) {
if (n.matches?.(SKIP) && n.tagName !== 'INPUT') return;
for (const a of ATTRS) {
if (n.hasAttribute?.(a)) {
const key = `i18n-${a}`;
const o = n[key] ?? n.getAttribute(a);
const t = tr(o);
if (t !== n.getAttribute(a)) { n[key] = o; n.setAttribute(a, t); }
}
}
for (const c of n.childNodes) translateNode(c);
}
}
function restoreAll(root) {
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let n;
while ((n = w.nextNode())) { if (orig.has(n)) { n.nodeValue = orig.get(n); orig.delete(n); } }
root.querySelectorAll('*').forEach((el) => {
for (const a of ATTRS) { const key = `i18n-${a}`; if (el[key] !== undefined) { el.setAttribute(a, el[key]); delete el[key]; } }
});
}
function start() {
translateNode(document.body);
document.title = tr(document.title);
if (observer) return;
observer = new MutationObserver((muts) => {
observer.disconnect();
for (const m of muts) {
if (m.type === 'childList') m.addedNodes.forEach(translateNode);
else if (m.type === 'characterData') { orig.delete(m.target); translateNode(m.target); }
else if (m.type === 'attributes') { delete m.target[`i18n-${m.attributeName}`]; translateNode(m.target); }
}
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
}
function stop() {
observer?.disconnect();
observer = null;
restoreAll(document.body);
}
export function setLang(pref) {
const next = resolveLang(pref);
const changed = next !== lang;
lang = next;
document.documentElement.lang = lang === 'en' ? 'en' : 'pt-BR';
if (lang === 'en') start();
else if (changed || observer) stop();
}
+8 -3
View File
@@ -4,14 +4,14 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sabiá</title>
<link rel="icon" href="/sabia-logo.svg">
<link rel="icon" href="/sabia.png">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<img src="/sabia-logo.svg" alt="">
<img class="logo" src="/sabia.png" alt="">
<div>
<strong>Sabiá</strong>
<small>auditoria de Wi-Fi</small>
@@ -23,6 +23,11 @@
<div class="side-foot">
<span id="verInfo" class="mono">—</span>
<select id="langSel" class="lang-sel" aria-label="Idioma" title="Idioma">
<option value="auto">Auto</option>
<option value="pt">Português</option>
<option value="en">English</option>
</select>
<button id="btnUpdate" type="button">checar atualização</button>
</div>
</aside>
@@ -71,6 +76,6 @@
</div>
</main>
</div>
<script src="/app.js"></script>
<script type="module" src="/app.js"></script>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Sabiá"><style>.a{stroke:#b45309}.b{stroke:#0891b2}.t{fill:#1d1d1f}@media (prefers-color-scheme:dark){.a{stroke:#f59e0b}.b{stroke:#22d3ee}.t{fill:#f5f5f7}}</style><defs><clipPath id="s-bird"><path d="M78 14 C70 8 60 6 52 10 C46 6 38 6 32 10 C20 16 12 28 12 42 C12 54 18 64 28 70 C26 76 28 82 34 85 C33 80 35 76 39 74 C46 78 55 79 63 76 C73 72 80 62 81 50 C88 48 93 42 94 34 C87 36 81 34 77 29 C81 25 83 19 78 14 Z"/></clipPath></defs><g clip-path="url(#s-bird)" fill="none" stroke-width="3.6"><path class="a" d="M78 14A6 6 0 0 1 78 26"/><path class="b" d="M78 14A6 6 0 0 0 78 26" stroke-dasharray="3 3"/><path class="a" d="M78 7A13 13 0 0 1 78 33"/><path class="b" d="M78 7A13 13 0 0 0 78 33" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M78 0A20 20 0 0 1 78 40"/><path class="b" d="M78 0A20 20 0 0 0 78 40" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M78 -7A27 27 0 0 1 78 47"/><path class="b" d="M78 -7A27 27 0 0 0 78 47" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M78 -14A34 34 0 0 1 78 54"/><path class="b" d="M78 -14A34 34 0 0 0 78 54" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M78 -21A41 41 0 0 1 78 61"/><path class="b" d="M78 -21A41 41 0 0 0 78 61" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M78 -28A48 48 0 0 1 78 68"/><path class="b" d="M78 -28A48 48 0 0 0 78 68" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M78 -35A55 55 0 0 1 78 75"/><path class="b" d="M78 -35A55 55 0 0 0 78 75" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M78 -42A62 62 0 0 1 78 82"/><path class="b" d="M78 -42A62 62 0 0 0 78 82" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M78 -49A69 69 0 0 1 78 89"/><path class="b" d="M78 -49A69 69 0 0 0 78 89" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M78 -56A76 76 0 0 1 78 96"/><path class="b" d="M78 -56A76 76 0 0 0 78 96" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M78 -63A83 83 0 0 1 78 103"/><path class="b" d="M78 -63A83 83 0 0 0 78 103" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M78 -70A90 90 0 0 1 78 110"/><path class="b" d="M78 -70A90 90 0 0 0 78 110" stroke-dasharray="3 3" stroke-dashoffset="36"/><rect class="t" stroke="none" x="20" y="60" width="64" height="40"/></g><circle class="t" cx="40" cy="36" r="2.6"/></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB