${band.band === '2.4 GHz'
? 'Nessa banda, prefira sempre 1, 6 ou 11 — canais no meio sobrepõem vizinhos.'
: rec?.dfs
? 'Canais DFS liberam mais espaço, mas o rádio é obrigado a sair do ar se detectar radar — tenha um canal não-DFS de reserva.'
: 'Considere mudar o AP para o canal recomendado ou mover pra outra banda.'}
`);
}
function renderResults(aps) {
lastAps = aps;
updateReportButton();
if (viewMode === 'channels') {
renderChannels();
return;
}
if (!aps.length) {
els.count.textContent = '0';
if (lastSnapshot?.warning) {
renderEmptyResult('A placa não está capturando nada', lastSnapshot.warning);
} else {
renderEmptyResult('Escutando o ar…', 'Nenhuma rede vista ainda. Isso pode levar alguns segundos enquanto os canais são varridos.');
}
return;
}
const all = buildGroups(aps);
const groups = sortGroups(filterGroups(all));
const filtering = !!els.searchInput.value.trim();
els.count.textContent = filtering
? `${groups.length} de ${all.length} redes`
: `${all.length} redes · ${aps.length} BSSIDs · ${new Set(aps.flatMap((a) => a.clients || [])).size} clientes`;
if (!groups.length) {
renderEmptyResult('Nenhuma rede corresponde ao filtro', 'Ajuste a busca ou limpe o campo de filtro.');
return;
}
setResultsHtml(`${alertsHtml()}
`)
.join('');
}
function securityTag(sec) {
return `${escapeHtml(sec || '—')}`;
}
function hsBadges(hs) {
const out = [];
if (hs.complete) out.push('completo');
if (hs.crackable) out.push('quebrável');
if (hs.password) out.push(`senha: ${escapeHtml(hs.password)}`);
if (hs.pmkid) out.push('PMKID');
return out.join('');
}
function hsListHtml(hs) {
if (!hs.length) return '
`;
}
function riskHtml(ap) {
const notes = [];
if (ap.security === 'Open') {
notes.push(
'
Rede aberta. O tráfego não é cifrado pelo Wi-Fi; qualquer cliente pode se associar.
',
);
}
if (ap.security === 'WEP') {
notes.push(
'
WEP. A cifra é quebrável; capture IVs na aba Ataques.
',
);
}
if (ap.wps) {
notes.push(
`
WPS ativo${ap.wps_version ? ` (${escapeHtml(ap.wps_version)})` : ''}. ${ap.wps_locked ? 'O AP anuncia configuração travada.' : 'PIN pode estar acessível; verifique o lock.'}
`,
);
}
if (ap.pmf_required) {
notes.push(
'
PMF exigido. Desautenticação e captura de handshake podem ser ignoradas pelos clientes.
` : ''}`;
}
const EVENT_LABEL = {
first_seen: 'primeiro visto',
probe: 'probe',
associate: 'associou',
deauth: 'desautenticado',
};
/** Sparkline em tamanho real (não o mini de 62px da tabela), pra caber bem
* sozinho dentro do card de sinal do detalhe do cliente. */
function bigSparkline(history) {
const points = (history || []).slice(-60);
if (points.length < 2) return '
Sem amostras de sinal suficientes ainda.
';
const W = 560;
const H = 70;
const dbms = points.map((p) => p.dbm ?? -90);
const min = Math.min(...dbms, -90);
const max = Math.max(...dbms, -30);
const span = Math.max(1, max - min);
const d = points
.map((p, i) => {
const x = (i / (points.length - 1)) * W;
const y = H - (((p.dbm ?? min) - min) / span) * (H - 6) - 3;
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(' ');
return ``;
}
/** Visão completa de um cliente: tudo que o Sabiá já capturou sobre ele —
* identificação, rede, sinal e a linha do tempo de eventos — num painel só,
* em vez de ter que adivinhar olhando a linha compacta da tabela. */
function renderClientDetail(clients) {
const client = clients.find((c) => c.mac === attack.timelineMac);
if (!client) return '';
const events = (client.events || []).slice(-12).reverse();
const rows = events.length
? events.map((e) => `
${renderClientDetail(clients)}`;
}
function crackHtml() {
const info = attack.crackInfo;
if (!info) return '';
if (info.status === 'running') {
return `
concluído Senha não encontrada no dicionário (${report.tried ?? info.tried ?? 0} tentativas).
`;
}
function directionLabel(direction) {
return { ap: 'AP → cliente', sta: 'cliente → AP', both: 'ambas' }[direction] || direction;
}
function jobStatusLabel(status) {
return {
running: 'em execução',
starting: 'iniciando',
done: 'concluído',
error: 'erro',
stopped: 'parado',
}[status] || status;
}
function wpsOptions() {
const tools = attack.wpsTools || [];
if (!tools.length) return '';
return tools
.map((t) => ``)
.join('');
}
function wpsHtml() {
const tools = attack.wpsTools || [];
const job = attack.wpsInfo;
const running = !!job && (job.status === 'running' || job.status === 'starting');
const output = (job?.output || []).slice(-12).join('\n');
const result = job
? job.password
? `Senha encontrada: ${job.password}${job.pin ? ` (PIN ${job.pin})` : ''}`
: job.error
? `Falha: ${job.error}`
: `Status: ${jobStatusLabel(job.status)}${job.pin ? ` · PIN ${job.pin}` : ''}`
: tools.length
? 'O reaver embutido usa Pixie Dust sem PIN. O bully aparece se estiver instalado no sistema.'
: 'O reaver embutido não pôde ser extraído nesta execução.';
return `
WPS PIN
Orquestra reaver/bully na interface monitor. O canal do AP é usado automaticamente; o processo pode ser interrompido a qualquer momento.
${escapeHtml(result)}
${escapeHtml(output || 'A saída do reaver/bully aparece aqui.')}
`;
}
function eviltwinHtml() {
const job = attack.eviltwinInfo;
const running = !!job && job.status === 'running';
const attemptWord = { correct: 'senha certa', wrong: 'senha errada', invalid: 'tamanho inválido' };
const result = job
? job.password
? `Senha confirmada: ${job.password}`
: job.error
? `Falha: ${job.error}`
: `${jobStatusLabel(job.status)} · ${job.attempts ?? 0} tentativa(s) no portal${job.last_attempt ? ` · última: ${attemptWord[job.last_attempt] || job.last_attempt}` : ''}`
: 'Clona o AP (aberto) numa segunda interface e sobe um portal pedindo a senha de novo. A senha só é aceita se bater de verdade com o handshake já capturado — não é um "aceita qualquer coisa".';
return `
Evil Twin + portal cativo
Exige um handshake quebrável já capturado deste AP (aba Handshake). Precisa de hostapd instalado e de uma segunda interface Wi-Fi em modo AP, diferente da que está capturando.
${escapeHtml(result)}
`;
}
function enterpriseTwinHtml(ap) {
if (!ap || !(ap.akm || []).includes('802.1X')) return '';
const job = attack.entTwinInfo;
const running = !!job && job.status === 'running';
const result = job
? job.error
? `Falha: ${job.error}`
: `${jobStatusLabel(job.status)} · ${job.captured ?? 0} credencial(is) capturada(s)${job.last_identity ? ` · última: ${job.last_identity}` : ''}`
: 'Clona o AP numa segunda interface e deixa o hostapd-wpe aceitar a autenticação EAP de qualquer cliente. A credencial sai sozinha, como parte do próprio 802.1X — sem portal, sem ação humana.';
return `
Evil Twin Enterprise (802.1X)
Rede Enterprise detectada (AKM 802.1X) — aqui a credencial é enviada automaticamente pelo cliente durante o handshake EAP, não precisa de portal. Exige hostapd-wpe instalado (não vem com o Sabiá: é um patch no build do hostapd — github.com/aircrack-ng/hostapd-wpe) e uma segunda interface Wi-Fi em modo AP.
${escapeHtml(result)}
${job && job.captured ? `
Log de credenciais (p/ john --format=netntlm ou reformatar pro hashcat -m 5500): ${escapeHtml(job.log_path)}
` : ''}
Limitação honesta: o certificado TLS do clone é autoassinado. Dispositivo que valida a cadeia de certificado da rede (comum em perfil corporativo/MDM atualizado) vai recusar ou avisar o usuário — essa é a defesa que deveria existir. Funciona contra configuração sem essa validação.
`;
}
function deauthLiveText() {
const job = attack.deauthInfo;
if (!job) return 'Pronto. O job contínuo trava a captura no canal do AP e para quando você mandar.';
if (job.error) return `Falha: ${job.error}`;
const channel = job.channel != null ? ` · canal ${job.channel}` : '';
if (job.status === 'running' || job.status === 'starting') {
return `Job ${job.id} · ${job.frames_sent ?? 0} quadro(s)${channel}${job.stop_on_handshake ? ' · para sozinho no handshake' : ''}`;
}
if (job.handshake) return `Handshake capturado em ${job.frames_sent ?? 0} quadro(s)${channel}.`;
return `Job ${job.id} ${jobStatusLabel(job.status)} · ${job.frames_sent ?? 0} quadro(s)${channel}.`;
}
function deauthRunning() {
const status = attack.deauthInfo?.status;
return !!attack.deauthJob && (status === 'running' || status === 'starting');
}
function renderAttacks(ap, clients, hs) {
const crackable = hs.filter((h) => h.crackable);
const stationOptions = clients
.map((c) => ``)
.join('');
const hsOptions = hs
.map((h) => ``)
.join('');
const pmfStation = attack.station || clients[0]?.mac || '';
return `
Somente em redes próprias ou com autorização escrita. Cada ação fica registrada na trilha de auditoria local, com interface, BSSID e estação.
Desautenticação
Mantém os quadros saindo em série até você parar (ou até sair o handshake). O canal da captura fica travado no AP durante o job.
${attack.station ? 'Somente o cliente selecionado será desautenticado.' : 'Todos os clientes associados (broadcast).'}
${escapeHtml(deauthLiveText())}
PMF (802.11w)
Dispara deauth nas duas direções e observa se a estação reassocia. Resultado prático, não prova criptográfica.
PMKID clientless
Forja um MAC local e envia association request com PMKID para tentar a mensagem 1. Muitos APs só expõem PMKID em roaming real.
Handshake
${hs.length ? `${hs.length} handshake(s) visto(s); ${crackable.length} quebrável(is).` : 'Nenhum handshake capturado ainda. Desautentique um cliente para estimular a reassociação.'}
${hsListHtml(hs)}
${crackHtml()}
Força bruta por máscara — sem dicionário, testa toda combinação do padrão. ?d dígito, ?l minúscula, ?u maiúscula, ?s símbolo, ?a qualquer um; o resto é literal.
WEP não tem handshake: o ataque coleta IVs em um .pcap. Reinicie a captura gravando o arquivo e gere tráfego com desautenticação. A captura atual é reiniciada e a tabela começa vazia.
Depois de coletar IVs suficientes: aircrack-ng ${escapeHtml(attack.wepOut || 'captura.pcap')}
` : `
WEP
Esta rede não usa WEP.
`}
`;
}
function updateAuthUi() {
const needsStation = attack.direction !== 'ap' && !attack.station;
els.tabPanel.querySelectorAll('[data-needs-auth]').forEach((b) => {
const deauth = b.dataset.act === 'deauth' || b.dataset.act === 'deauth-handshake' || b.dataset.act === 'deauth-start';
b.disabled = !attack.authorized || b.dataset.locked === '1' || (deauth && needsStation);
});
const running = deauthRunning();
const start = els.tabPanel.querySelector('[data-act="deauth-start"]');
const stop = els.tabPanel.querySelector('[data-act="deauth-stop"]');
const burst = els.tabPanel.querySelector('[data-act="deauth"]');
const handshake = els.tabPanel.querySelector('[data-act="deauth-handshake"]');
if (start) start.disabled = running || !attack.authorized || needsStation;
if (stop) stop.disabled = !running;
if (burst) burst.disabled = running || !attack.authorized || needsStation;
if (handshake) handshake.disabled = running || !attack.authorized || needsStation;
}
async function ensureWpsTools() {
if (attack.wpsLoaded) return;
attack.wpsLoaded = true;
const tools = await api('/api/wps/tools');
attack.wpsTools = Array.isArray(tools) ? tools : [];
if (attack.wpsTools.length && !attack.wpsTools.some((t) => t.name === attack.wpsTool)) {
attack.wpsTool = attack.wpsTools[0].name;
}
if (activeTab === 'attacks') renderTarget(true);
}
function setResult(id, text, bad) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text;
el.classList.toggle('bad', !!bad);
}
function openTarget(bssid) {
const ap = findAp(bssid);
if (!ap) return;
if (attack.deauthJob) void stopDeauthJob();
selectedBssid = bssid;
activeTab = 'overview';
attack.deauthInfo = null;
attack.authorized = false;
attack.station = '';
attack.hsStation = '';
attack.crackJob = null;
attack.crackInfo = null;
attack.timelineMac = '';
const slug = bssid.replace(/[^0-9a-z]/gi, '');
attack.hsOut = `sabia-${slug}.pcap`;
attack.wepOut = `sabia-wep-${slug}.pcap`;
renderTarget(true);
if (!els.targetDlg.open) els.targetDlg.showModal();
void loadSnapshot();
}
async function loadSnapshot() {
if (!current) return;
const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
if (snap?.access_points) {
lastSnapshot = snap;
renderResults(snap.access_points);
if (selectedBssid) renderTarget(false);
}
}
// Enquanto startDeauthJob está religando a captura e esperando o AP
// reaparecer na varredura, a sessão nova responde vazia por alguns ciclos
// de poll — sem essa trava, renderTarget() interpreta "AP não visto ainda"
// como "AP não existe mais" e fecha o diálogo debaixo do usuário no meio
// da própria ação que ele acabou de pedir.
let resumingForBssid = null;
function renderTarget(force) {
const ap = findAp(selectedBssid);
if (!ap) {
if (els.targetDlg.open && selectedBssid !== resumingForBssid) els.targetDlg.close();
return;
}
if (!els.targetDlg.open) return;
const group = apsOfGroup(ap);
const clients = clientsFor(ap);
const hs = handshakesFor(ap);
els.targetTitle.textContent = ap.hidden || !ap.ssid ? 'Rede oculta' : ap.ssid;
const sub = [mono(ap.bssid)];
if (ap.vendor) sub.push(escapeHtml(ap.vendor));
sub.push(ap.channel != null ? `canal ${ap.channel}` : 'canal —');
if (ap.band) sub.push(escapeHtml(ap.band));
sub.push(securityTag(ap.security));
const selector = group.length > 1
? ``
: '';
els.targetSub.innerHTML = `${sub.join(' · ')}${selector}`;
els.tabClientsCount.textContent = String(clients.length);
els.tabPanel.dataset.tab = activeTab;
document.querySelectorAll('#targetDlg .tabs [data-tab]').forEach((b) => {
b.setAttribute('aria-selected', String(b.dataset.tab === activeTab));
});
if (activeTab === 'attacks' && !force) {
updateAttackLive(ap, hs);
return;
}
els.tabPanel.innerHTML =
activeTab === 'clients' ? renderClients(ap, clients) : activeTab === 'attacks' ? renderAttacks(ap, clients, hs) : renderOverview(ap, clients, hs);
if (activeTab === 'attacks') {
updateAuthUi();
void ensureWpsTools();
}
const sel = document.getElementById('bssidSel');
if (sel) sel.addEventListener('change', () => openTarget(sel.value));
const deauthSel = document.getElementById('deauthStation');
if (deauthSel) {
deauthSel.addEventListener('change', () => {
attack.station = deauthSel.value;
const scope = document.getElementById('deauthScope');
if (scope) {
scope.textContent = attack.direction === 'sta'
? 'Do cliente para o AP (exige estação selecionada).'
: attack.direction === 'both'
? 'Nas duas direções (exige estação selecionada).'
: attack.station
? 'Somente o cliente selecionado será desautenticado.'
: 'Todos os clientes associados (broadcast).';
}
updateAuthUi();
});
}
}
function updateAttackLive(ap, hs) {
const crackable = hs.filter((h) => h.crackable);
document.querySelectorAll('#tabPanel [data-act="export-hs"], #tabPanel [data-act="export-all"], #tabPanel [data-act="export-hc"], #tabPanel [data-act="export-all-hc"]').forEach((b) => {
if (hs.length) delete b.dataset.locked;
else b.dataset.locked = '1';
});
document.querySelectorAll('#tabPanel [data-act="crack"]').forEach((b) => {
if (crackable.length) delete b.dataset.locked;
else b.dataset.locked = '1';
});
const summary = document.getElementById('hsSummary');
if (summary) {
const crackable = hs.filter((h) => h.crackable);
summary.textContent = hs.length
? `${hs.length} handshake(s) visto(s); ${crackable.length} quebrável(is).`
: 'Nenhum handshake capturado ainda. Desautentique um cliente para estimular a reassociação.';
}
const list = document.getElementById('hsList');
if (list) list.innerHTML = hsListHtml(hs);
const progress = document.getElementById('crackProgress');
if (progress) progress.innerHTML = crackHtml();
const deauthResult = document.getElementById('deauthResult');
if (deauthResult) {
deauthResult.textContent = deauthLiveText();
deauthResult.classList.toggle('bad', !!attack.deauthInfo?.error);
}
updateAuthUi();
}
async function pollCrackJob() {
if (!attack.crackJob) return;
const job = await api(`/api/crack?id=${encodeURIComponent(attack.crackJob)}`);
if (!job || job.error) {
attack.crackJob = null;
attack.crackInfo = { status: 'error', error: job?.error || 'job não encontrado' };
const el = document.getElementById('crackProgress');
if (el) el.innerHTML = crackHtml();
return;
}
attack.crackInfo = job;
if (job.status === 'running') {
const el = document.getElementById('crackProgress');
if (el) el.innerHTML = crackHtml();
return;
}
attack.crackJob = null;
const el = document.getElementById('crackProgress');
if (el) el.innerHTML = crackHtml();
refreshAfterCrack();
}
async function refreshAfterCrack() {
if (!selectedBssid || !current) return;
const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
if (snap?.access_points) {
lastSnapshot = snap;
renderResults(snap.access_points);
renderTarget(true);
}
}
function updateDeauthUi() {
const el = document.getElementById('deauthResult');
if (el) {
el.textContent = deauthLiveText();
el.classList.toggle('bad', !!attack.deauthInfo?.error);
}
updateAuthUi();
}
async function waitForBssidChannel(bssid, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
const ap = snap?.access_points?.find((a) => a.bssid?.toLowerCase() === bssid?.toLowerCase());
if (ap?.channel != null) return true;
await new Promise((resolve) => setTimeout(resolve, 500));
}
return false;
}
async function startDeauthJob(handshake) {
// Fixa o alvo no início: o resto desta função faz esperas longas
// (religar a captura, aguardar a rede reaparecer na varredura), e nesse
// meio tempo o poll global pode não achar mais o AP na primeira resposta
// vazia da sessão recém-reiniciada e fechar o diálogo, zerando
// `selectedBssid`. Sem essa cópia local, o job acabava sendo disparado
// com bssid=null ("MAC inválido: null") mesmo depois de localizar a rede
// com sucesso.
const bssid = selectedBssid;
const station = document.getElementById('deauthStation')?.value || '';
const direction = document.getElementById('deauthDirection')?.value || attack.direction;
const frame = document.getElementById('deauthFrame')?.value || attack.frameKind;
const reason = Number(document.getElementById('deauthReason')?.value) || 7;
const interval = Number(document.getElementById('deauthInterval')?.value) || 200;
if (!handshake && direction !== 'ap' && !station) {
setResult('deauthResult', 'Escolha um cliente para desautenticar nas direções sta/both.', true);
return;
}
// Essa ação exige uma sessão de captura ativa (é o que guarda o handshake
// montado); sem isso o backend recusa com "inicie a captura antes do
// job". Em vez de obrigar o usuário a voltar e religar manualmente, retoma
// a captura aqui — do jeito que ele descreveu querer.
if (!scanning) {
const scanRes = await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
if (scanRes && scanRes.ok === false) {
setResult('deauthResult', `Não foi possível retomar a captura: ${scanRes.error || 'erro desconhecido'}`, true);
return;
}
scanning = true;
els.btnScan.textContent = 'parar captura';
els.btnScan.classList.add('live');
els.wave.classList.add('scanning');
// A captura acabou de reiniciar: a sessão ainda não sabe em qual canal
// este BSSID está, e o job travaria o canal errado (o canal em que o
// scanner por acaso já estava saltando) em vez do canal real do AP —
// os quadros de deauth saem no vácuo e o job termina sem efeito. Espera
// o AP reaparecer no snapshot (ele já esteve no ar, é questão de
// segundos de varredura) antes de disparar o job.
setResult('deauthResult', 'Retomando a captura e localizando a rede…');
resumingForBssid = bssid;
let found;
try {
found = await waitForBssidChannel(bssid, 20_000);
} finally {
resumingForBssid = null;
}
if (!found) {
setResult('deauthResult', 'A rede não reapareceu na varredura a tempo. Tente de novo.', true);
return;
}
}
const q = new URLSearchParams({ iface: current, bssid, confirm: 'true' });
if (station) q.set('station', station);
if (handshake) {
q.set('seconds', '30');
} else {
q.set('direction', direction);
q.set('frame', frame);
q.set('reason', String(reason));
q.set('interval_ms', String(interval));
}
const res = await api(`/api/${handshake ? 'handshakes/capture' : 'deauth/start'}?${q}`, { method: 'POST' });
if (res?.error || !res?.job) {
setResult('deauthResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
return;
}
attack.deauthJob = res.job;
attack.deauthInfo = {
id: res.job,
status: 'starting',
frames_sent: 0,
station,
direction: handshake ? 'ap' : direction,
frame_kind: handshake ? 'deauth' : frame,
stop_on_handshake: !!handshake,
};
setResult('deauthResult', handshake ? 'Forçando reassociação e handshake…' : 'Desautenticação contínua iniciada.');
updateDeauthUi();
void pollDeauth(res.job);
}
async function stopDeauthJob() {
const id = attack.deauthJob;
if (!id) return;
attack.deauthJob = null;
const res = await api(`/api/deauth/stop?id=${encodeURIComponent(id)}`, { method: 'POST' });
if (attack.deauthInfo) {
attack.deauthInfo.status = res?.error ? 'error' : 'stopped';
if (res?.error) attack.deauthInfo.error = res.error;
}
updateDeauthUi();
if (selectedBssid) void loadSnapshot();
}
async function pollDeauth(id) {
const job = await api(`/api/deauth/job?id=${encodeURIComponent(id)}`);
if (!job || job.error) {
if (attack.deauthJob !== id) return;
attack.deauthJob = null;
attack.deauthInfo = { id, status: 'error', error: job?.error || 'job não encontrado', frames_sent: 0 };
updateDeauthUi();
return;
}
if (attack.deauthJob !== id) return;
attack.deauthInfo = job;
updateDeauthUi();
if (job.status === 'running' || job.status === 'starting') {
setTimeout(() => void pollDeauth(id), 800);
return;
}
attack.deauthJob = null;
updateDeauthUi();
if (selectedBssid) void loadSnapshot();
}
async function sendDeauth(targetStation, count, reason, resultId) {
const q = new URLSearchParams({
iface: current,
bssid: selectedBssid,
count: String(count),
reason: String(reason),
direction: attack.direction,
frame: attack.frameKind,
confirm: 'true',
});
if (targetStation) q.set('station', targetStation);
const res = await api(`/api/deauth?${q}`, { method: 'POST' });
if (res?.error) setResult(resultId, `Falha: ${res.error}`, true);
else setResult(resultId, `${res.frames_sent ?? count} quadro(s) ${res.frame_kind || attack.frameKind} em ${directionLabel(res.direction || attack.direction)} para ${res.station || 'broadcast'} (motivo ${res.reason ?? reason}).`);
return res;
}
async function pollWps(id) {
const job = await api(`/api/wps?id=${encodeURIComponent(id)}`);
if (!job || job.error) {
attack.wpsInfo = { status: 'error', error: job?.error || 'job não encontrado', output: [] };
} else {
attack.wpsInfo = job;
}
const pre = document.getElementById('wpsOutput');
if (pre) pre.textContent = (attack.wpsInfo.output || []).slice(-12).join('\n') || 'Aguardando saída do reaver/bully…';
const res = document.getElementById('wpsResult');
if (res) {
res.textContent = attack.wpsInfo.password
? `Senha encontrada: ${attack.wpsInfo.password}${attack.wpsInfo.pin ? ` (PIN ${attack.wpsInfo.pin})` : ''}`
: attack.wpsInfo.error
? `Falha: ${attack.wpsInfo.error}`
: `Status: ${jobStatusLabel(attack.wpsInfo.status)}${attack.wpsInfo.pin ? ` · PIN ${attack.wpsInfo.pin}` : ''}`;
res.classList.toggle('bad', !!attack.wpsInfo.error);
}
const stop = document.querySelector('[data-act="wps-stop"]');
const start = document.querySelector('[data-act="wps-start"]');
const running = attack.wpsInfo.status === 'running' || attack.wpsInfo.status === 'starting';
if (stop) stop.disabled = !running;
if (start) start.disabled = !running ? !attack.authorized || !(attack.wpsTools || []).length : true;
if (running) setTimeout(() => void pollWps(id), 1500);
}
async function pollEvilTwin(id) {
const job = await api(`/api/eviltwin?id=${encodeURIComponent(id)}`);
attack.eviltwinInfo = !job || job.error && !job.status
? { status: 'error', error: job?.error || 'job não encontrado' }
: job;
const el = document.getElementById('eviltwinResult');
if (el) {
const attemptWord = { correct: 'senha certa', wrong: 'senha errada', invalid: 'tamanho inválido' };
const info = attack.eviltwinInfo;
el.textContent = info.password
? `Senha confirmada: ${info.password}`
: info.error
? `Falha: ${info.error}`
: `${jobStatusLabel(info.status)} · ${info.attempts ?? 0} tentativa(s) no portal${info.last_attempt ? ` · última: ${attemptWord[info.last_attempt] || info.last_attempt}` : ''}`;
el.classList.toggle('bad', !!info.error);
}
const running = attack.eviltwinInfo.status === 'running';
const start = document.querySelector('[data-act="eviltwin-start"]');
const stop = document.querySelector('[data-act="eviltwin-stop"]');
if (start) start.disabled = running;
if (stop) stop.disabled = !running;
if (running) setTimeout(() => void pollEvilTwin(id), 1500);
}
async function pollEnterpriseTwin(id) {
const job = await api(`/api/eviltwin-enterprise?id=${encodeURIComponent(id)}`);
attack.entTwinInfo = !job || job.error && !job.status
? { status: 'error', error: job?.error || 'job não encontrado' }
: job;
const el = document.getElementById('entTwinResult');
if (el) {
const info = attack.entTwinInfo;
el.textContent = info.error
? `Falha: ${info.error}`
: `${jobStatusLabel(info.status)} · ${info.captured ?? 0} credencial(is) capturada(s)${info.last_identity ? ` · última: ${info.last_identity}` : ''}`;
el.classList.toggle('bad', !!info.error);
}
const running = attack.entTwinInfo.status === 'running';
const start = document.querySelector('[data-act="enttwin-start"]');
const stop = document.querySelector('[data-act="enttwin-stop"]');
if (start) start.disabled = running;
if (stop) stop.disabled = !running;
if (running) setTimeout(() => void pollEnterpriseTwin(id), 1500);
}
els.targetClose.addEventListener('click', () => els.targetDlg.close());
els.targetDlg.addEventListener('close', () => {
if (attack.deauthJob) void stopDeauthJob();
selectedBssid = null;
});
document.querySelectorAll('#targetDlg .tabs [data-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
activeTab = btn.dataset.tab;
renderTarget(true);
});
});
els.tabPanel.addEventListener('click', async (e) => {
const jump = e.target.closest('[data-jump]');
if (jump) {
activeTab = jump.dataset.jump;
renderTarget(true);
return;
}
const timelineBtn = e.target.closest('[data-act="timeline"]');
if (timelineBtn) {
attack.timelineMac = timelineBtn.dataset.mac;
renderTarget(true);
return;
}
if (e.target.closest('[data-act="timeline-close"]')) {
attack.timelineMac = '';
renderTarget(true);
return;
}
const clientBtn = e.target.closest('[data-act="attack-client"]');
if (clientBtn) {
attack.station = clientBtn.dataset.mac;
activeTab = 'attacks';
renderTarget(true);
return;
}
const btn = e.target.closest('button[data-act]');
if (!btn || btn.disabled) return;
if (btn.dataset.needsAuth !== undefined && !attack.authorized) {
return;
}
const act = btn.dataset.act;
const ap = findAp(selectedBssid);
if (!ap) return;
if (act === 'deauth-start' || act === 'deauth-handshake') {
btn.disabled = true;
await startDeauthJob(act === 'deauth-handshake');
return;
}
if (act === 'deauth-stop') {
btn.disabled = true;
await stopDeauthJob();
return;
}
if (act === 'deauth') {
const station = document.getElementById('deauthStation')?.value || '';
const reason = Number(document.getElementById('deauthReason')?.value) || 7;
btn.disabled = true;
await sendDeauth(station, Math.min(256, Math.max(1, attack.count)), reason, 'deauthResult');
btn.disabled = !attack.authorized;
return;
}
if (act === 'export-hs' || act === 'export-all') {
const out = document.getElementById('hsOut')?.value?.trim();
if (!out) return setResult('exportResult', 'Informe o caminho do .pcap.', true);
const station = document.getElementById('hsStation')?.value || '';
const q = new URLSearchParams({ iface: current, out });
if (act === 'export-hs') {
if (!station) return setResult('exportResult', 'Selecione um handshake.', true);
q.set('bssid', selectedBssid);
q.set('station', station);
}
btn.disabled = true;
const res = await api(`/api/handshakes/${act === 'export-all' ? 'export-all' : 'export'}?${q}`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error) setResult('exportResult', `Falha: ${res.error}`, true);
else setResult('exportResult', `${res.handshakes ?? 0} handshake(s), ${res.frames ?? 0} quadro(s) em ${res.path}`);
return;
}
if (act === 'export-hc' || act === 'export-all-hc') {
const base = document.getElementById('hsOut')?.value?.trim();
if (!base) return setResult('exportResult', 'Informe o caminho base do arquivo.', true);
const out = base.replace(/\.(pcap|pcapng)$/i, '') + '.hc22000';
const station = document.getElementById('hsStation')?.value || '';
const q = new URLSearchParams({ iface: current, out });
if (act === 'export-hc') {
if (!station) return setResult('exportResult', 'Selecione um handshake.', true);
q.set('bssid', selectedBssid);
q.set('station', station);
}
btn.disabled = true;
const res = await api(`/api/handshakes/${act === 'export-hc' ? 'export-hc22000' : 'export-all-hc22000'}?${q}`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error) setResult('exportResult', `Falha: ${res.error}`, true);
else setResult('exportResult', `${res.hashes ?? 0} hash(es) de ${res.handshakes ?? 0} handshake(s) em ${res.path}`);
return;
}
if (act === 'pmkid') {
const attempts = Math.min(50, Math.max(1, Number(document.getElementById('pmkidAttempts')?.value) || 5));
btn.disabled = true;
setResult('pmkidResult', 'Enviando associação com PMKID…');
const res = await api(`/api/pmkid?iface=${encodeURIComponent(current)}&bssid=${encodeURIComponent(selectedBssid)}&attempts=${attempts}&confirm=true`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error) return setResult('pmkidResult', `Falha: ${res.error}`, true);
setResult('pmkidResult', res.pmkid
? `PMKID capturado: ${res.pmkid}`
: `${res.frames_sent ?? 0} quadro(s) enviados; PMKID não apareceu. ${res.detail || ''}`);
attack.pmkidInfo = res;
return;
}
if (act === 'pmf') {
const station = document.getElementById('pmfStation')?.value;
if (!station) return setResult('pmfResult', 'Nenhuma estação disponível.', true);
btn.disabled = true;
setResult('pmfResult', 'Testando por ~4 s…');
const res = await api(`/api/pmf?iface=${encodeURIComponent(current)}&bssid=${encodeURIComponent(selectedBssid)}&station=${encodeURIComponent(station)}&reason=7&confirm=true`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error) return setResult('pmfResult', `Falha: ${res.error}`, true);
const verdict = res.verdict === 'pmf_off' ? 'PMF não obrigatório' : 'Inconclusivo';
setResult('pmfResult', `${verdict} · ${res.detail}`);
attack.pmfInfo = res;
return;
}
if (act === 'wps-start') {
const tool = document.getElementById('wpsTool')?.value;
if (!tool) return setResult('wpsResult', 'Nenhuma ferramenta WPS disponível.', true);
if (ap.channel == null) return setResult('wpsResult', 'Canal do AP desconhecido.', true);
const pin = document.getElementById('wpsPin')?.value?.trim() || '';
const q = new URLSearchParams({ iface: current, bssid: selectedBssid, channel: String(ap.channel), tool, confirm: 'true' });
if (pin) q.set('pin', pin);
btn.disabled = true;
const res = await api(`/api/wps?${q}`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error || !res?.job) {
setResult('wpsResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
return;
}
attack.wpsJob = res.job;
attack.wpsInfo = { status: 'starting', output: [] };
setResult('wpsResult', 'Job iniciado…');
void pollWps(res.job);
return;
}
if (act === 'wps-stop') {
if (!attack.wpsJob) return;
btn.disabled = true;
const res = await api(`/api/wps/stop?id=${encodeURIComponent(attack.wpsJob)}`, { method: 'POST' });
if (res?.error) setResult('wpsResult', `Falha: ${res.error}`, true);
else setResult('wpsResult', 'Parada solicitada.');
return;
}
if (act === 'eviltwin-start') {
const apIface = document.getElementById('eviltwinIface')?.value?.trim() || '';
if (!apIface) return setResult('eviltwinResult', 'Informe a interface do clone (uma segunda placa).', true);
const port = Math.min(65535, Math.max(1, Number(document.getElementById('eviltwinPort')?.value) || 8080));
const deauth = document.getElementById('eviltwinDeauth')?.checked || false;
attack.eviltwinApIface = apIface;
attack.eviltwinPortalPort = port;
attack.eviltwinDeauth = deauth;
const q = new URLSearchParams({
iface: current, ap_iface: apIface, bssid: selectedBssid,
portal_port: String(port), deauth: String(deauth), confirm: 'true',
});
btn.disabled = true;
const res = await api(`/api/eviltwin?${q}`, { method: 'POST' });
if (res?.error || !res?.job) {
btn.disabled = !attack.authorized;
return setResult('eviltwinResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
}
attack.eviltwinJob = res.job;
attack.eviltwinInfo = { status: 'running', attempts: 0 };
void pollEvilTwin(res.job);
return;
}
if (act === 'eviltwin-stop') {
if (!attack.eviltwinJob) return;
btn.disabled = true;
const res = await api(`/api/eviltwin/stop?id=${encodeURIComponent(attack.eviltwinJob)}`, { method: 'POST' });
if (res?.error) setResult('eviltwinResult', `Falha: ${res.error}`, true);
else setResult('eviltwinResult', 'Parada solicitada.');
return;
}
if (act === 'enttwin-start') {
const apIface = document.getElementById('entTwinIface')?.value?.trim() || '';
if (!apIface) return setResult('entTwinResult', 'Informe a interface do clone (uma segunda placa).', true);
attack.entTwinApIface = apIface;
const q = new URLSearchParams({ iface: current, ap_iface: apIface, bssid: selectedBssid, confirm: 'true' });
btn.disabled = true;
const res = await api(`/api/eviltwin-enterprise?${q}`, { method: 'POST' });
if (res?.error || !res?.job) {
btn.disabled = !attack.authorized;
return setResult('entTwinResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
}
attack.entTwinJob = res.job;
attack.entTwinInfo = { status: 'running', captured: 0 };
void pollEnterpriseTwin(res.job);
return;
}
if (act === 'enttwin-stop') {
if (!attack.entTwinJob) return;
btn.disabled = true;
const res = await api(`/api/eviltwin-enterprise/stop?id=${encodeURIComponent(attack.entTwinJob)}`, { method: 'POST' });
if (res?.error) setResult('entTwinResult', `Falha: ${res.error}`, true);
else setResult('entTwinResult', 'Parada solicitada.');
return;
}
if (act === 'crack') {
const wordlist = document.getElementById('wordlist')?.value?.trim() || '';
const station = document.getElementById('hsStation')?.value || '';
const rules = document.getElementById('crackRules')?.checked || false;
attack.crackRules = rules;
const q = new URLSearchParams({ iface: current, bssid: selectedBssid, rules: String(rules) });
if (wordlist) q.set('wordlist', wordlist);
if (station) q.set('station', station);
btn.disabled = true;
const res = await api(`/api/crack?${q}`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error || !res?.job) {
setResult('exportResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
} else {
attack.crackJob = res.job;
attack.crackInfo = { status: 'running', tried: 0 };
const progress = document.getElementById('crackProgress');
if (progress) progress.innerHTML = crackHtml();
}
return;
}
if (act === 'crack-mask') {
const mask = document.getElementById('crackMask')?.value?.trim() || '';
if (!mask) return setResult('exportResult', 'Informe uma máscara (ex.: ?d?d?d?d?d?d?d?d).', true);
attack.crackMask = mask;
const station = document.getElementById('hsStation')?.value || '';
const q = new URLSearchParams({ iface: current, bssid: selectedBssid, mask });
if (station) q.set('station', station);
btn.disabled = true;
const res = await api(`/api/crack/mask?${q}`, { method: 'POST' });
btn.disabled = !attack.authorized;
if (res?.error || !res?.job) {
setResult('exportResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
} else {
attack.crackJob = res.job;
attack.crackInfo = { status: 'running', tried: 0 };
const progress = document.getElementById('crackProgress');
if (progress) progress.innerHTML = crackHtml();
}
return;
}
if (act === 'wep-restart') {
const out = document.getElementById('wepOut')?.value?.trim();
if (!out) return setResult('wepResult', 'Informe o caminho do .pcap.', true);
btn.disabled = true;
await api(`/api/scan/stop?iface=${encodeURIComponent(current)}`, { method: 'POST' });
const q = new URLSearchParams({ iface: current, pcap_out: out });
if (ap.channel != null) q.set('channel', String(ap.channel));
const res = await api(`/api/scan/start?${q}`, { method: 'POST' });
if (res?.error) {
setResult('wepResult', `Falha: ${res.error}`, true);
} else {
scanning = true;
lastSnapshot = null;
lastAps = [];
els.btnScan.textContent = 'parar captura';
els.btnScan.classList.add('live');
els.wave.classList.add('scanning');
setResult('wepResult', `Captura gravando em ${out}. Gere tráfego e aguarde IVs suficientes.`);
}
btn.disabled = !attack.authorized;
return;
}
if (act === 'wep-deauth') {
btn.disabled = true;
await sendDeauth('', 10, 7, 'wepResult');
btn.disabled = !attack.authorized;
return;
}
if (act === 'wep-copy') {
const out = document.getElementById('wepOut')?.value?.trim() || 'captura.pcap';
const cmd = `aircrack-ng ${out}`;
try {
await navigator.clipboard.writeText(cmd);
setResult('wepResult', 'Comando copiado.');
} catch {
setResult('wepResult', cmd);
}
}
});
els.tabPanel.addEventListener('input', (e) => {
const t = e.target;
if (t.id === 'deauthCount') attack.count = Number(t.value) || 1;
else if (t.id === 'hsOut') attack.hsOut = t.value;
else if (t.id === 'wordlist') attack.wordlist = t.value;
else if (t.id === 'crackMask') attack.crackMask = t.value;
else if (t.id === 'eviltwinIface') attack.eviltwinApIface = t.value;
else if (t.id === 'eviltwinPort') attack.eviltwinPortalPort = Number(t.value) || 8080;
else if (t.id === 'entTwinIface') attack.entTwinApIface = t.value;
else if (t.id === 'wepOut') attack.wepOut = t.value;
});
els.tabPanel.addEventListener('change', (e) => {
const t = e.target;
if (t.id === 'authChk') {
attack.authorized = t.checked;
updateAuthUi();
} else if (t.id === 'crackRules') attack.crackRules = t.checked;
else if (t.id === 'eviltwinDeauth') attack.eviltwinDeauth = t.checked;
else if (t.id === 'deauthStation') attack.station = t.value;
else if (t.id === 'deauthDirection') {
attack.direction = t.value;
const scope = document.getElementById('deauthScope');
if (scope) {
scope.textContent = attack.direction === 'sta'
? 'Do cliente para o AP (exige estação selecionada).'
: attack.direction === 'both'
? 'Nas duas direções (exige estação selecionada).'
: attack.station
? 'Somente o cliente selecionado será desautenticado.'
: 'Todos os clientes associados (broadcast).';
scope.classList.toggle('bad', attack.direction !== 'ap' && !attack.station);
}
updateAuthUi();
} else if (t.id === 'deauthFrame') attack.frameKind = t.value;
else if (t.id === 'deauthInterval') attack.intervalMs = Number(t.value) || 200;
else if (t.id === 'deauthReason') attack.reason = Number(t.value) || 7;
else if (t.id === 'hsStation') attack.hsStation = t.value;
else if (t.id === 'wpsTool') attack.wpsTool = t.value;
});
function reportUrl(iface, download) {
const url = new URL('/api/report.html', location.origin);
url.searchParams.set('iface', iface);
url.searchParams.set('token', token);
if (download) url.searchParams.set('download', '1');
return url.toString();
}
function reportCanvasSize(pages) {
reportState.pages = pages || 1;
const w = 794;
const h = Math.max(1123, reportState.pages * 1123);
els.reportCanvas.style.width = `${w}px`;
els.reportCanvas.style.height = `${h}px`;
els.reportFrame.style.width = `${w}px`;
els.reportFrame.style.height = `${h}px`;
applyReportZoom();
}
function applyReportZoom() {
els.reportCanvas.style.transform = `scale(${reportState.zoom})`;
els.reportZoomLabel.textContent = `${Math.round(reportState.zoom * 100)}%`;
}
async function openReport() {
if (!current) return;
reportState.iface = current;
els.reportStatus.textContent = 'Montando o relatório…';
if (!els.reportDlg.open) els.reportDlg.showModal();
if (tauri) {
const res = await api(`/api/report.html?iface=${encodeURIComponent(current)}`);
if (res?.error || !res?.html) {
els.reportStatus.textContent = `Falha: ${res?.error || 'não foi possível gerar o relatório'}`;
return;
}
els.reportFrame.removeAttribute('src');
els.reportFrame.srcdoc = res.html;
reportState.url = null;
} else {
const url = reportUrl(current, false);
reportState.url = url;
els.reportFrame.removeAttribute('srcdoc');
els.reportFrame.src = url;
}
reportCanvasSize(1);
}
function measureReport() {
let doc = null;
try { doc = els.reportFrame.contentDocument; } catch { return; }
if (!doc) return;
const pages = doc.querySelectorAll('.pagedjs_page').length;
if (pages) {
reportCanvasSize(pages);
els.reportStatus.textContent = `Pré-visualização A4 · ${pages} página(s).`;
return;
}
const height = Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight || 0);
if (height > 400) {
reportCanvasSize(Math.ceil(height / 1123));
els.reportStatus.textContent = 'Pré-visualização A4.';
}
}
function scheduleReportMeasure() {
if (reportMeasureTimer) clearInterval(reportMeasureTimer);
let attempts = 0;
const tick = () => {
attempts++;
measureReport();
if (attempts >= 12) {
clearInterval(reportMeasureTimer);
reportMeasureTimer = null;
}
};
reportMeasureTimer = setInterval(tick, 500);
setTimeout(tick, 300);
}
els.reportFrame.addEventListener('load', () => {
if (reportState.url) els.reportStatus.textContent = 'Paginando o relatório…';
scheduleReportMeasure();
});
window.addEventListener('message', (event) => {
const data = event.data;
if (!data || data.type !== 'sabia-report-ready') return;
reportCanvasSize(Number(data.pages) || 1);
els.reportStatus.textContent = `Pré-visualização A4 · ${reportState.pages} página(s).`;
});
els.btnReport.addEventListener('click', () => void openReport());
els.reportClose.addEventListener('click', () => els.reportDlg.close());
els.reportZoomIn.addEventListener('click', () => {
reportState.zoom = Math.min(1.5, Math.round((reportState.zoom + 0.1) * 10) / 10);
applyReportZoom();
});
els.reportZoomOut.addEventListener('click', () => {
reportState.zoom = Math.max(0.4, Math.round((reportState.zoom - 0.1) * 10) / 10);
applyReportZoom();
});
els.reportPrint.addEventListener('click', () => {
try {
els.reportFrame.contentWindow.focus();
els.reportFrame.contentWindow.print();
} catch {
els.reportStatus.textContent = 'Não foi possível abrir a impressão; baixe o HTML e abra no navegador.';
}
});
els.reportDownload.addEventListener('click', async () => {
if (!current) return;
if (tauri) {
els.reportStatus.textContent = 'Preparando o arquivo…';
const res = await api(`/api/report.html?iface=${encodeURIComponent(current)}&download=true`);
if (!res?.html) {
els.reportStatus.textContent = `Falha: ${res?.error || 'não foi possível gerar o HTML'}`;
return;
}
const blob = new Blob([res.html], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `sabia-${current.replace(/[^0-9a-z]/gi, '')}.html`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 2000);
els.reportStatus.textContent = 'HTML baixado — abre offline e imprime em A4.';
} else {
window.open(reportUrl(current, true), '_blank', 'noopener');
}
});
els.reportDlg.addEventListener('close', () => {
if (reportMeasureTimer) clearInterval(reportMeasureTimer);
reportMeasureTimer = null;
if (!tauri) els.reportFrame.removeAttribute('src');
else els.reportFrame.removeAttribute('srcdoc');
});
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function escapeAttr(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
}[c]));
}
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 snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
if (snap?.access_points && snap.running === false) {
// A sessão existe e respondeu normal (200), mas o próprio engine
// marcou running=false: a thread de captura morreu sozinha (erro de
// I/O, adaptador caiu da USB, etc.). Sem isso, a UI continuava achando
// que ainda estava capturando e nunca contava o motivo real.
scanning = false;
els.btnScan.textContent = 'iniciar captura';
els.btnScan.classList.remove('live');
els.wave.classList.remove('scanning');
lastSnapshot = snap;
renderResults(snap.access_points);
alert('A captura parou sozinha: ' + (snap.error || 'motivo desconhecido — confira se o adaptador ainda está conectado.'));
} else if (snap?.access_points) {
lastSnapshot = snap;
renderResults(snap.access_points);
if (selectedBssid) renderTarget(false);
} else if (snap && snap.ok === false) {
// A captura morreu no meio (interface saiu do monitor, processo externo
// tomou o adaptador, etc.). Antes disso caía no fallback de baixo, que
// sempre responde 200 com lista vazia — a UI continuava "escutando o
// ar" indefinidamente sem nenhuma rede aparecer e sem avisar por quê.
scanning = false;
els.btnScan.textContent = 'iniciar captura';
els.btnScan.classList.remove('live');
els.wave.classList.remove('scanning');
alert('A captura parou: ' + (snap.error || 'erro desconhecido'));
} else {
const aps = await api(`/api/scan?iface=${encodeURIComponent(current)}`);
renderResults(aps);
}
}
if (attack.crackJob) pollCrackJob();
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();
setView(viewMode);
checkElevation();
const status = await api('/api/version');
els.verInfo.textContent = `v${status.version}`;
await loadIfaces();
animateWave();
poll();
})();