// Guará: UI. Communicates with the core via `rpc(method, params)`: // • Tauri app: invoke('rpc') • `guara ui` mode: POST /rpc/ with token. import { createExplorer } from './explorer.js'; import { setLang, LOC, currentLang } from './i18n.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 { /* storage unavailable */ } 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 || '')}`); // Update HTML only when changed (prevents flickering and losing focus/hover). function setHTML(el, html) { if (!el || el._h === html) return false; el._h = html; el.innerHTML = html; return true; } // ------------------------------------------------------------------ theme 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 { /* no storage */ } saveSettings({ theme: t }); })); // ------------------------------------------------------------------ settings // (declared before use: `S` is defined further below, so the object lives here) const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true }; async function saveSettings(patch) { Object.assign(SETTINGS, patch); try { await rpc('settings_set', SETTINGS); } catch { /* continue with local storage only */ } } // ------------------------------------------------------------------ utilities 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(LOC(), { 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.querySelectorAll('[data-pick]').forEach((b) => (b.onclick = async () => { const f = fields.find((x) => x.name === b.dataset.pick); const r = await tauri.dialog.open(f.pick); if (r) d.querySelector(`[name="${f.name}"]`).value = Array.isArray(r) ? r[0] : r; })); 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 = { elevated: true, devices: [], defaults: {}, sessions: [], // {job, target, label} device: null, // {path,name,...} layout: null, job: null, // active scan job id status: null, filters: { category: null, minConfidence: null, query: '', sort: 'confidence' }, items: [], total: 0, selected: new Set(), allFiltered: false, current: null, // focused file (preview) 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); })); const rl = $('#recentList'); rl.dataset.empty = 'Nada salvo ainda'; rl.innerHTML = (S.recent || []).map((r, i) => `
  • `).join(''); rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r]))); } async function loadRecent() { try { S.recent = await rpc('sessions_list'); } catch { S.recent = []; } renderSide(); } async function openRecent(r) { setMenu(false); try { const { job } = await rpc('session_load', { path: r.path }); const st = await rpc('status', { job }); let s = S.sessions.find((x) => x.job === job); if (!s) { 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)}

    ` }); } } $('#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 }); let s = S.sessions.find((x) => x.job === job); if (!s) { 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)}

    ` }); } }; // ------------------------------------------------------------------ home 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.elevated ? `
    ${warnSvg}
    Este disco exige permissão de administrador.
    ` : 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.
    `}
    `; $('#elevateHome')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e)))); } // ------------------------------------------------------------------ device 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; S.health = null; try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; } S.layout = layout; renderDevice(err); // Query SMART health in the background without blocking the screen. rpc('health', { path: d.path }).then((h) => { if (S.device === d) { S.health = h; paintHealth(); } }).catch(() => {}); } const HEALTH_WORD = { ok: 'Bom', warning: 'Atenção', failing: 'Ruim' }; function paintHealth() { const el = $('#healthArea'); const h = S.health; if (!el || !h) { if (el) el.innerHTML = ''; return; } if (!h.available) { el.innerHTML = `

    Saúde do disco

    ${esc(h.summary)}

    `; return; } const bad = h.status === 'failing' || h.status === 'warning'; const fmtN = (n) => Number(n).toLocaleString(LOC()); const tile = (label, value, cls = '') => value == null ? '' : `
    ${label}${value}
    `; const temp = h.temperature_c; const tiles = [ `
    Estado${HEALTH_WORD[h.status] || '?'}${h.health_pct != null ? ` ${h.health_pct}%` : ''}
    `, tile('Temperatura', temp == null ? null : `${temp} °C`, temp >= 60 ? 'failing' : temp >= 50 ? 'warning' : ''), tile('Horas ligado', h.power_on_hours == null ? null : `${fmtN(h.power_on_hours)} h`), tile('Vezes ligado', h.power_cycles == null ? null : fmtN(h.power_cycles)), tile('Total lido', h.host_reads == null ? null : human(h.host_reads)), tile('Total gravado', h.host_writes == null ? null : human(h.host_writes)), ].join(''); const info = (h.info || []).map(([k, v]) => `
    ${esc(k)}
    ${esc(v)}
    `).join(''); const dash = (x) => x == null ? '-' : x; const attrs = h.attributes || []; const table = attrs.length ? `
    Atributos SMART (${attrs.length})
    ${attrs.map((a) => ``).join('')}
    IDAtributoAtualPiorLimiteBruto
    ${esc(a.id)}${esc(a.name)}${dash(a.value)}${dash(a.worst)}${dash(a.thresh)}${esc(a.raw)}
    ` : ''; const scope = h.partition ? `

    O SMART é do disco inteiro: os dados abaixo são de ${esc(h.disk)}, que contém ${esc(h.partition)}.

    ` : ''; // Fields that triggered the alert (standard summary fields appear below). const known = new Set(['Modelo', 'Nº de série', 'Horas ligado', 'Temperatura', 'Autoavaliação SMART', 'Vida útil usada']); const why = (h.details || []).filter(([k]) => !known.has(k)).map(([k, v]) => `
    ${esc(k)}
    ${esc(v)}
    `).join(''); el.innerHTML = `${bad ? `
    ${warnSvg}
    ${esc(h.summary)} ${why ? `
    ${why}
    ` : ''} ${(h.advice || []).map((a) => `
    ${esc(a)}
    `).join('')}
    ` : ''}

    Saúde do disco

    ${scope}
    ${tiles}
    ${info ? `
    ${info}
    ` : ''}${table}
    `; $('#healthImg')?.addEventListener('click', startImage); } /** Disk showing signs of failure: offer to create a disk image before scanning. */ function confirmRisky() { return new Promise((resolve) => { const h = S.health; const d = $('#dlg'); d.innerHTML = `

    Este disco parece estar com defeito

    ${esc(h.summary)}

    ${(h.advice || []).map((a) => `

    ${esc(a)}

    `).join('')}
    `; d.onclose = () => resolve(d.returnValue === 'image' ? 'image' : d.returnValue === 'scan' ? 'scan' : null); d.showModal(); }); } 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.
    Volumes cifrados, RAID e vídeos
    `; $('#elevate')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e)))); const opts = (mode) => ({ mode, assembly: (() => { const v = (id) => ($(id)?.value || '').trim(); const secrets = []; if (v('#optPw')) secrets.push({ kind: 'password', value: v('#optPw') }); if (v('#optRk')) secrets.push({ kind: 'recovery', value: v('#optRk') }); if (v('#optKf')) secrets.push({ kind: 'keyfile', path: v('#optKf') }); return { secrets, members: v('#optMembers').split('\n').map((x) => x.trim()).filter(Boolean), raid: $('#optRaid')?.checked ? {} : null, remember_keys: $('#optRemember')?.checked || false, }; })(), references: $('#optRef')?.value.trim() ? [$('#optRef').value.trim()] : [], 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); paintHealth(); $('#goSearch')?.addEventListener('click', startSearch); $('#srchTerms')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') startSearch(); }); $('#goHash')?.addEventListener('click', startHash); } async function startScan(options) { if (S.health && (S.health.status === 'failing' || S.health.status === 'warning') && options.mode === 'deep' && !S.healthAck?.[S.device.path]) { const c = await confirmRisky(); if (c === 'image') return startImage(); if (!c) return; (S.healthAck ??= {})[S.device.path] = true; } 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 f = await askForm({ 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.', ok: 'Criar imagem', fields: [ { name: 'dest', label: 'Arquivo de destino', value: base + 'disco-guara.img' }, { name: 'format', label: 'Formato da imagem', value: 'raw', options: [['raw', 'Bruta (.img)'], ['e01', 'Expert Witness (.E01, com hash e metadados)'], ['aff4', 'AFF4 (.aff4)']] }, { name: 'case', label: 'Número do caso (E01/AFF4)', value: '' }, { name: 'examiner', label: 'Examinador (E01/AFF4)', value: '' }, ] }); if (!f || !f.dest) return; const dest = f.dest; const format = f.format === 'e01' ? { kind: 'e01', case_number: f.case, examiner: f.examiner } : f.format === 'aff4' ? { kind: 'aff4', case_number: f.case, examiner: f.examiner } : { kind: 'raw' }; try { const { job } = await rpc('image_start', { path: S.device.path, dest, format }); 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(LOC())} ocorrência${res.total === 1 ? '' : 's'}`; setHTML($('#shProg'), run ? `
    ${pct}% · ${res.total.toLocaleString(LOC())} 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(LOC())}${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)); })(); } // ------------------------------------------------------------------ scan let EX = null; // file explorer (grid, navigation, details) 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); // Keep polling while the scan is running or dates/EXIF are still being enriched. if (st.status !== 'running' && !st.enriching) { stopPoll(); loadRecent(); 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, noInlineMedia: !!tauri && /Linux/i.test(navigator.userAgent), getSettings: () => SETTINGS }); } 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(LOC())}/${st.enriching.total.toLocaleString(LOC())}` : ''; 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(LOC())}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(LOC())} 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 chooseSource() { let cands = []; try { cands = await rpc('session_candidates', { job: S.job }); } catch { /* fall back to manual path input */ } const st = S.status || {}; const size = st.layout?.size; const d = $('#dlg'); const badge = (c) => (c.same_content === true ? 'mesmo disco' : c.same_content === false ? 'tamanho igual, conteúdo diferente' : c.same_size ? 'mesmo tamanho (precisa de administrador para conferir)' : ''); d.innerHTML = `

    Escolher a origem

    Esta sessão foi feita sobre ${esc(st.target || '')}${size ? ` (${human(size)})` : ''}. Escolha o disco que voltou ou uma imagem dele.

    ${cands.map((c, i) => ``).join('') || '

    Nenhum disco encontrado. Informe o caminho abaixo.

    '}
    `; d.querySelectorAll('.cand .dev').forEach((b) => (b.onclick = () => { d.querySelectorAll('.cand .dev').forEach((x) => x.setAttribute('aria-current', 'false')); b.setAttribute('aria-current', 'true'); $('#srcPath', d).value = cands[+b.dataset.c].device.path; })); d.onclose = async () => { if (d.returnValue !== 'ok') return; const path = $('#srcPath', d).value.trim(); const force = d.querySelector('[name=force]').checked; if (!path) return; try { await rpc('session_relink', { job: S.job, path, force }); toast('Origem reconectada'); const cur = { job: S.job, target: st.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)}

    ` }); } }; d.showModal(); } 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') { try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); } catch (err) { toast(err.message); chooseSource(); } } else if (act === 'reconnect') { try { const r = await rpc('session_reconnect', { job: S.job }); toast(r.renamed ? `Disco encontrado como ${r.renamed}` : 'Disco reconectado'); const cur = { job: S.job, target: S.status.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' }; openJob(cur); } catch { chooseSource(); } } else if (act === 'relink') { chooseSource(); } 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(LOC())} 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 pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados. Termine o nome em .pdf para gerar PDF; com .html sai uma página web.', 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 f = await askForm({ 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. Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.', ok: 'Recuperar', fields: [ { name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } }, { name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] }, ], }); if (!f || !f.dest) return; const dest = f.dest; try { const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout, ...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)}).

    ${r.skipped ? `

    ${r.skipped} já estavam recuperados e conferidos (pulados).

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

    O arquivo guara-manifesto.json lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.

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

    ${esc(e.message)}

    ` }); } } // Progress modal for auxiliary jobs (recovery / disk imaging). 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); } // ------------------------------------------------------------------ settings modal $('#settingsBtn')?.addEventListener('click', () => { const d = $('#dlg'); d.innerHTML = `

    Configurações

    ${tauri?.core?.invoke ? `` : ''}
    `; d.querySelector('[name=lang]').value = SETTINGS.lang; d.querySelector('#updNow')?.addEventListener('click', (e) => { e.preventDefault(); checkUpdate(true); }); d.onclose = () => { if (d.returnValue !== 'ok') return; const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked }; if (d.querySelector('[name=updates]')) patch.updates = d.querySelector('[name=updates]').checked; setLang(patch.lang); patch.ui_lang = currentLang(); saveSettings(patch); EX?.relang?.(); toast('Salvo'); }; d.showModal(); }); // ------------------------------------------------------------------ app updater // The desktop shell (Tauri) queries `latest.json` from the Gitea release, verifies the // package signature against the embedded public key, installs, and relaunches. No-op in browser mode (`guara ui`). let UPD = null; async function checkUpdate(manual) { if (!tauri?.core?.invoke) return; try { UPD = await tauri.core.invoke('update_check'); renderUpdateCard(); if (manual) toast(UPD ? `Nova versão ${UPD.version} disponível` : 'Você já está na versão mais recente'); } catch (e) { if (manual) toast(`Não foi possível verificar atualizações: ${e}`); } } function renderUpdateCard(state) { let el = $('#updateCard'); if (!UPD) { el?.remove(); return; } if (!el) { el = document.createElement('div'); el.id = 'updateCard'; el.className = 'update-card'; el.setAttribute('role', 'status'); document.body.appendChild(el); } if (state === 'busy') return; el.innerHTML = `
    Nova versão do Guará disponívelv${esc(UPD.current)} → v${esc(UPD.version)}
    ${UPD.auto ? '' : '

    Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:

    ' + esc(UPD.url) + '

    '}
    ${UPD.auto ? '' : ''} ${UPD.notes ? '' : ''}
    `; $('#updGo')?.addEventListener('click', installUpdate); $('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); }); $('#updNotes')?.addEventListener('click', () => info({ title: `Guará ${UPD.version}`, html: `
    ${esc(UPD.notes)}
    ` })); } async function installUpdate() { const el = $('#updateCard'); el.innerHTML = `
    Atualizando o Guará…

    Baixando a atualização…

    `; renderUpdateCard('busy'); try { await tauri.core.invoke('update_install'); } catch (e) { toast(String(e)); renderUpdateCard(); } } function updateProgress(p) { const bar = $('#updBar'); const msg = $('#updMsg'); if (!bar || !msg) return; if (p.installing) { bar.classList.add('indet'); msg.textContent = 'Instalando… o Guará vai reabrir sozinho.'; return; } if (p.total) { const pct = Math.min(100, Math.round((p.downloaded / p.total) * 100)); bar.classList.remove('indet'); bar.firstElementChild.style.width = `${pct}%`; msg.textContent = `Baixando… ${pct}%`; } } // ------------------------------------------------------------------ close while scan is running function askClose() { return new Promise((resolve) => { const d = $('#dlg'); d.innerHTML = `

    Há uma varredura em andamento

    A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.

    `; d.onclose = () => resolve(d.returnValue); d.showModal(); }); } function watchClose() { window.addEventListener('beforeunload', (e) => { if (S.status?.status === 'running' && !tauri) { e.preventDefault(); e.returnValue = ''; } }); const win = tauri?.window?.getCurrentWindow?.(); if (!win?.onCloseRequested) return; win.onCloseRequested(async (ev) => { if (S.status?.status !== 'running') return; ev.preventDefault(); const c = await askClose(); if (c === 'stay' || !c) return; if (c === 'pause') { try { await rpc('scan_pause', { job: S.job }); for (let i = 0; i < 40; i++) { const st = await rpc('status', { job: S.job }); if (st.status !== 'running') break; await new Promise((r) => setTimeout(r, 250)); } await new Promise((r) => setTimeout(r, 600)); // allow auto-save to flush } catch { /* exit anyway */ } } win.destroy(); }); } // ------------------------------------------------------------------ app startup (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'); Object.assign(SETTINGS, st); applyTheme(st.theme); try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ } } catch { /* usa o local */ } setLang(SETTINGS.lang); if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() }); watchClose(); tauri?.event?.listen?.('update-progress', (e) => updateProgress(e.payload)); if (SETTINGS.updates !== false) checkUpdate(false); if (tauri) S.elevated = await tauri.core.invoke('is_elevated').catch(() => true); 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(); await loadRecent(); renderHome(); })();