- 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.
258 lines
9.2 KiB
JavaScript
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();
|
|
})();
|