Files
sabia/apps/gui/ui/app.js
T
Lucas df27974359 App desktop (Tauri), GitHub Actions de CI/release, elevação automática
- Move a UI de crates/sabia-cli/src/web/ para apps/gui/ui/: fonte única
  servida tanto pelo `sabia ui` quanto pelo app desktop.
- sabia-core::engine::Engine centraliza sessões de scan/monitor/update —
  usado pelo servidor local da CLI e pelo app Tauri, sem duplicar lógica.
- apps/gui/src-tauri: app desktop Tauri 2, mesma interface, comandos IPC
  espelhando a API HTTP. Pede elevação de administrador/root
  automaticamente ao abrir (modo monitor e captura crua exigem):
  manifesto requireAdministrator no Windows (UAC antes mesmo do app
  abrir), pkexec no Linux e osascript no macOS, com reinício automático
  e aviso + botão de nova tentativa na interface se for negada.
- Ícone gerado a partir do logo (apps/gui/src-tauri/icons/source.png).
- .github/workflows/ci.yml: fmt, clippy, testes (Linux/Windows/macOS),
  auditoria de dependências, compilação do app desktop.
- .github/workflows/release.yml: ao empurrar uma tag vX.Y.Z, builda a
  CLI (Linux x86_64/aarch64, macOS x86_64/aarch64, Windows x86_64) e o
  app desktop pras mesmas plataformas, publica tudo numa release do
  GitHub com checksums SHA-256.
- sabia-core::update: corrige o nome do asset esperado (casava pelo
  nome fixo, mas o release empacota com a versão no nome) e a extração
  (tar.gz no Linux/macOS, zip no Windows).
- LICENSE-MIT, LICENSE-APACHE, CHANGELOG.md com a entrada 0.1.0.
- cargo fmt + clippy (-D warnings) limpos em todo o workspace.
2026-10-23 08:56:08 -03:00

258 lines
9.2 KiB
JavaScript

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'),
};
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() {
checkElevation();
const status = await api('/api/version');
els.verInfo.textContent = `v${status.version}`;
await loadIfaces();
animateWave();
poll();
})();