// Guará — interface. Fala com o núcleo por `rpc(método, parâmetros)`: // • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/ com token. const tauri = window.__TAURI__; const params = new URLSearchParams(location.search); let token = params.get('token'); try { if (token) sessionStorage.setItem('guara-token', token); else token = sessionStorage.getItem('guara-token'); } catch { /* armazenamento indisponível */ } async function rpc(method, p = {}) { if (tauri?.core?.invoke) return tauri.core.invoke('rpc', { method, params: p }); const r = await fetch(`/rpc/${method}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Guara-Token': token || '' }, body: JSON.stringify(p), }); const body = await r.json().catch(() => ({})); if (!r.ok) throw new Error(body.error || `erro ${r.status}`); return body; } // ------------------------------------------------------------------ utilidades const $ = (s, el = document) => el.querySelector(s); const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const human = (n) => { if (!n) return '0 B'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0, v = n; while (v >= 1024 && i < 4) { v /= 1024; i++; } return i === 0 ? `${n} B` : `${v.toFixed(v >= 100 ? 0 : 1)} ${u[i]}`; }; const dur = (s) => { s = Math.round(s); return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}min ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}min`; }; const date = (t) => (t ? new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }) : '—'); const CATS = [ ['image', 'Fotos'], ['document', 'Documentos'], ['video', 'Vídeos'], ['audio', 'Áudio'], ['archive', 'Compactados'], ['database', 'Bancos de dados'], ['other', 'Outros'], ]; const CONF = { high: ['alta', 'ok'], medium: ['média', 'warn'], low: ['baixa', 'bad'] }; const KIND_ICON = { USB: '', default: '', }; const icon = (kind) => `${KIND_ICON[kind] || KIND_ICON.default}`; const warnSvg = ''; function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('show'), 3600); } function ask({ title, text, value, placeholder, ok = 'OK', pick }) { return new Promise((resolve) => { const d = $('#dlg'); d.innerHTML = `

${esc(title)}

${text}

${pick && tauri?.dialog ? '' : ''}
`; const input = $('input', d); $('#pickBtn', d)?.addEventListener('click', async () => { const r = await tauri.dialog.open(pick); if (r) input.value = Array.isArray(r) ? r[0] : r; }); d.onclose = () => resolve(d.returnValue === 'ok' ? input.value.trim() : null); d.showModal(); input.focus(); input.select(); }); } function info({ title, html }) { const d = $('#dlg'); d.innerHTML = `

${esc(title)}

${html}
`; d.onclose = null; d.showModal(); } // ------------------------------------------------------------------ estado const S = { devices: [], defaults: {}, sessions: [], // {job, target, label} device: null, // {path,name,...} layout: null, job: null, // id da varredura atual status: null, filters: { category: null, minConfidence: null, query: '', sort: 'confidence' }, items: [], total: 0, selected: new Set(), allFiltered: false, current: null, // arquivo em foco (pré-visualização) poll: null, recJob: null, }; const main = $('#main'); const setMenu = (open) => { $('#app').classList.toggle('menu-open', open); $('#menuBtn').setAttribute('aria-expanded', String(open)); }; $('#menuBtn').onclick = () => setMenu(!$('#app').classList.contains('menu-open')); $('#scrim').onclick = () => setMenu(false); $('#refreshBtn').onclick = () => loadDevices(true); // ------------------------------------------------------------------ dispositivos async function loadDevices(manual) { try { S.devices = await rpc('devices'); } catch (e) { toast(e.message); } renderSide(); if (manual) toast('Lista atualizada'); } function renderSide() { const dl = $('#deviceList'); dl.dataset.empty = 'Nenhum disco encontrado'; dl.innerHTML = S.devices.map((d, i) => `
  • `).join(''); dl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openDevice(S.devices[+b.dataset.i]); setMenu(false); })); const sl = $('#sessionList'); sl.dataset.empty = 'Nenhuma ainda'; sl.innerHTML = S.sessions.map((s, i) => `
  • `).join(''); sl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openJob(S.sessions[+b.dataset.j]); setMenu(false); })); } $('#openImageBtn').onclick = async () => { setMenu(false); let path; if (tauri?.dialog) { path = await tauri.dialog.open({ multiple: false, directory: false }); if (Array.isArray(path)) path = path[0]; } else { path = await ask({ title: 'Abrir arquivo de imagem', text: 'Caminho completo de uma imagem de disco (.img, .dd, .iso…).', placeholder: '/caminho/para/disco.img', ok: 'Abrir' }); } if (path) openDevice({ path, name: path.split(/[\\/]/).pop(), kind: 'Imagem', size: 0, readable: true, mounted_at: [], removable: false }); }; $('#loadSessionBtn').onclick = async () => { setMenu(false); let path; if (tauri?.dialog) { path = await tauri.dialog.open({ multiple: false, filters: [{ name: 'Sessão do Guará', extensions: ['json'] }] }); if (Array.isArray(path)) path = path[0]; } else { path = await ask({ title: 'Abrir sessão salva', text: 'Caminho do arquivo de sessão (.json) criado pelo Guará.', placeholder: '/caminho/sessao.json', ok: 'Abrir' }); } if (!path) return; try { const { job } = await rpc('session_load', { path }); const st = await rpc('status', { job }); const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() }; S.sessions.unshift(s); openJob(s); } catch (e) { toast(e.message); } }; // ------------------------------------------------------------------ início function renderHome() { stopPoll(); S.device = null; S.job = null; renderSide(); main.innerHTML = `

    Encontre o que você achou que tinha perdido.

    Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo — arquivos apagados, partições perdidas e espaço não particionado — sem escrever nada no disco de origem.

    Uma varredura, tudo junto

    Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.

    Confiança por arquivo

    Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.

    Origem protegida

    Somente leitura. A recuperação sempre vai para outra pasta, com SHA-256 de tudo.

    ${S.devices.length ? '' : `
    ${warnSvg}
    Nenhum disco apareceu. Discos brutos exigem administrador. Rode sudo guara ui (Linux/macOS) ou abra o Guará como administrador (Windows). Você ainda pode abrir um arquivo de imagem pelo menu.
    `}
    `; } // ------------------------------------------------------------------ dispositivo async function openDevice(d) { stopPoll(); S.device = d; S.job = null; S.layout = null; renderSide(); main.innerHTML = `

    Lendo ${esc(d.name)}…

    `; main.scrollTop = 0; let layout = null, err = null; try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; } S.layout = layout; renderDevice(err); } function fsClass(p) { if (p.encrypted) return 'fs-enc'; const f = (p.fs || '').toLowerCase(); if (f.startsWith('fat')) return 'fs-fat'; return `fs-${f}` in { 'fs-ntfs': 1, 'fs-exfat': 1, 'fs-ext': 1, 'fs-apfs': 1, 'fs-hfsplus': 1 } ? `fs-${f}` : 'fs-unknown'; } const FS_NAME = { ntfs: 'NTFS', fat12: 'FAT12', fat16: 'FAT16', fat32: 'FAT32', exfat: 'exFAT', ext: 'ext2/3/4', apfs: 'APFS', hfsplus: 'HFS+', bitlocker: 'BitLocker', luks: 'LUKS', unknown: 'Desconhecido' }; function diskMap(layout) { const total = layout.size || 1; const segs = []; layout.partitions.forEach((p) => segs.push({ start: p.start, len: p.len, p })); layout.gaps.forEach((g) => segs.push({ start: g.start, len: g.len, gap: true })); segs.sort((a, b) => a.start - b.start); return ``; } function renderDevice(err) { const d = S.device, l = S.layout; const notes = []; if (!d.readable) notes.push(['bad', 'Este disco exige permissão de administrador. ' + (tauri ? '' : 'Feche e rode de novo com sudo guara ui (Linux/macOS) ou como administrador (Windows).')]); if (err) notes.push(['bad', esc(err)]); if (d.mounted_at?.length) notes.push(['warn', `Este disco está em uso (montado em ${esc(d.mounted_at.join(', '))}). O Guará só lê, mas evite gravar nele enquanto recupera — qualquer escrita pode sobrescrever o que você quer salvar.`]); if (d.trim) notes.push(['warn', 'Este SSD usa TRIM: o sistema avisa o disco quais blocos foram apagados e o próprio SSD pode zerá-los. Arquivos apagados há bastante tempo tendem a não ser recuperáveis.']); (l?.warnings || []).forEach((w) => notes.push(['warn', esc(w)])); main.innerHTML = `

    ${esc(d.name)}

    ${esc(d.kind)}${d.size ? ' · ' + human(d.size) : l ? ' · ' + human(l.size) : ''} · ${esc(d.path)}
    ${notes.map(([k, t]) => `
    ${warnSvg}
    ${t}
    `).join('')} ${l ? `

    Mapa do disco

    ${diskMap(l)}
    ${l.partitions.map((p) => `
    Partição ${p.index + 1} ${esc(FS_NAME[p.fs] || p.fs)} · ${human(p.len)}${p.label ? ' · ' + esc(p.label) : ''} ${p.lost ? 'encontrada por varredura' : ''}${p.encrypted ? `cifrada (${esc(p.encrypted)}) · só detecção` : ''}
    `).join('')} ${l.gaps.map((g) => `
    Espaço não particionado ${human(g.len)}
    `).join('')}
    ` : ''}

    Como procurar

    Recomendado

    Varredura profunda

    Lê os metadados e examina todo o espaço livre, partições danificadas e áreas não particionadas por assinatura. Encontra mais, leva mais tempo.

    Varredura rápida

    Só os registros do sistema de arquivos. Em segundos mostra arquivos apagados recentemente, com nome e pasta.

    Criar imagem do disco

    Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.

    Opções avançadas
    ${CATS.map(([k, n]) => ``).join('')}
    Sem nenhuma categoria marcada, todos os tipos são procurados.
    `; $('#elevate')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e)))); const opts = (mode) => ({ mode, include_allocated: $('#optAlloc')?.checked || false, categories: [...document.querySelectorAll('#optCats input:checked')].map((i) => i.value), }); $('#goDeep')?.addEventListener('click', () => startScan(opts('deep'))); $('#goQuick')?.addEventListener('click', () => startScan(opts('quick'))); $('#goImage')?.addEventListener('click', startImage); } async function startScan(options) { try { const { job } = await rpc('scan_start', { path: S.device.path, options }); const s = { job, target: S.device.path, label: S.device.name }; S.sessions.unshift(s); openJob(s); } catch (e) { toast(e.message); info({ title: 'Não foi possível iniciar', html: `

    ${esc(e.message)}

    ` }); } } async function startImage() { const base = (S.defaults.home || '') + (S.defaults.sep || '/'); const dest = await ask({ title: 'Criar imagem do disco', text: 'Onde salvar o arquivo de imagem? Use outro disco com espaço suficiente e um nome que ainda não exista.', value: base + 'disco-guara.img', ok: 'Criar imagem' }); if (!dest) return; try { const { job } = await rpc('image_start', { path: S.device.path, dest }); watchAux(job, 'Criando imagem do disco', (st) => { const r = st.report || {}; const bad = (r.bad_ranges || []).length; return `

    Imagem salva em ${esc(dest)} (${human(r.bytes)}).

    SHA-256: ${esc(r.sha256)}

    ${bad ? `
    ${warnSvg}
    ${bad} trecho(s) ilegível(is) foram preenchidos com zeros. Veja o mapa de erros ao lado da imagem.
    ` : '

    Nenhum setor ruim encontrado.

    '}`; }); } catch (e) { toast(e.message); } } // ------------------------------------------------------------------ varredura function openJob(s) { stopPoll(); S.job = s.job; S.filters = { category: null, minConfidence: null, query: '', sort: 'confidence' }; S.items = []; S.total = 0; S.selected = new Set(); S.allFiltered = false; S.current = null; S.status = null; renderSide(); renderScan(); tick(); S.poll = setInterval(tick, 500); main.scrollTop = 0; } function stopPoll() { clearInterval(S.poll); S.poll = null; } let lastFound = -1, lastFilterKey = ''; async function tick() { if (!S.job) return; let st; try { st = await rpc('status', { job: S.job }); } catch (e) { stopPoll(); toast(e.message); return; } S.status = st; const key = JSON.stringify(S.filters); if (st.found !== lastFound || key !== lastFilterKey) { lastFound = st.found; lastFilterKey = key; await loadResults(true); } paintStatus(); if (st.status !== 'running') { stopPoll(); if (st.status === 'error') toast(st.error); await loadResults(true); paintStatus(); } } async function loadResults(reset) { const f = S.filters; const query = { sort: f.sort, offset: reset ? 0 : S.items.length, limit: 200 }; if (f.category) query.category = f.category; if (f.minConfidence) query.min_confidence = f.minConfidence; if (f.query) query.query = f.query; const r = await rpc('results', { job: S.job, query }); S.items = reset ? r.items : S.items.concat(r.items); S.total = r.total; paintList(); } function renderScan() { main.innerHTML = `

    Procurando…

    `; $('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome()); let t; $('#q').oninput = (e) => { clearTimeout(t); t = setTimeout(() => { S.filters.query = e.target.value; resetSel(); tick(); }, 250); }; $('#conf').onchange = (e) => { S.filters.minConfidence = e.target.value || null; resetSel(); tick(); }; $('#sort').onchange = (e) => { S.filters.sort = e.target.value; tick(); }; paintPreview(); } const resetSel = () => { S.selected = new Set(); S.allFiltered = false; }; function paintStatus() { const st = S.status; if (!st || !$('#progressArea')) return; const running = st.status === 'running'; const p = st.progress || {}; const pct = p.total ? Math.min(100, Math.floor((p.done / p.total) * 100)) : null; $('#scanTitle').textContent = running ? 'Procurando…' : st.status === 'paused' ? 'Varredura pausada' : st.status === 'error' ? 'Algo deu errado' : st.status === 'stopped' ? 'Varredura interrompida' : 'Varredura concluída'; $('#scanSub').innerHTML = `${esc(st.target)}`; const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0; const eta = rate && running ? dur((p.total - p.done) / rate) : null; $('#progressArea').innerHTML = `
    ${esc(p.phase || (running ? 'Iniciando' : ''))}${pct != null ? ` · ${pct}%` : ''}
    ${running ? '' : st.status === 'paused' ? '' : ''} ${!running && st.found ? '' : ''}
    ${st.found.toLocaleString('pt-BR')}arquivos encontrados
    ${dur(st.elapsed)}decorrido${eta ? ' · faltam ~' + eta : ''}
    ${st.confidence.high}confiança alta
    ${st.confidence.medium}média
    ${st.confidence.low}baixa
    `; $('#pauseBtn')?.addEventListener('click', async () => { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); }); $('#stopBtn')?.addEventListener('click', async () => { await rpc('scan_stop', { job: S.job }); }); $('#resumeBtn')?.addEventListener('click', async () => { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }); $('#saveBtn')?.addEventListener('click', async () => { const path = await ask({ title: 'Salvar sessão', text: 'Guarda os resultados para recuperar depois, sem varrer de novo.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-sessao.json', ok: 'Salvar' }); if (path) { try { await rpc('session_save', { job: S.job, path }); toast('Sessão salva'); } catch (e) { toast(e.message); } } }); $('#warnArea').innerHTML = (st.warnings || []).map((w) => `
    ${warnSvg}
    ${esc(w)}
    `).join('') + (st.error ? `
    ${warnSvg}
    ${esc(st.error)}
    ` : ''); // chips const total = st.found; $('#chips').innerHTML = [``] .concat(CATS.filter(([k]) => st.categories[k]).map(([k, n]) => ``)).join(''); $('#chips').querySelectorAll('.chip').forEach((c) => (c.onclick = () => { S.filters.category = c.dataset.c || null; resetSel(); tick(); })); const sel = $('#conf'); if (sel && sel.value !== (S.filters.minConfidence || '')) sel.value = S.filters.minConfidence || ''; paintBar(); } function paintList() { const el = $('#table'); if (!el) return; const st = S.status; if (!S.items.length) { el.innerHTML = `

    ${st && st.status === 'running' ? 'Procurando…' : 'Nada por aqui'}

    ${st && st.status === 'running' ? 'Os arquivos aparecem aqui assim que são encontrados.' : (S.filters.category || S.filters.query || S.filters.minConfidence) ? 'Nenhum arquivo combina com os filtros.' : 'Não encontramos arquivos recuperáveis neste disco.'}

    `; return; } const allSel = S.items.every((i) => S.selected.has(i.id)) || S.allFiltered; el.innerHTML = `
    NomeTamanhoOrigemConfiança
    ` + S.items.map((i) => { const [cl, cc] = CONF[i.confidence]; const on = S.allFiltered || S.selected.has(i.id); return `
    ${esc(i.name)}${esc(i.dir || (i.origin === 'carved' ? 'Encontrado por assinatura' : '—'))}
    ${human(i.size)}
    ${i.origin === 'carved' ? 'assinatura' : esc(i.origin)}
    ${cl}
    `; }).join('') + (S.items.length < S.total ? `
    ` : ''); $('#selAll').onchange = (e) => { if (e.target.checked) S.items.forEach((i) => S.selected.add(i.id)); else resetSel(); paintList(); paintBar(); }; el.querySelectorAll('.tr').forEach((row) => { const id = +row.dataset.id; const cb = $('input', row); cb.onclick = (e) => { e.stopPropagation(); if (S.allFiltered) { S.allFiltered = false; S.items.forEach((i) => S.selected.add(i.id)); } cb.checked ? S.selected.add(id) : S.selected.delete(id); paintBar(); row.setAttribute('aria-selected', cb.checked); }; row.onclick = () => focusItem(S.items.find((i) => i.id === id)); row.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); focusItem(S.items.find((i) => i.id === id)); } }; }); $('#moreBtn')?.addEventListener('click', () => loadResults(false)); paintBar(); } async function focusItem(item) { S.current = item; document.querySelectorAll('.tr').forEach((r) => r.setAttribute('aria-selected', (+r.dataset.id === item.id) || S.selected.has(+r.dataset.id) || S.allFiltered)); paintPreview(); if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(item.ext) && item.size < 8 * 1024 * 1024) { try { const p = await rpc('preview', { job: S.job, id: item.id }); if (S.current?.id === item.id && p.mime) $('.thumb', $('#preview')).innerHTML = `Pré-visualização de ${esc(item.name)}`; } catch { /* sem prévia */ } } } function paintPreview() { const el = $('#preview'); if (!el) return; const i = S.current; if (!i) { el.innerHTML = `
    Selecione um arquivo para ver os detalhes
    `; return; } const [cl, cc] = CONF[i.confidence]; el.innerHTML = `
    ${esc(i.ext ? i.ext.toUpperCase() : 'ARQUIVO')}
    Nome
    ${esc(i.name)}
    Pasta
    ${esc(i.dir || '—')}
    Tamanho
    ${human(i.size)}
    Modificado
    ${date(i.modified)}
    Origem
    ${i.origin === 'carved' ? 'Assinatura de formato' : esc(i.origin.toUpperCase())}
    Confiança
    ${cl}
    ${i.note ? `
    Aviso
    ${esc(i.note)}
    ` : ''}
    `; $('#recOne').onclick = () => recover({ ids: [i.id] }); } function selCount() { return S.allFiltered ? S.total : S.selected.size; } function paintBar() { const host = $('#barHost'); if (!host) return; const st = S.status; const n = selCount(); const canAll = st && st.found > 0 && st.status !== 'error'; if (!n && !canAll) { host.innerHTML = ''; return; } const moreOffer = !S.allFiltered && S.selected.size > 0 && S.selected.size < S.total && S.items.length === S.selected.size ? `` : ''; host.innerHTML = `
    ${n ? `${n.toLocaleString('pt-BR')} selecionado(s)` : `${S.total.toLocaleString('pt-BR')} arquivo(s) na lista`}${moreOffer} ${n ? '' : ''}
    `; $('#selFiltered')?.addEventListener('click', () => { S.allFiltered = true; paintList(); }); $('#recSel')?.addEventListener('click', () => recover(S.allFiltered ? { filter: currentFilter() } : { ids: [...S.selected] })); $('#recAll')?.addEventListener('click', () => recover({ filter: currentFilter() })); } function currentFilter() { const f = S.filters; const q = { sort: f.sort, limit: 1 }; if (f.category) q.category = f.category; if (f.minConfidence) q.min_confidence = f.minConfidence; if (f.query) q.query = f.query; return q; } async function recover(sel) { const dest = await ask({ title: 'Recuperar para…', text: 'Escolha uma pasta em outro disco. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', ok: 'Recuperar', pick: { directory: true, multiple: false }, }); if (!dest) return; try { const { job } = await rpc('recover_start', { job: S.job, dest, ...sel }); watchAux(job, 'Recuperando arquivos', (st) => { const r = st.report || {}; const ok = (r.recovered || []).length, bad = (r.failed || []).length; return `

    ${ok} arquivo(s) salvos em ${esc(dest)} (${human(r.total_bytes)}).

    ${bad ? `
    ${warnSvg}
    ${bad} arquivo(s) não puderam ser gravados.
    ` : ''}

    O arquivo guara-manifesto.json lista o SHA-256 de cada arquivo para você conferir a integridade.

    `; }); } catch (e) { info({ title: 'Não foi possível recuperar', html: `

    ${esc(e.message)}

    ` }); } } // Modal de progresso de tarefas auxiliares (recuperação / imagem). function watchAux(job, title, doneHtml) { const d = $('#dlg'); d.onclose = null; const draw = (st) => { const p = st.progress || {}; const pct = p.total ? Math.floor((p.done / p.total) * 100) : 0; if (st.status === 'running') { d.innerHTML = `

    ${esc(title)}

    ${esc(p.phase || '')}

    ${pct}%

    `; $('#auxStop').onclick = () => rpc('job_stop', { job }); } else if (st.status === 'error') { d.innerHTML = `

    Algo deu errado

    ${warnSvg}
    ${esc(st.error)}
    `; } else { d.innerHTML = `

    ${st.status === 'stopped' ? 'Interrompido' : 'Pronto'}

    ${doneHtml(st)}
    `; } }; if (!d.open) d.showModal(); draw({ status: 'running', progress: {} }); const iv = setInterval(async () => { try { const st = await rpc('status', { job }); draw(st); if (st.status !== 'running') clearInterval(iv); } catch { clearInterval(iv); } }, 400); } async function openJobFromList(s) { openJob(s); } // ------------------------------------------------------------------ início do app (async function init() { try { const v = await rpc('version'); $('#versionTag').textContent = `v${v.version}`; S.defaults = await rpc('defaults'); } catch (e) { main.innerHTML = `
    ${warnSvg}
    Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando guara ui (ele inclui o token).
    `; return; } await loadDevices(); renderHome(); })();