Files
sabia/apps/gui/ui/app.js
T
Lucas RayanandClaude Opus 5.5 791538c9d9 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>
2026-10-23 12:46:19 -03:00

275 lines
9.8 KiB
JavaScript

import { setLang, currentLang } from './i18n.js';
const params = new URLSearchParams(location.search);
const token = params.get('token');
const tauri = window.__TAURI__;
const els = {
list: document.getElementById('ifaceList'),
ifaceSub: document.getElementById('ifaceSub'),
modePill: document.getElementById('modePill'),
modeLabel: document.getElementById('modeLabel'),
btnMonitor: document.getElementById('btnMonitor'),
btnScan: document.getElementById('btnScan'),
resultArea: document.getElementById('resultArea'),
count: document.getElementById('count'),
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;
const ICON_WIFI = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg>`;
const ICON_EMPTY = `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg>`;
async function api(path, opts) {
if (tauri) return tauriApi(path);
const url = new URL(path, location.origin);
url.searchParams.set('token', token);
const r = await fetch(url, opts);
return r.json();
}
// No app desktop não existe servidor HTTP: as mesmas rotas viram chamadas
// IPC diretas pro núcleo, pelos comandos registrados em src-tauri/src/main.rs.
async function tauriApi(path) {
const [p, qs] = path.split('?');
const q = new URLSearchParams(qs || '');
const invoke = tauri.core.invoke;
try {
switch (p) {
case '/api/version':
return { version: await invoke('version') };
case '/api/ifaces':
return await invoke('list_ifaces');
case '/api/monitor':
await invoke('set_monitor', { iface: q.get('iface'), enable: q.get('enable') === 'true' });
return { ok: true };
case '/api/scan/start':
await invoke('start_scan', { iface: q.get('iface') });
return { ok: true };
case '/api/scan/stop':
await invoke('stop_scan', { iface: q.get('iface') });
return { ok: true };
case '/api/scan':
return await invoke('scan_results', { iface: q.get('iface') });
case '/api/update/check':
return await invoke('update_check');
default:
return { error: 'rota desconhecida' };
}
} catch (e) {
return { ok: false, error: String(e) };
}
}
function renderEmptyResult(message, hint) {
els.resultArea.innerHTML = `<div class="ap-card empty-state">${ICON_EMPTY}<h2>${message}</h2><p>${hint}</p></div>`;
}
function renderIfaceList() {
els.list.innerHTML = '';
for (const i of ifaces) {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.className = 'dev';
btn.setAttribute('aria-current', String(i.name === current));
btn.innerHTML = `<span class="ico">${ICON_WIFI}</span><span><span class="t">${i.name}</span><span class="s">${i.mac}</span></span>`;
btn.addEventListener('click', () => selectIface(i.name));
li.appendChild(btn);
els.list.appendChild(li);
}
}
function selectIface(name) {
current = name;
scanning = false;
renderIfaceList();
refreshModeUi();
renderEmptyResult('Pronto pra capturar', 'Ative o modo monitor e inicie a captura pra ver as redes no ar.');
}
function refreshModeUi() {
const iface = ifaces.find((i) => i.name === current);
const on = !!iface && iface.mode === 'Monitor';
els.ifaceSub.textContent = current ? `${current} · ${iface?.mac ?? ''}` : 'selecione uma interface';
els.modeLabel.textContent = on ? 'modo monitor' : 'modo gerenciado';
els.modePill.className = 'pill' + (on ? ' on' : '');
els.btnMonitor.disabled = !current;
els.btnMonitor.textContent = on ? 'desativar modo monitor' : 'ativar modo monitor';
els.btnScan.disabled = !on;
}
async function loadIfaces() {
ifaces = await api('/api/ifaces');
if (!current && ifaces.length) current = ifaces[0].name;
renderIfaceList();
refreshModeUi();
if (current) renderEmptyResult('Pronto pra capturar', 'Ative o modo monitor e inicie a captura pra ver as redes no ar.');
else renderEmptyResult('Nenhuma interface Wi-Fi', 'Conecte um adaptador compatível com modo monitor e recarregue.');
}
els.btnMonitor.addEventListener('click', async () => {
const iface = ifaces.find((i) => i.name === current);
const enable = !(iface && iface.mode === 'Monitor');
els.btnMonitor.disabled = true;
const res = await api(`/api/monitor?iface=${encodeURIComponent(current)}&enable=${enable}`, { method: 'POST' });
if (!res.ok) {
alert('Não foi possível trocar o modo: ' + (res.error || 'erro desconhecido'));
}
await loadIfaces();
});
els.btnScan.addEventListener('click', async () => {
if (!scanning) {
await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
scanning = true;
els.btnScan.textContent = 'parar captura';
els.btnScan.classList.add('live');
els.wave.classList.add('scanning');
} else {
await api(`/api/scan/stop?iface=${encodeURIComponent(current)}`, { method: 'POST' });
scanning = false;
els.btnScan.textContent = 'iniciar captura';
els.btnScan.classList.remove('live');
els.wave.classList.remove('scanning');
}
});
els.btnUpdate.addEventListener('click', async () => {
els.btnUpdate.textContent = 'checando…';
const status = await api('/api/update/check');
els.btnUpdate.textContent = status.update_available
? `atualizar para ${status.latest_version}`
: 'já atualizado';
});
function secClass(sec) {
if (!sec) return '';
return 'sec-' + sec.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
}
function signalBars(dbm) {
// dBm tipicamente entre -30 (ótimo) e -90 (péssimo)
let level = 0;
if (dbm != null) {
if (dbm >= -55) level = 4;
else if (dbm >= -65) level = 3;
else if (dbm >= -75) level = 2;
else level = 1;
}
const heights = [5, 8, 11, 14];
let bars = '';
for (let i = 0; i < 4; i++) {
bars += `<i class="${i < level ? 'on' : ''}" style="height:${heights[i]}px"></i>`;
}
return `<span class="signal-bars">${bars}</span>`;
}
function renderResults(aps) {
els.count.textContent = aps.length;
if (!aps.length) {
renderEmptyResult('Escutando o ar…', 'Nenhuma rede vista ainda. Isso pode levar alguns segundos enquanto os canais são varridos.');
return;
}
const rows = aps.map((ap) => `
<tr>
<td class="ssid">${ap.ssid ? escapeHtml(ap.ssid) : '<em>oculta</em>'}</td>
<td class="bssid">${ap.bssid}</td>
<td>${ap.channel ?? '—'}</td>
<td>${signalBars(ap.last_signal_dbm)}${ap.last_signal_dbm != null ? ap.last_signal_dbm + ' dBm' : '—'}</td>
<td><span class="sec-tag ${secClass(ap.security)}">${ap.security || '—'}</span></td>
<td>${ap.clients.length}</td>
</tr>`).join('');
els.resultArea.innerHTML = `
<div class="ap-card">
<table>
<thead><tr><th>Rede</th><th>BSSID</th><th>Canal</th><th>Sinal</th><th>Segurança</th><th>Clientes</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
let waveT = 0;
function animateWave() {
waveT += 0.12;
if (scanning) {
let d = 'M0 17 ';
for (let x = 0; x <= 600; x += 12) {
const y = 17 + Math.sin(x * 0.045 + waveT) * 9 * Math.sin(waveT * 0.6 + x * 0.002);
d += `L${x} ${y.toFixed(1)} `;
}
document.getElementById('waveLive').setAttribute('d', d);
}
requestAnimationFrame(animateWave);
}
async function poll() {
if (scanning && current) {
const aps = await api(`/api/scan?iface=${encodeURIComponent(current)}`);
renderResults(aps);
}
setTimeout(poll, 1000);
}
async function checkElevation() {
if (!tauri) return; // via `sabia ui` a elevação é responsabilidade do terminal (sudo)
const notice = document.getElementById('elevateNotice');
const msg = document.getElementById('elevateMsg');
const retry = document.getElementById('btnElevateRetry');
const elevated = await tauri.core.invoke('is_elevated').catch(() => true);
if (elevated) return;
notice.hidden = false;
msg.textContent = 'Pedindo privilégio de administrador — necessário pra modo monitor e captura…';
retry.hidden = true;
retry.addEventListener('click', async () => {
msg.textContent = 'Pedindo privilégio de administrador — necessário pra modo monitor e captura…';
retry.hidden = true;
await tauri.core.invoke('relaunch_elevated').catch((e) => {
msg.textContent = String(e);
retry.hidden = false;
});
});
tauri.event?.listen?.('elevate-failed', (e) => {
msg.textContent = String(e.payload || 'Não foi possível obter privilégio de administrador.');
retry.hidden = false;
});
}
(async function init() {
applyLangPref();
checkElevation();
const status = await api('/api/version');
els.verInfo.textContent = `v${status.version}`;
await loadIfaces();
animateWave();
poll();
})();