// Guará: interface. Fala com o núcleo por `rpc(método, parâmetros)`: // • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/ com token. import { createExplorer } from './explorer.js'; 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; } const isWin = /Windows/i.test(navigator.userAgent); const fileBase = tauri ? (isWin ? 'http://guara.localhost' : 'guara://localhost') : ''; const thumbUrl = (job, id, s = 256) => `${fileBase}/thumb/${job}/${id}?s=${s}` + (tauri ? '' : `&t=${encodeURIComponent(token || '')}`); const fileUrl = (job, id) => `${fileBase}/file/${job}/${id}` + (tauri ? '' : `?t=${encodeURIComponent(token || '')}`); // Atualiza o HTML só quando mudou (evita piscar e perder o foco/hover). function setHTML(el, html) { if (!el || el._h === html) return false; el._h = html; el.innerHTML = html; return true; } // ------------------------------------------------------------------ tema function applyTheme(t) { const root = document.documentElement; if (t === 'light' || t === 'dark') root.dataset.theme = t; else delete root.dataset.theme; document.querySelectorAll('[data-theme-pick]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.themePick === (t || 'auto')))); } try { applyTheme(localStorage.getItem('guara-theme') || 'auto'); } catch { applyTheme('auto'); } document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async () => { const t = b.dataset.themePick; applyTheme(t); try { localStorage.setItem('guara-theme', t); } catch { /* sem armazenamento */ } try { await rpc('settings_set', { theme: t }); } catch { /* segue só com o armazenamento local */ } })); // ------------------------------------------------------------------ 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' }) : 'sem data'); 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 askForm({ title, text, fields, ok = 'OK' }) { return new Promise((resolve) => { const d = $('#dlg'); d.innerHTML = `

${esc(title)}

${text || ''}

${fields.map((f) => ``).join('')}
`; d.onclose = () => { if (d.returnValue !== 'ok') return resolve(null); const out = {}; d.querySelectorAll('[name]').forEach((el) => (out[el.name] = el.value.trim())); resolve(out); }; d.showModal(); d.querySelector('[name]')?.focus(); }); } 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) { info({ title: 'Não foi possível abrir a sessão', html: `

    ${esc(e.message)}

    ` }); } }; // ------------------------------------------------------------------ início function renderHome() { main.classList.remove('fixed'); 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) { main.classList.remove('fixed'); 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, porque 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.

    Ferramentas de perícia

    Pesquisar texto no disco

    Procura palavras em todo o disco, inclusive em espaço livre e fora de partições (UTF-8 e UTF-16). Útil quando não há estrutura de arquivo.

    Hashes da origem

    Calcula MD5, SHA-1 e SHA-256 do disco inteiro para registrar a integridade e a cadeia de custódia no relatório.

    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); $('#goSearch')?.addEventListener('click', startSearch); $('#srchTerms')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') startSearch(); }); $('#goHash')?.addEventListener('click', startHash); } 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); } } async function startHash() { try { const { job } = await rpc('hash_start', { path: S.device.path }); watchAux(job, 'Calculando hashes da origem', (st) => { const r = st.report || {}; S.sourceHashes = { ...r, path: S.device.path }; return `

    Hashes do conteúdo inteiro de ${esc(S.device.path)}:

    MD5
    ${esc(r.md5)}
    SHA-1
    ${esc(r.sha1)}
    SHA-256
    ${esc(r.sha256)}

    Eles serão incluídos nos relatórios gerados nesta sessão.

    `; }); } catch (e) { toast(e.message); } } async function startSearch() { const terms = ($('#srchTerms')?.value || '').split(',').map((t) => t.trim()).filter(Boolean); if (!terms.length) { toast('Digite pelo menos um termo.'); $('#srchTerms')?.focus(); return; } try { const { job } = await rpc('search_start', { path: S.device.path, terms, ignore_case: true, utf16: true }); openSearch(job, terms); } catch (e) { toast(e.message); } } function openSearch(job, terms) { stopPoll(); EX?.destroy(); EX = null; S.job = null; main.classList.remove('fixed'); main.innerHTML = `

    Procurando…

    Termos: ${terms.map((t) => `${esc(t)}`).join(', ')}
    `; $('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome()); const termRe = new RegExp(terms.map((t) => esc(t).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'gi'); const tick2 = async () => { let st, res; try { st = await rpc('status', { job }); res = await rpc('search_results', { job, offset: 0, limit: 1000 }); } catch { return false; } const run = st.status === 'running'; const p = st.progress || {}; const pct = p.total ? Math.floor((p.done / p.total) * 100) : 0; $('#shTitle').textContent = run ? 'Procurando…' : `${res.total.toLocaleString('pt-BR')} ocorrência${res.total === 1 ? '' : 's'}`; setHTML($('#shProg'), run ? `
    ${pct}% · ${res.total.toLocaleString('pt-BR')} até agora
    ` : st.status === 'error' ? `
    ${warnSvg}
    ${esc(st.error)}
    ` : ''); setHTML($('#hits'), res.items.length ? `
    PosiçãoPartiçãoCodificaçãoContexto
    ` + res.items.map((h) => `
    ${h.offset.toLocaleString('pt-BR')}${h.partition ? '#' + h.partition : 'fora'}${esc(h.encoding)}${esc(h.context).replace(termRe, (m) => `${m}`)}
    `).join('') : (run ? '' : '

    Nada encontrado

    Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).

    ')); return run; }; $('#shProg').onclick = (e) => { if (e.target.dataset.stop) rpc('job_stop', { job }); }; (async () => { while (await tick2()) await new Promise((r) => setTimeout(r, 700)); })(); } // ------------------------------------------------------------------ varredura let EX = null; // explorador de arquivos (grade, navegação, detalhes) function openJob(s) { stopPoll(); EX?.destroy(); EX = null; S.job = s.job; S.status = null; renderSide(); renderScan(); tick(); S.poll = setInterval(tick, 500); } function stopPoll() { clearInterval(S.poll); S.poll = null; } 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; paintStatus(); EX?.onStatus(st); // continua consultando enquanto a varredura roda ou as datas/EXIF ainda estão sendo lidas if (st.status !== 'running' && !st.enriching) { stopPoll(); if (st.status === 'error') toast(st.error); } else if (!S.poll) { S.poll = setInterval(tick, 700); } } function renderScan() { main.classList.add('fixed'); main.innerHTML = `

    Procurando…

    `; $('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome()); $('#progressArea').onclick = onProgressClick; EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast }); } 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; const enr = st.enriching ? `Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString('pt-BR')}/${st.enriching.total.toLocaleString('pt-BR')}` : ''; const buttons = running ? '' : st.status === 'paused' ? '' : ''; const relink = st.source === false ? '' : ''; const tools = relink + (!running && st.found ? '' : ''); if (running || st.status === 'paused') { setHTML($('#progressArea'), `
    ${esc(p.phase || 'Iniciando')}${pct != null ? ` · ${pct}%` : ''}
    ${buttons}
    ${st.found.toLocaleString('pt-BR')}arquivos
    ${dur(st.elapsed)}decorrido${eta ? ' · faltam ~' + eta : ''}
    ${st.confidence.high}alta
    ${st.confidence.medium}média
    ${st.confidence.low}baixa
    `); } else { setHTML($('#progressArea'), `
    ${st.found.toLocaleString('pt-BR')} arquivos em ${dur(st.elapsed)} confiança alta ${st.confidence.high} · média ${st.confidence.medium} · baixa ${st.confidence.low}${enr}${tools}
    `); } setHTML($('#warnArea'), (st.warnings || []).map((w) => `
    ${warnSvg}
    ${esc(w)}
    `).join('') + (st.error ? `
    ${warnSvg}
    ${esc(st.error)}
    ` : '')); } async function onProgressClick(e) { const act = e.target.closest('[data-act]')?.dataset.act; if (!act) return; if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); } else if (act === 'stop') await rpc('scan_stop', { job: S.job }); else if (act === 'resume') { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); } else if (act === 'relink') { const path = await ask({ title: 'Escolher a origem', text: `Esta sessão foi feita sobre ${esc(S.status?.target || '')}. Informe o dispositivo ou a imagem do mesmo disco para pré-visualizar e recuperar.`, value: S.status?.target || '', ok: 'Usar esta origem' }); if (path) { try { await rpc('session_relink', { job: S.job, path }); toast('Origem reconectada'); const cur = { job: S.job, target: S.status.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' }; openJob(cur); } catch (err) { info({ title: 'Não foi possível usar essa origem', html: `

    ${esc(err.message)}

    ` }); } } } else if (act === 'csv') { const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' }); if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString('pt-BR')} linhas gravadas`); } catch (err) { toast(err.message); } } } else if (act === 'report') { const f = await askForm({ title: 'Relatório de recuperação', text: 'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.', ok: 'Gerar relatório', fields: [ { name: 'path', label: 'Salvar em', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-relatorio.html' }, { name: 'case_id', label: 'Número do caso' }, { name: 'examiner', label: 'Responsável pelo exame' }, { name: 'organization', label: 'Organização' }, { name: 'notes', label: 'Observações', multiline: true } ] }); if (f) { try { const hashes = S.sourceHashes && S.sourceHashes.path === (S.status?.target) ? { md5: S.sourceHashes.md5, sha1: S.sourceHashes.sha1, sha256: S.sourceHashes.sha256 } : null; await rpc('export_report', { job: S.job, path: f.path, case: { case_id: f.case_id, examiner: f.examiner, organization: f.organization, notes: f.notes }, hashes }); toast('Relatório gravado'); } catch (err) { toast(err.message); } } } else if (act === 'save') { 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 (err) { toast(err.message); } } } } 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'); try { const st = await rpc('settings_get'); if (st?.theme) { applyTheme(st.theme); try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ } } } catch { /* usa o local */ } tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); }); } 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(); })();