feat: pré-visualização completa, tema, nova identidade e correções (0.2.0)
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s

- Preview de imagem, vídeo/MP4, áudio, PDF (pdf.js), texto, Office/ODF, ZIP e hex,
  lido sob demanda com Range; tela cheia com navegação por teclado.
- Seletor de tema Auto/Claro/Escuro persistido (vale também como administrador).
- Identidade verde-azulada com logo de pixels que formam um lobo.
- Corrige reinício como administrador no AppImage (usa $APPIMAGE) e mostra o motivo.
- Corrige barra inferior piscando (render idempotente, barra criada uma vez).
- Corrige FAT32 em mídias pequenas não reconhecido.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-18 15:34:01 -03:00
1 parent 510f1bc48e
commit c5c94a9956
32 files changed
+1666 -227

No files matched your search

+13
View File
@@ -2,6 +2,19 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [0.2.0] - 2026-10-18
### Adicionado
- **Pré-visualização completa:** imagem (zoom), vídeo (MP4 e outros), áudio, PDF (pdf.js incluso, funciona offline), texto, Word/Excel/PowerPoint/OpenDocument, conteúdo de ZIP e hexadecimal; tela cheia com navegação por teclado (↑ ↓ ← → Espaço Esc). O conteúdo é lido sob demanda com suporte a Range, sem gravar em disco.
- Seletor de tema **Auto / Claro / Escuro**; a escolha é salva e vale também no app reiniciado como administrador.
- Nova identidade visual em verde-azulado, com logo de pixels que se juntam até formar um lobo-guará.
### Corrigido
- Reiniciar como administrador no Linux: no AppImage o executável fica num ponto de montagem inacessível ao root; agora usa o próprio `.AppImage`, mostra o motivo se falhar e fecha o app antigo só quando o novo abre.
- Barra de ações inferior piscava a cada atualização; agora é criada uma vez e só atualiza o texto. O painel de progresso também deixou de ser recriado a cada ciclo.
- FAT32 em mídias pequenas (menos de 65 525 clusters) não era reconhecido.
- O tema não acompanhava a escolha ao abrir como root (o WebView do root usava outro tema do sistema).
## [0.1.0] - 2026-10-18
### Adicionado
Generated
+2 -2
View File
@@ -248,7 +248,7 @@ dependencies = [
[[package]]
name = "guara-cli"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"clap",
"ctrlc",
@@ -260,7 +260,7 @@ dependencies = [
[[package]]
name = "guara-core"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"libc",
"serde",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package]
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
+4 -2
View File
@@ -1,4 +1,4 @@
<p align="center"><img src="apps/gui/ui/guara.svg" width="96" alt="Guará"></p>
<p align="center"><img src="apps/gui/ui/guara-logo.svg" width="360" alt="Guará"></p>
<h1 align="center">Guará</h1>
<p align="center">Recuperação de arquivos livre e gratuita para HD, SSD e pendrive.<br>Terminal e interface gráfica · Windows, Linux e macOS · somente leitura na origem.</p>
@@ -55,7 +55,9 @@ guara ui # interface no navegador (127.0.0.1 + t
## Interface gráfica
App desktop (Tauri) com barra lateral de dispositivos, mapa do disco, resultados ao vivo, filtros por tipo, busca, pré-visualização de imagens, confiança por arquivo, pausar/continuar e salvar sessão. Tema claro/escuro automático e layout responsivo. O mesmo front-end roda em `guara ui`.
App desktop (Tauri) com barra lateral de dispositivos, mapa do disco, resultados ao vivo, filtros por tipo, busca, confiança por arquivo, pausar/continuar e salvar sessão. Tema **Auto / Claro / Escuro** à sua escolha (lembrado também ao reiniciar como administrador) e layout responsivo. O mesmo front-end roda em `guara ui`.
**Pré-visualização antes de recuperar** (lida direto do disco, sem gravar nada): imagens (com zoom), **vídeo (MP4/MOV/WebM)** e áudio com player, **PDF** (página a página), texto, documentos Word/Excel/PowerPoint/OpenDocument (texto extraído), conteúdo de ZIP e visão hexadecimal para o resto. Use ↑/↓ para percorrer a lista, Espaço/Enter ou duplo clique para abrir em tela cheia. Quando um arquivo não abre, o aviso ajuda a julgar se ele está danificado. A reprodução de vídeo depende dos codecs do seu sistema (no Linux, instale os plugins `gstreamer1.0-libav` e `gstreamer1.0-plugins-good`).
## Limites conhecidos (v0.1)
+4 -1
View File
@@ -3,7 +3,7 @@
[package]
name = "guara-gui"
description = "Guará: interface gráfica de recuperação de arquivos"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
@@ -20,6 +20,9 @@ tauri-plugin-dialog = "2"
serde_json = "1"
guara-core = { path = "../../../crates/guara-core" }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[profile.release]
lto = "thin"
codegen-units = 1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 416 KiB

+125 -21
View File
@@ -16,40 +16,144 @@ async fn rpc(state: tauri::State<'_, State>, method: String, params: serde_json:
}
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o
/// manifesto já exige elevação.
/// manifesto já exige elevação. O app novo encerra este quando abre; se a
/// autorização falhar, a janela continua aqui e a interface mostra o motivo.
#[tauri::command]
fn relaunch_elevated() -> Result<(), String> {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let mut cmd = std::process::Command::new("pkexec");
cmd.arg("env");
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"] {
if let Ok(v) = std::env::var(k) {
cmd.arg(format!("{k}={v}"));
use tauri::Emitter;
let exe = elevated_exe()?;
let cfg = guara_core::settings::config_dir();
let log = std::env::temp_dir().join(format!("guara-elevate-{}.log", std::process::id()));
let _ = std::fs::write(&log, b"");
let out = std::fs::OpenOptions::new().append(true).open(&log).map_err(|e| e.to_string())?;
let err = out.try_clone().map_err(|e| e.to_string())?;
let pid = std::process::id().to_string();
#[cfg(target_os = "linux")]
let mut cmd = {
let mut c = std::process::Command::new("pkexec");
c.arg("/usr/bin/env");
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND"] {
if let Ok(v) = std::env::var(k) {
c.arg(format!("{k}={v}"));
}
}
}
cmd.arg(exe);
cmd.spawn().map_err(|e| format!("pkexec indisponível: {e}"))?;
std::process::exit(0);
}
#[cfg(target_os = "macos")]
{
let script = format!("do shell script \"'{}' &> /dev/null &\" with administrator privileges", exe.display());
std::process::Command::new("osascript").args(["-e", &script]).spawn().map_err(|e| e.to_string())?;
std::process::exit(0);
c.arg(format!("GUARA_CONFIG_DIR={}", cfg.display()));
c.arg(format!("GUARA_REPLACE_PID={pid}"));
c.arg(&exe);
c
};
#[cfg(target_os = "macos")]
let mut cmd = {
let shell = format!(
"GUARA_CONFIG_DIR='{}' GUARA_REPLACE_PID={} '{}' >/dev/null 2>&1 &",
cfg.display(),
pid,
exe.display()
);
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
let mut c = std::process::Command::new("osascript");
c.args(["-e", &script]);
c
};
cmd.stdout(out).stderr(err);
let mut child = cmd.spawn().map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
std::thread::spawn(move || {
// No Linux o pkexec só termina com sucesso quando o app elevado é fechado (e o app
// elevado já terá encerrado este processo). Qualquer falha antes disso vira aviso.
let status = child.wait();
if matches!(&status, Ok(s) if s.success()) {
let _ = std::fs::remove_file(&log);
return;
}
let detail = std::fs::read_to_string(&log).unwrap_or_default();
let msg = match status.ok().and_then(|s| s.code()) {
Some(126) => "Autorização cancelada.".to_string(),
Some(127) => "Não foi possível autenticar como administrador.".to_string(),
_ => format!("Não foi possível reiniciar como administrador. {}", detail.lines().last().unwrap_or("").trim()),
};
let _ = app.emit("elevate-failed", msg);
let _ = std::fs::remove_file(&log);
});
Ok(())
}
#[cfg(windows)]
{
let _ = exe;
let _ = app;
Err("o Guará já roda como administrador no Windows".into())
}
}
/// Caminho que o root consegue executar. No AppImage, `current_exe` aponta para
/// dentro de um ponto de montagem FUSE do usuário, inacessível ao root: usa o
/// próprio arquivo `.AppImage` (variável `APPIMAGE`).
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn elevated_exe() -> Result<std::path::PathBuf, String> {
if let Some(p) = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
return Ok(p);
}
std::env::current_exe().map_err(|e| e.to_string())
}
/// Serve os arquivos recuperados para pré-visualização (`guara://localhost/file/<job>/<id>`),
/// com suporte a Range para vídeo/áudio/PDF, lendo direto da origem.
fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http::Response<Vec<u8>> {
use tauri::http::Response;
const CAP: u64 = 8 * 1024 * 1024;
let mut it = req.uri().path().trim_start_matches("/file/").split('/');
let (job, id) = (it.next().unwrap_or(""), it.next().and_then(|s| s.parse::<u64>().ok()));
let err = |code: u16| Response::builder().status(code).header("Access-Control-Allow-Origin", "*").body(Vec::new()).unwrap();
let Some(id) = id else { return err(400) };
let range = req.headers().get("range").and_then(|v| v.to_str().ok());
let slice = match eng.file_slice(job, id, range) {
Ok(s) => s,
Err(code) => return err(code),
};
let total = slice.total;
let start = slice.start;
let want = slice.len();
let mut headers = slice.headers();
let partial = slice.partial || want > CAP;
let Ok(data) = slice.read_all(CAP) else { return err(500) };
let mut b = Response::builder().status(if partial { 206 } else { 200 });
headers.retain(|(k, _)| *k != "Content-Range");
for (k, v) in &headers {
b = b.header(*k, v);
}
if partial && !data.is_empty() {
b = b.header("Content-Range", format!("bytes {}-{}/{}", start, start + data.len() as u64 - 1, total));
}
b.body(data).unwrap_or_else(|_| err(500))
}
fn main() {
let engine = Arc::new(Engine::new());
let for_protocol = engine.clone();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(State(Arc::new(Engine::new())))
.manage(State(engine))
.register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| {
let eng = for_protocol.clone();
std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
})
.setup(|_app| {
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
#[cfg(unix)]
if let Some(pid) = std::env::var("GUARA_REPLACE_PID").ok().and_then(|p| p.parse::<i32>().ok()) {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(1200));
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
unsafe {
if libc::geteuid() == 0 {
libc::kill(pid, libc::SIGTERM);
}
}
});
}
Ok(())
})
.invoke_handler(tauri::generate_handler![rpc, relaunch_elevated])
.run(tauri::generate_context!())
.expect("erro ao iniciar o Guará");
+14 -4
View File
@@ -1,9 +1,11 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Guara",
"version": "0.1.0",
"version": "0.2.0",
"identifier": "dev.guara.app",
"build": { "frontendDist": "../ui" },
"build": {
"frontendDist": "../ui"
},
"app": {
"withGlobalTauri": true,
"windows": [
@@ -16,7 +18,9 @@
"resizable": true
}
],
"security": { "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" }
"security": {
"csp": "default-src 'self'; img-src 'self' data: blob: guara: http://guara.localhost; media-src 'self' blob: guara: http://guara.localhost; connect-src 'self' ipc: http://ipc.localhost guara: http://guara.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'"
}
},
"bundle": {
"active": true,
@@ -24,6 +28,12 @@
"category": "Utility",
"shortDescription": "Recuperação de arquivos",
"longDescription": "Guará recupera arquivos apagados e partições perdidas de HD, SSD e pendrive, sempre em modo somente leitura na origem.",
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"]
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+85 -8
View File
@@ -7,10 +7,10 @@
--text-2: #6e6e73;
--text-3: #8e8e93;
--line: rgba(0, 0, 0, 0.09);
--accent: #d9541e;
--accent-2: #ff8a3d;
--accent: #0f766e;
--accent-2: #14b8a6;
--accent-ink: #ffffff;
--accent-soft: rgba(217, 84, 30, 0.1);
--accent-soft: rgba(15, 118, 110, 0.11);
--ok: #1a8f3c;
--warn: #b26a00;
--bad: #d12b2b;
@@ -34,10 +34,10 @@
--text-2: #a1a1a6;
--text-3: #7d7d82;
--line: rgba(255, 255, 255, 0.1);
--accent: #ff7a3d;
--accent-2: #ff9d5c;
--accent-ink: #1a0b03;
--accent-soft: rgba(255, 122, 61, 0.15);
--accent: #2dd4bf;
--accent-2: #5eead4;
--accent-ink: #042f2e;
--accent-soft: rgba(45, 212, 191, 0.15);
--ok: #32d74b;
--warn: #ffb340;
--bad: #ff6961;
@@ -48,6 +48,32 @@
}
}
:root[data-theme="dark"] {
--bg: #000000;
--surface: #1c1c1e;
--surface-2: #232326;
--side: rgba(28, 28, 30, 0.8);
--text: #f5f5f7;
--text-2: #a1a1a6;
--text-3: #7d7d82;
--line: rgba(255, 255, 255, 0.1);
--accent: #2dd4bf;
--accent-2: #5eead4;
--accent-ink: #042f2e;
--accent-soft: rgba(45, 212, 191, 0.15);
--ok: #32d74b;
--warn: #ffb340;
--bad: #ff6961;
--ok-soft: rgba(50, 215, 75, 0.15);
--warn-soft: rgba(255, 179, 64, 0.15);
--bad-soft: rgba(255, 105, 97, 0.15);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
}
:root { color-scheme: light dark; }
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
@@ -211,7 +237,8 @@ input[type="checkbox"] { accent-color: var(--accent); width: 17px; height: 17px;
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; font-size: 13.5px; }
.kv dt { color: var(--text-3); } .kv dd { margin: 0; overflow-wrap: anywhere; }
.actionbar { position: fixed; left: var(--side-w); right: 0; bottom: 0; padding: 14px 24px calc(14px + env(safe-area-inset-bottom)); display: flex; gap: 12px; align-items: center; justify-content: center; background: var(--side); backdrop-filter: saturate(180%) blur(24px); -webkit-backdrop-filter: saturate(180%) blur(24px); border-top: 1px solid var(--line); z-index: 5; animation: rise .3s; }
.actionbar { position: fixed; left: var(--side-w); right: 0; bottom: 0; padding: 14px 24px calc(14px + env(safe-area-inset-bottom)); display: flex; gap: 12px; align-items: center; justify-content: center; background: var(--side); backdrop-filter: saturate(180%) blur(24px); -webkit-backdrop-filter: saturate(180%) blur(24px); border-top: 1px solid var(--line); z-index: 5; }
.actionbar.enter { animation: rise .3s; }
.actionbar .info { color: var(--text-2); font-size: 14px; }
dialog { border: 0; border-radius: 22px; padding: 26px; width: min(520px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); }
@@ -255,3 +282,53 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
/* ------------------------------------------------------------ tema */
.theme-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); }
.theme-switch button { border: 0; background: transparent; cursor: pointer; padding: 4px 10px; border-radius: 999px; font-size: 12px; color: var(--text-2); }
.theme-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .18); font-weight: 600; }
/* ------------------------------------------------------------ pré-visualização */
.viewer { position: relative; min-height: 160px; max-height: 52vh; overflow: auto; border-radius: 12px; background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--line); display: flex; flex-direction: column; align-items: center; }
.viewer.large { max-height: none; height: 100%; min-height: 0; }
.viewer > * { max-width: 100%; }
.pv-img { display: block; max-width: 100%; max-height: 46vh; object-fit: contain; margin: auto; }
.viewer.large .pv-img { max-height: 100%; }
.viewer.zoomable .pv-img { cursor: zoom-in; }
.viewer.actual .pv-img { max-width: none; max-height: none; cursor: zoom-out; }
.viewer.actual { align-items: flex-start; }
.pv-video { width: 100%; max-height: 46vh; background: #000; border-radius: 12px; }
.viewer.large .pv-video { max-height: 100%; height: 100%; }
.pv-audio { width: calc(100% - 24px); margin: auto 12px; }
.pv-note { width: 100%; padding: 8px 12px; font-size: 12.5px; color: var(--text-2); text-align: center; }
.pv-note.bad { text-align: left; color: var(--text); background: var(--warn-soft); border-radius: 10px; margin: 8px; width: calc(100% - 16px); font-size: 13.5px; }
.pv-loading { height: 160px; display: grid; place-items: center; width: 100%; }
.pv-loading i { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--line); border-top-color: var(--accent); animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.pv-text, .pv-hex { margin: 0; padding: 12px 14px; width: 100%; font: 12.5px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); text-align: left; }
.pv-hex { white-space: pre; overflow-x: auto; overflow-wrap: normal; font-size: 12px; }
.pv-hex .o { color: var(--text-3); } .pv-hex .a { color: var(--accent); }
.pv-page { width: 100%; background: var(--surface); padding: 18px 22px; font-family: Georgia, "Times New Roman", serif; font-size: 14px; line-height: 1.6; text-align: left; }
.pv-page p { margin: 0 0 .8em; white-space: pre-wrap; } .pv-page h4 { margin: 1em 0 .4em; font-family: var(--font); font-size: 16px; }
.pv-sheet { width: 100%; overflow: auto; text-align: left; }
.pv-sheet table { border-collapse: collapse; font-size: 12.5px; min-width: 100%; }
.pv-sheet th, .pv-sheet td { border: 1px solid var(--line); padding: 4px 8px; white-space: nowrap; max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
.pv-sheet th { background: var(--surface); color: var(--text-3); font-weight: 500; position: sticky; top: 0; }
.pv-slide { width: calc(100% - 20px); margin: 10px; padding: 14px 16px; background: var(--surface); border-radius: 10px; box-shadow: 0 0 0 1px var(--line); text-align: left; }
.pv-slide small { color: var(--text-3); } .pv-slide h4 { margin: 4px 0 6px; font-size: 16px; } .pv-slide p { margin: 0 0 4px; font-size: 13.5px; }
.pv-zip { list-style: none; margin: 0; padding: 4px 0; width: 100%; text-align: left; font-size: 13px; }
.pv-zip li { display: flex; justify-content: space-between; gap: 12px; padding: 5px 14px; border-bottom: 1px solid var(--line); }
.pv-zip li span { overflow-wrap: anywhere; } .pv-zip small { color: var(--text-3); flex: none; }
.pv-pdfbar { position: sticky; top: 0; z-index: 2; display: flex; gap: 10px; align-items: center; justify-content: center; padding: 6px; width: 100%; background: var(--side); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
.pv-pdfbar .pg { font-size: 13px; color: var(--text-2); min-width: 110px; text-align: center; }
.pv-pdfwrap { padding: 8px; } .pv-canvas { display: block; background: #fff; box-shadow: 0 2px 14px rgba(0, 0, 0, .25); }
dialog.lightbox { width: min(1180px, 96vw); height: min(90vh, 920px); padding: 0; display: none; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
dialog.lightbox[open] { display: grid; }
.lb-head, .lb-foot { display: flex; gap: 12px; align-items: center; padding: 12px 16px; }
.lb-head { border-bottom: 1px solid var(--line); } .lb-foot { border-top: 1px solid var(--line); flex-wrap: wrap; justify-content: space-between; }
.lb-head .ttl { flex: 1; min-width: 0; } .lb-head .ttl b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .lb-head .ttl small { color: var(--text-2); }
.lb-body { padding: 12px; min-height: 0; }
.hero-logo { width: min(440px, 80%); height: auto; display: block; margin: 0 0 22px -4px; }
.pill.accent { background: var(--accent-soft); color: var(--accent); }
+171 -63
View File
@@ -1,6 +1,8 @@
// Guará — interface. Fala com o núcleo por `rpc(método, parâmetros)`:
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
const tauri = window.__TAURI__;
const params = new URLSearchParams(location.search);
let token = params.get('token');
@@ -21,6 +23,32 @@ async function rpc(method, p = {}) {
return body;
}
const isWin = /Windows/i.test(navigator.userAgent);
const fileBase = tauri ? (isWin ? 'http://guara.localhost' : 'guara://localhost') : '';
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -173,7 +201,7 @@ function renderHome() {
S.device = null; S.job = null;
renderSide();
main.innerHTML = `<div class="page">
<section class="hero"><h1>Encontre o que você achou que tinha perdido.</h1>
<section class="hero"><img class="hero-logo" src="guara-logo.svg" alt="Guará"><h1>Encontre o que você achou que tinha perdido.</h1>
<p>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.</p></section>
<div class="cards">
<div class="card"><h3>Uma varredura, tudo junto</h3><p>Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.</p></div>
@@ -348,10 +376,13 @@ function renderScan() {
<div class="split"><div class="table" id="table"></div><aside class="preview" id="preview" aria-live="polite"></aside></div>
</div><div id="barHost"></div>`;
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
$('#progressArea').onclick = onProgressClick;
$('#chips').onclick = (e) => { const c = e.target.closest('.chip'); if (c) { S.filters.category = c.dataset.c || null; resetSel(); tick(); } };
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(); };
wireTable();
paintPreview();
}
const resetSel = () => { S.selected = new Set(); S.allFiltered = false; };
@@ -366,10 +397,10 @@ function paintStatus() {
$('#scanSub').innerHTML = `<span class="mono">${esc(st.target)}</span>`;
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 = `<div class="progress-card">
setHTML($('#progressArea'), `<div class="progress-card">
<div class="row spread"><div><b>${esc(p.phase || (running ? 'Iniciando' : ''))}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div>
<div class="row">${running ? '<button class="btn sm" id="pauseBtn">Pausar</button><button class="btn sm danger" id="stopBtn">Parar</button>' : st.status === 'paused' ? '<button class="btn sm primary" id="resumeBtn">Continuar</button>' : ''}
${!running && st.found ? '<button class="btn sm" id="saveBtn">Salvar sessão…</button>' : ''}</div></div>
<div class="row">${running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>' : st.status === 'paused' ? '<button class="btn sm primary" data-act="resume">Continuar</button>' : ''}
${!running && st.found ? '<button class="btn sm" data-act="save">Salvar sessão…</button>' : ''}</div></div>
<div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
<div class="stats">
<div class="stat"><b>${st.found.toLocaleString('pt-BR')}</b><span>arquivos encontrados</span></div>
@@ -377,86 +408,151 @@ function paintStatus() {
<div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>confiança alta</span></div>
<div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div>
<div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
</div></div>`;
$('#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) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('')
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : '');
</div></div>`);
setHTML($('#warnArea'), (st.warnings || []).map((w) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('')
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : ''));
// chips
const total = st.found;
$('#chips').innerHTML = [`<button class="chip" data-c="" aria-pressed="${!S.filters.category}">Tudo<small>${total}</small></button>`]
.concat(CATS.filter(([k]) => st.categories[k]).map(([k, n]) => `<button class="chip" data-c="${k}" aria-pressed="${S.filters.category === k}">${n}<small>${st.categories[k]}</small></button>`)).join('');
$('#chips').querySelectorAll('.chip').forEach((c) => (c.onclick = () => { S.filters.category = c.dataset.c || null; resetSel(); tick(); }));
setHTML($('#chips'), [`<button class="chip" data-c="" aria-pressed="${!S.filters.category}">Tudo<small>${total}</small></button>`]
.concat(CATS.filter(([k]) => st.categories[k]).map(([k, n]) => `<button class="chip" data-c="${k}" aria-pressed="${S.filters.category === k}">${n}<small>${st.categories[k]}</small></button>`)).join(''));
const sel = $('#conf'); if (sel && sel.value !== (S.filters.minConfidence || '')) sel.value = S.filters.minConfidence || '';
paintBar();
}
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 === '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); } }
}
}
function rowHtml(i) {
const [cl, cc] = CONF[i.confidence];
const on = S.allFiltered || S.selected.has(i.id);
return `<div class="tr" role="row" data-id="${i.id}" aria-selected="${on || S.current?.id === i.id}" tabindex="0">
<input type="checkbox" ${on ? 'checked' : ''} aria-label="Selecionar ${esc(i.name)}">
<div class="nm"><b>${esc(i.name)}</b><small>${esc(i.dir || (i.origin === 'carved' ? 'Encontrado por assinatura' : '—'))}</small></div>
<div class="num">${human(i.size)}</div><div class="num" style="text-align:left">${i.origin === 'carved' ? 'assinatura' : esc(i.origin)}</div>
<div class="num"><span class="pill ${cc}">${cl}</span></div></div>`;
}
function paintList() {
const el = $('#table');
if (!el) return;
const st = S.status;
if (!S.items.length) {
el.innerHTML = `<div class="empty"><h3>${st && st.status === 'running' ? 'Procurando…' : 'Nada por aqui'}</h3><p>${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.'}</p></div>`;
setHTML(el, `<div class="empty"><h3>${st && st.status === 'running' ? 'Procurando…' : 'Nada por aqui'}</h3><p>${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.'}</p></div>`);
paintBar();
return;
}
const allSel = S.items.every((i) => S.selected.has(i.id)) || S.allFiltered;
el.innerHTML = `<div class="thead"><input type="checkbox" id="selAll" aria-label="Selecionar todos" ${allSel ? 'checked' : ''}><span>Nome</span><span style="text-align:right">Tamanho</span><span>Origem</span><span style="text-align:right">Confiança</span></div>`
+ S.items.map((i) => {
const [cl, cc] = CONF[i.confidence];
const on = S.allFiltered || S.selected.has(i.id);
return `<div class="tr" role="row" data-id="${i.id}" aria-selected="${on || S.current?.id === i.id}" tabindex="0">
<input type="checkbox" ${on ? 'checked' : ''} aria-label="Selecionar ${esc(i.name)}">
<div class="nm"><b>${esc(i.name)}</b><small>${esc(i.dir || (i.origin === 'carved' ? 'Encontrado por assinatura' : '—'))}</small></div>
<div class="num">${human(i.size)}</div><div class="num" style="text-align:left">${i.origin === 'carved' ? 'assinatura' : esc(i.origin)}</div>
<div class="num"><span class="pill ${cc}">${cl}</span></div></div>`;
}).join('')
+ (S.items.length < S.total ? `<div class="more"><button class="btn" id="moreBtn">Mostrar mais (${(S.total - S.items.length).toLocaleString('pt-BR')})</button></div>` : '');
$('#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));
setHTML(el, `<div class="thead"><input type="checkbox" id="selAll" aria-label="Selecionar todos" ${allSel ? 'checked' : ''}><span>Nome</span><span style="text-align:right">Tamanho</span><span>Origem</span><span style="text-align:right">Confiança</span></div>`
+ S.items.map(rowHtml).join('')
+ (S.items.length < S.total ? `<div class="more"><button class="btn" id="moreBtn">Mostrar mais (${(S.total - S.items.length).toLocaleString('pt-BR')})</button></div>` : ''));
paintBar();
}
async function focusItem(item) {
// Eventos da tabela por delegação (o HTML é recriado, o listener não).
function wireTable() {
const el = $('#table');
el.addEventListener('click', (e) => {
if (e.target.id === 'moreBtn') return loadResults(false);
const row = e.target.closest('.tr');
if (e.target.id === 'selAll') {
if (e.target.checked) S.items.forEach((i) => S.selected.add(i.id)); else resetSel();
el._h = null; paintList();
return;
}
if (!row) return;
const id = +row.dataset.id;
if (e.target.matches('input[type=checkbox]')) {
if (S.allFiltered) { S.allFiltered = false; S.items.forEach((i) => S.selected.add(i.id)); }
e.target.checked ? S.selected.add(id) : S.selected.delete(id);
row.setAttribute('aria-selected', e.target.checked);
el._h = null;
paintBar();
return;
}
focusItem(S.items.find((i) => i.id === id));
});
el.addEventListener('dblclick', (e) => { if (e.target.closest('.tr')) openLightbox(); });
}
let stopPv = null;
function focusItem(item, { scroll = false } = {}) {
if (!item) return;
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));
document.querySelectorAll('.tr').forEach((r) => {
const on = +r.dataset.id === item.id || S.selected.has(+r.dataset.id) || S.allFiltered;
r.setAttribute('aria-selected', on);
if (scroll && +r.dataset.id === item.id) r.scrollIntoView({ block: 'nearest' });
});
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 = `<img alt="Pré-visualização de ${esc(item.name)}" src="data:${p.mime};base64,${p.base64}">`;
} catch { /* sem prévia */ }
}
}
function detailsHtml(i) {
const [cl, cc] = CONF[i.confidence];
return `<dl class="kv"><dt>Nome</dt><dd>${esc(i.name)}</dd><dt>Pasta</dt><dd>${esc(i.dir || '—')}</dd><dt>Tamanho</dt><dd>${human(i.size)}</dd>
<dt>Modificado</dt><dd>${date(i.modified)}</dd><dt>Origem</dt><dd>${i.origin === 'carved' ? 'Assinatura de formato' : esc(i.origin.toUpperCase())}</dd>
<dt>Confiança</dt><dd><span class="pill ${cc}">${cl}</span></dd>${i.note ? `<dt>Aviso</dt><dd>${esc(i.note)}</dd>` : ''}</dl>`;
}
function paintPreview() {
const el = $('#preview');
if (!el) return;
stopPv?.(); stopPv = null;
const i = S.current;
if (!i) { el.innerHTML = `<div class="thumb">Selecione um arquivo para ver os detalhes</div>`; return; }
const [cl, cc] = CONF[i.confidence];
el.innerHTML = `<div class="thumb">${esc(i.ext ? i.ext.toUpperCase() : 'ARQUIVO')}</div>
<dl class="kv"><dt>Nome</dt><dd>${esc(i.name)}</dd><dt>Pasta</dt><dd>${esc(i.dir || '—')}</dd><dt>Tamanho</dt><dd>${human(i.size)}</dd>
<dt>Modificado</dt><dd>${date(i.modified)}</dd><dt>Origem</dt><dd>${i.origin === 'carved' ? 'Assinatura de formato' : esc(i.origin.toUpperCase())}</dd>
<dt>Confiança</dt><dd><span class="pill ${cc}">${cl}</span></dd>${i.note ? `<dt>Aviso</dt><dd>${esc(i.note)}</dd>` : ''}</dl>
<div class="row"><button class="btn primary sm" id="recOne">Recuperar este arquivo</button></div>`;
if (!i) { el.innerHTML = '<div class="viewer"><div class="pv-note">Selecione um arquivo para pré-visualizar</div></div>'; return; }
el.innerHTML = `<div class="viewer" id="pvHost"></div><div class="sub" style="font-size:12.5px">${esc(KIND_LABEL[kindOf(i)])}</div>${detailsHtml(i)}
<div class="row"><button class="btn sm" id="pvFull">Tela cheia</button><button class="btn primary sm" id="recOne">Recuperar este arquivo</button></div>`;
stopPv = renderPreview($('#pvHost'), i, { url: fileUrl(S.job, i.id) });
$('#pvFull').onclick = openLightbox;
$('#recOne').onclick = () => recover({ ids: [i.id] });
}
// ------------------------------------------------------------------ tela cheia
let stopLb = null;
function openLightbox() {
const i = S.current;
if (!i) return;
const d = $('#lightbox');
const idx = S.items.findIndex((x) => x.id === i.id);
d.innerHTML = `<div class="lb-head"><button class="icon-btn" id="lbPrev" aria-label="Anterior" ${idx <= 0 ? 'disabled' : ''}>‹</button><button class="icon-btn" id="lbNext" aria-label="Próximo" ${idx >= S.items.length - 1 ? 'disabled' : ''}>›</button>
<div class="ttl"><b>${esc(i.name)}</b><small>${esc(KIND_LABEL[kindOf(i)])} · ${human(i.size)} · confiança ${CONF[i.confidence][0]}</small></div>
<button class="icon-btn" id="lbClose" aria-label="Fechar"><svg viewBox="0 0 24 24" width="20" height="20"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button></div>
<div class="lb-body"><div class="viewer large" id="lbHost"></div></div>
<div class="lb-foot"><span class="sub">${i.note ? esc(i.note) : '← → para navegar · Esc para fechar'}</span><button class="btn primary sm" id="lbRec">Recuperar este arquivo</button></div>`;
stopLb?.();
stopLb = renderPreview($('#lbHost', d), i, { url: fileUrl(S.job, i.id), large: true });
const go = (n) => { const t = S.items[idx + n]; if (t) { focusItem(t, { scroll: true }); openLightbox(); } };
$('#lbPrev', d).onclick = () => go(-1);
$('#lbNext', d).onclick = () => go(1);
$('#lbClose', d).onclick = () => d.close();
$('#lbRec', d).onclick = () => { d.close(); recover({ ids: [i.id] }); };
d.onclose = () => { stopLb?.(); stopLb = null; };
d.onkeydown = (e) => { if (e.key === 'ArrowLeft') go(-1); else if (e.key === 'ArrowRight') go(1); };
if (!d.open) d.showModal();
}
// Teclado: ↑/↓ percorre a lista; Espaço/Enter abre a tela cheia.
document.addEventListener('keydown', (e) => {
if (!S.job || document.querySelector('dialog[open]') || /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement?.tagName) && document.activeElement.type !== 'checkbox') return;
const idx = S.items.findIndex((x) => x.id === S.current?.id);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const t = S.items[Math.min(S.items.length - 1, Math.max(0, idx + (e.key === 'ArrowDown' ? 1 : -1)))];
focusItem(t, { scroll: true });
} else if ((e.key === ' ' || e.key === 'Enter') && S.current && !e.target.closest?.('button')) {
e.preventDefault();
openLightbox();
}
});
function selCount() { return S.allFiltered ? S.total : S.selected.size; }
function paintBar() {
@@ -465,14 +561,24 @@ function paintBar() {
const st = S.status;
const n = selCount();
const canAll = st && st.found > 0 && st.status !== 'error';
if (!n && !canAll) { host.innerHTML = ''; return; }
if (!n && !canAll) { host.innerHTML = ''; host._h = null; return; }
const moreOffer = !S.allFiltered && S.selected.size > 0 && S.selected.size < S.total && S.items.length === S.selected.size
? `<button class="btn sm" id="selFiltered">Selecionar todos os ${S.total.toLocaleString('pt-BR')}</button>` : '';
host.innerHTML = `<div class="actionbar"><span class="info">${n ? `${n.toLocaleString('pt-BR')} selecionado(s)` : `${S.total.toLocaleString('pt-BR')} arquivo(s) na lista`}</span>${moreOffer}
${n ? '<button class="btn primary" id="recSel">Recuperar selecionados</button>' : '<button class="btn primary" id="recAll">Recuperar todos da lista</button>'}</div>`;
$('#selFiltered')?.addEventListener('click', () => { S.allFiltered = true; paintList(); });
$('#recSel')?.addEventListener('click', () => recover(S.allFiltered ? { filter: currentFilter() } : { ids: [...S.selected] }));
$('#recAll')?.addEventListener('click', () => recover({ filter: currentFilter() }));
? '<button class="btn sm" data-bar="all">Selecionar todos os ' + S.total.toLocaleString('pt-BR') + '</button>' : '';
const inner = `<span class="info">${n ? `${n.toLocaleString('pt-BR')} selecionado(s)` : `${S.total.toLocaleString('pt-BR')} arquivo(s) na lista`}</span>${moreOffer}
${n ? '<button class="btn primary" data-bar="sel">Recuperar selecionados</button>' : '<button class="btn primary" data-bar="rec">Recuperar todos da lista</button>'}`;
let bar = host.firstElementChild;
if (!bar) {
// A barra é criada uma vez (com animação de entrada) e depois só tem o texto atualizado.
host.innerHTML = '<div class="actionbar enter"></div>';
bar = host.firstElementChild;
bar.onclick = (e) => {
const act = e.target.closest('[data-bar]')?.dataset.bar;
if (act === 'all') { S.allFiltered = true; $('#table')._h = null; paintList(); }
else if (act === 'sel') recover(S.allFiltered ? { filter: currentFilter() } : { ids: [...S.selected] });
else if (act === 'rec') recover({ filter: currentFilter() });
};
}
setHTML(bar, inner);
}
function currentFilter() {
@@ -540,6 +646,8 @@ async function openJobFromList(s) { openJob(s); }
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 = `<div class="page"><div class="notice bad">${warnSvg}<div>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 <code>guara ui</code> (ele inclui o token).</div></div></div>`;
return;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 100" role="img" aria-label="Guará"><rect x="1.66" y="1.00" width="2.52" height="2.52" rx="0.30" fill="#67e8f9" opacity="0.50"/><rect x="5.59" y="0.44" width="2.16" height="2.16" rx="0.26" fill="#67e8f9" opacity="0.50"/><rect x="15.38" y="-1.68" width="1.95" height="1.95" rx="0.23" fill="#67e8f9" opacity="0.50"/><rect x="34.96" y="-2.08" width="3.04" height="3.04" rx="0.37" fill="#61e0ef" opacity="0.58"/><rect x="116.00" y="0.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="45.74" y="8.23" width="2.65" height="2.65" rx="0.32" fill="#48c0c8" opacity="0.63"/><rect x="58.59" y="2.22" width="2.04" height="2.04" rx="0.25" fill="#42b8bf" opacity="0.75"/><rect x="116.00" y="4.00" width="4.00" height="4.00" rx="0.48" fill="#127a73" opacity="1.00"/><rect x="22.44" y="11.12" width="3.15" height="3.15" rx="0.38" fill="#5fddec" opacity="0.51"/><rect x="29.61" y="5.65" width="2.48" height="2.48" rx="0.30" fill="#63e3f2" opacity="0.56"/><rect x="33.91" y="5.53" width="1.74" height="1.74" rx="0.21" fill="#60dfee" opacity="0.58"/><rect x="37.69" y="11.80" width="1.80" height="1.80" rx="0.22" fill="#52ccd7" opacity="0.60"/><rect x="80.66" y="8.60" width="2.43" height="2.43" rx="0.29" fill="#17807a" opacity="0.90"/><rect x="116.00" y="8.00" width="4.00" height="4.00" rx="0.48" fill="#10776f" opacity="1.00"/><rect x="23.94" y="11.79" width="0.95" height="0.95" rx="0.11" fill="#5fdeec" opacity="0.52"/><rect x="80.55" y="11.98" width="3.38" height="3.38" rx="0.41" fill="#1a8580" opacity="0.90"/><rect x="116.00" y="12.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="12.93" y="18.62" width="0.92" height="0.92" rx="0.11" fill="#66e7f8" opacity="0.50"/><rect x="80.08" y="16.11" width="3.84" height="3.84" rx="0.46" fill="#1d8884" opacity="0.90"/><rect x="84.27" y="16.17" width="3.12" height="3.12" rx="0.37" fill="#137b74" opacity="0.92"/><rect x="112.00" y="16.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="116.00" y="16.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="36.99" y="18.33" width="1.19" height="1.19" rx="0.14" fill="#5edceb" opacity="0.58"/><rect x="42.76" y="23.58" width="2.57" height="2.57" rx="0.31" fill="#5bd9e7" opacity="0.60"/><rect x="84.65" y="20.70" width="2.62" height="2.62" rx="0.31" fill="#1c8783" opacity="0.92"/><rect x="88.57" y="20.34" width="3.00" height="3.00" rx="0.36" fill="#147c76" opacity="0.94"/><rect x="112.00" y="20.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="116.00" y="20.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="2.84" y="24.95" width="1.02" height="1.02" rx="0.12" fill="#67e8f9" opacity="0.50"/><rect x="13.36" y="27.09" width="3.04" height="3.04" rx="0.36" fill="#67e8f9" opacity="0.50"/><rect x="35.01" y="22.08" width="2.21" height="2.21" rx="0.26" fill="#60dfed" opacity="0.58"/><rect x="39.95" y="23.90" width="1.39" height="1.39" rx="0.17" fill="#4fc8d2" opacity="0.60"/><rect x="57.75" y="26.92" width="1.51" height="1.51" rx="0.18" fill="#38abae" opacity="0.75"/><rect x="84.57" y="24.33" width="3.22" height="3.22" rx="0.39" fill="#19837e" opacity="0.92"/><rect x="87.85" y="24.01" width="4.20" height="4.20" rx="0.50" fill="#117871" opacity="0.94"/><rect x="112.00" y="24.00" width="4.00" height="4.00" rx="0.48" fill="#127a72" opacity="1.00"/><rect x="116.00" y="24.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="16.90" y="29.10" width="1.84" height="1.84" rx="0.22" fill="#67e8f9" opacity="0.50"/><rect x="22.27" y="28.50" width="1.55" height="1.55" rx="0.19" fill="#67e8f9" opacity="0.52"/><rect x="27.25" y="29.79" width="1.65" height="1.65" rx="0.20" fill="#60deed" opacity="0.54"/><rect x="75.33" y="29.42" width="2.98" height="2.98" rx="0.36" fill="#23908d" opacity="0.84"/><rect x="80.43" y="28.69" width="3.16" height="3.16" rx="0.38" fill="#17817b" opacity="0.90"/><rect x="84.52" y="28.41" width="3.38" height="3.38" rx="0.41" fill="#157e77" opacity="0.92"/><rect x="88.64" y="28.72" width="2.47" height="2.47" rx="0.30" fill="#1d8985" opacity="0.94"/><rect x="92.11" y="28.12" width="3.81" height="3.81" rx="0.46" fill="#0f766e" opacity="1.00"/><rect x="112.00" y="28.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="116.00" y="28.00" width="4.00" height="4.00" rx="0.48" fill="#147c75" opacity="1.00"/><rect x="120.00" y="28.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="2.99" y="31.83" width="1.52" height="1.52" rx="0.18" fill="#66e6f7" opacity="0.50"/><rect x="3.82" y="34.72" width="1.03" height="1.03" rx="0.12" fill="#63e3f3" opacity="0.50"/><rect x="26.27" y="35.88" width="3.13" height="3.13" rx="0.38" fill="#57d3e0" opacity="0.54"/><rect x="35.28" y="36.35" width="2.04" height="2.04" rx="0.24" fill="#5edcea" opacity="0.56"/><rect x="84.84" y="3Line truncated

After

Width:  |  Height:  |  Size: 33 KiB

+1 -17
View File
@@ -1,17 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ff8a3d"/>
<stop offset="1" stop-color="#d9441c"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="30" fill="url(#g)"/>
<!-- orelhas grandes e focinho longo do lobo-guará -->
<path d="M30 22 L52 50 L38 62 Z" fill="#fff"/>
<path d="M98 22 L76 50 L90 62 Z" fill="#fff"/>
<path d="M38 62 L52 50 Q64 44 76 50 L90 62 L74 96 Q64 108 54 96 Z" fill="#fff"/>
<path d="M54 80 Q64 90 74 80 L69 96 Q64 100 59 96 Z" fill="#3a1608"/>
<circle cx="53" cy="68" r="4" fill="#3a1608"/>
<circle cx="75" cy="68" r="4" fill="#3a1608"/>
<path d="M54 52 Q64 47 74 52 L70 60 Q64 56 58 60 Z" fill="#ffd2b0"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#14b8a6"/><stop offset="1" stop-color="#0c4a6e"/></linearGradient></defs><rect width="128" height="128" rx="29" fill="url(#g)"/><g transform="translate(-19 20) scale(0.88)"><rect x="116.00" y="0.00" width="4.00" height="4.00" rx="0.48" fill="#effdfa" opacity="1.00"/><rect x="55.88" y="7.09" width="2.81" height="2.81" rx="0.34" fill="#bff9ee" opacity="0.72"/><rect x="72.25" y="5.08" width="1.41" height="1.41" rx="0.17" fill="#dcfbf5" opacity="0.81"/><rect x="116.00" y="4.00" width="4.00" height="4.00" rx="0.48" fill="#eefdf9" opacity="1.00"/><rect x="80.28" y="8.30" width="2.76" height="2.76" rx="0.33" fill="#e3fcf7" opacity="0.90"/><rect x="116.00" y="8.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="26.46" y="13.82" width="3.31" height="3.31" rx="0.40" fill="#9bf6e4" opacity="0.54"/><rect x="53.70" y="15.81" width="1.46" height="1.46" rx="0.17" fill="#bef9ed" opacity="0.69"/><rect x="68.83" y="10.06" width="1.56" height="1.56" rx="0.19" fill="#cbfaf1" opacity="0.81"/><rect x="80.87" y="12.94" width="2.88" height="2.88" rx="0.35" fill="#e5fcf7" opacity="0.90"/><rect x="84.12" y="12.06" width="3.38" height="3.38" rx="0.41" fill="#dcfbf5" opacity="0.92"/><rect x="116.00" y="12.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="64.31" y="19.49" width="2.49" height="2.49" rx="0.30" fill="#cbfaf1" opacity="0.75"/><rect x="83.91" y="16.21" width="3.68" height="3.68" rx="0.44" fill="#e6fcf7" opacity="0.92"/><rect x="112.00" y="16.00" width="4.00" height="4.00" rx="0.48" fill="#eefdfa" opacity="1.00"/><rect x="116.00" y="16.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="35.36" y="23.45" width="2.13" height="2.13" rx="0.26" fill="#a8f7e8" opacity="0.56"/><rect x="84.51" y="20.97" width="2.47" height="2.47" rx="0.30" fill="#ddfbf5" opacity="0.92"/><rect x="88.20" y="20.36" width="3.47" height="3.47" rx="0.42" fill="#e0fcf6" opacity="0.94"/><rect x="112.00" y="20.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="116.00" y="20.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="57.99" y="21.72" width="3.23" height="3.23" rx="0.39" fill="#c0f9ee" opacity="0.72"/><rect x="88.09" y="24.27" width="3.62" height="3.62" rx="0.43" fill="#f0fdfa" opacity="0.94"/><rect x="112.00" y="24.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="116.00" y="24.00" width="4.00" height="4.00" rx="0.48" fill="#ecfdf9" opacity="1.00"/><rect x="33.49" y="30.98" width="1.25" height="1.25" rx="0.15" fill="#aaf7e8" opacity="0.56"/><rect x="53.47" y="27.77" width="1.44" height="1.44" rx="0.17" fill="#c0f9ee" opacity="0.69"/><rect x="88.16" y="28.01" width="3.99" height="3.99" rx="0.48" fill="#ecfdf9" opacity="0.94"/><rect x="92.10" y="28.10" width="3.81" height="3.81" rx="0.46" fill="#f0fdfa" opacity="1.00"/><rect x="112.00" y="28.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="116.00" y="28.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="120.00" y="28.00" width="4.00" height="4.00" rx="0.48" fill="#edfdf9" opacity="1.00"/><rect x="31.35" y="34.66" width="1.37" height="1.37" rx="0.16" fill="#a0f7e6" opacity="0.56"/><rect x="41.92" y="32.71" width="2.72" height="2.72" rx="0.33" fill="#abf7e9" opacity="0.63"/><rect x="80.35" y="32.60" width="3.35" height="3.35" rx="0.40" fill="#e4fcf7" opacity="0.90"/><rect x="88.21" y="32.24" width="3.78" height="3.78" rx="0.45" fill="#e6fcf8" opacity="0.94"/><rect x="92.18" y="32.04" width="3.81" height="3.81" rx="0.46" fill="#f0fdfa" opacity="1.00"/><rect x="96.02" y="32.03" width="3.89" height="3.89" rx="0.47" fill="#f0fdfa" opacity="1.00"/><rect x="108.00" y="32.00" width="4.00" height="4.00" rx="0.48" fill="#edfdf9" opacity="1.00"/><rect x="112.00" y="32.00" width="4.00" height="4.00" rx="0.48" fill="#eefdfa" opacity="1.00"/><rect x="116.00" y="32.00" width="4.00" height="4.00" rx="0.48" fill="#ecfdf9" opacity="1.00"/><rect x="120.00" y="32.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="48.73" y="37.21" width="1.04" height="1.04" rx="0.12" fill="#b6f8eb" opacity="0.63"/><rect x="71.16" y="34.38" width="2.65" height="2.65" rx="0.32" fill="#d1fbf2" opacity="0.81"/><rect x="80.74" y="36.35" width="2.54" height="2.54" rx="0.30" fill="#eafcf8" opacity="0.90"/><rect x="84.39" y="36.27" width="3.60" height="3.60" rx="0.43" fill="#e4fcf7" opacity="0.92"/><rect x="88.49" y="36.43" width="3.34" height="3.34" rx="0.40" fill="#e5fcf7" opacity="0.94"/><rect x="92.14" y="36.15" width="3.81" height="3.81" rx="0.46" fill="#f0fdfa" opacity="1.00"/><rect x="96.09" y="36.09" width="3.89" height="3.89" rx="0.47" fill="#f0fdfa" opacity="1.00"/><rect x="108.00" y="36.00" widLine truncated

Before

Width:  |  Height:  |  Size: 828 B

After

Width:  |  Height:  |  Size: 29 KiB

+2 -1
View File
@@ -26,13 +26,14 @@
<ul id="sessionList" class="list"></ul>
<button class="side-action" id="loadSessionBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>Abrir sessão salva…</button>
</nav>
<footer class="side-foot"><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
<footer class="side-foot"><div class="theme-switch" role="group" aria-label="Tema"><button data-theme-pick="auto" aria-pressed="true">Auto</button><button data-theme-pick="light" aria-pressed="false">Claro</button><button data-theme-pick="dark" aria-pressed="false">Escuro</button></div><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
</aside>
<div class="scrim" id="scrim"></div>
<main class="main" id="main" tabindex="-1"></main>
</div>
<dialog id="dlg"></dialog>
<dialog id="lightbox" class="lightbox" aria-label="Pré-visualização"></dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="app.js" type="module"></script>
</body>
+297
View File
@@ -0,0 +1,297 @@
// Pré-visualização de arquivos recuperados.
// O conteúdo é lido em streaming do núcleo (com Range) pela URL `url`; nada é
// gravado em disco. Tipos: imagem, vídeo, áudio, PDF (pdf.js), texto, documentos
// Office/OpenDocument (texto extraído), listagem de ZIP e visão hexadecimal.
const IMG = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'ico']);
const VID = new Set(['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ogv']);
const AUD = new Set(['mp3', 'wav', 'ogg', 'opus', 'm4a', 'aac', 'flac']);
const TXT = new Set(['txt', 'md', 'csv', 'tsv', 'json', 'xml', 'html', 'htm', 'log', 'ini', 'yml', 'yaml', 'js', 'css', 'c', 'h', 'cpp', 'py', 'rs', 'sh', 'bat', 'sql', 'toml', 'cfg', 'conf', 'srt', 'tex', 'svg']);
const OFFICE = new Set(['docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp']);
const ZIPS = new Set(['zip', 'jar', 'apk', 'epub']);
export function kindOf(item) {
const e = (item.ext || '').toLowerCase();
if (IMG.has(e)) return 'image';
if (VID.has(e)) return 'video';
if (AUD.has(e)) return 'audio';
if (e === 'pdf') return 'pdf';
if (OFFICE.has(e)) return 'office';
if (ZIPS.has(e)) return 'zip';
if (TXT.has(e)) return 'text';
return 'hex';
}
export const KIND_LABEL = {
image: 'Imagem', video: 'Vídeo', audio: 'Áudio', pdf: 'PDF', office: 'Documento (texto extraído)',
zip: 'Conteúdo do arquivo compactado', text: 'Texto', hex: 'Dados brutos (hexadecimal)',
};
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const human = (n) => {
const u = ['B', 'KB', 'MB', 'GB'];
let i = 0, v = n;
while (v >= 1024 && i < 3) { v /= 1024; i++; }
return i ? `${v.toFixed(1)} ${u[i]}` : `${n} B`;
};
async function fetchRange(url, a, b) {
const r = await fetch(url, { headers: { Range: `bytes=${a}-${b}` } });
if (!r.ok) throw new Error(`leitura falhou (${r.status})`);
return new Uint8Array(await r.arrayBuffer());
}
// ------------------------------------------------------------------ ZIP mínimo
async function zipDir(url, size) {
const tailLen = Math.min(size, 66000);
const tail = await fetchRange(url, size - tailLen, size - 1);
const dv = new DataView(tail.buffer);
let e = -1;
for (let i = tail.length - 22; i >= 0; i--) if (dv.getUint32(i, true) === 0x06054b50) { e = i; break; }
if (e < 0) throw new Error('diretório do ZIP não encontrado (arquivo possivelmente incompleto)');
const n = dv.getUint16(e + 10, true);
const cdSize = dv.getUint32(e + 12, true);
const cdOff = dv.getUint32(e + 16, true);
if (cdOff + cdSize > size) throw new Error('diretório do ZIP inconsistente');
const cd = await fetchRange(url, cdOff, cdOff + cdSize - 1);
const cv = new DataView(cd.buffer);
const out = [];
let p = 0;
for (let i = 0; i < n && p + 46 <= cd.length; i++) {
if (cv.getUint32(p, true) !== 0x02014b50) break;
const nl = cv.getUint16(p + 28, true), xl = cv.getUint16(p + 30, true), cl = cv.getUint16(p + 32, true);
out.push({
method: cv.getUint16(p + 10, true), csize: cv.getUint32(p + 20, true), usize: cv.getUint32(p + 24, true),
off: cv.getUint32(p + 42, true), name: new TextDecoder().decode(cd.subarray(p + 46, p + 46 + nl)),
});
p += 46 + nl + xl + cl;
}
return out;
}
async function zipRead(url, e, cap = 8 << 20) {
if (e.usize > cap) throw new Error('parte grande demais');
const lh = await fetchRange(url, e.off, e.off + 29);
const dv = new DataView(lh.buffer);
const start = e.off + 30 + dv.getUint16(26, true) + dv.getUint16(28, true);
const data = e.csize ? await fetchRange(url, start, start + e.csize - 1) : new Uint8Array(0);
if (e.method === 0) return data;
if (e.method !== 8) throw new Error('compressão não suportada');
const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
const xml = (bytes) => new DOMParser().parseFromString(new TextDecoder().decode(bytes), 'application/xml');
// ------------------------------------------------------------------ renderizadores
function note(host, html, cls = '') {
host.insertAdjacentHTML('beforeend', `<div class="pv-note ${cls}">${html}</div>`);
}
function failure(host, item, err, withHex) {
host.innerHTML = '';
note(host, `<b>Não foi possível pré-visualizar.</b><br>${esc(err?.message || err || '')} O arquivo pode estar danificado, incompleto ou parcialmente sobrescrito — isso também indica que a chance de recuperação é menor.`, 'bad');
return withHex ? hex(host, item, host.dataset.url, false) : null;
}
async function hex(host, item, url, large) {
const n = Math.min(item.size, large ? 4096 : 1024);
if (!n) { note(host, 'Arquivo vazio.'); return; }
const b = await fetchRange(url, 0, n - 1);
const lines = [];
for (let i = 0; i < b.length; i += 16) {
const row = [...b.subarray(i, i + 16)];
const h = row.map((x) => x.toString(16).padStart(2, '0')).join(' ').padEnd(47);
const a = row.map((x) => (x >= 32 && x < 127 ? String.fromCharCode(x) : '·')).join('');
lines.push(`<span class="o">${i.toString(16).padStart(6, '0')}</span> ${h} <span class="a">${esc(a)}</span>`);
}
host.insertAdjacentHTML('beforeend', `<pre class="pv-hex">${lines.join('\n')}</pre>`);
if (item.size > n) note(host, `Mostrando os primeiros ${human(n)} de ${human(item.size)}.`);
}
async function text(host, item, url, large) {
const cap = large ? 262144 : 24576;
const b = await fetchRange(url, 0, Math.min(item.size, cap) - 1);
const sample = b.subarray(0, 2048);
const bin = sample.filter((x) => x === 0 || (x < 9) ).length;
if (bin > sample.length * 0.02) { note(host, 'Este arquivo não parece ser texto.'); return hex(host, item, url, large); }
let s = new TextDecoder('utf-8', { fatal: false }).decode(b);
if ((s.match(/�/g) || []).length > s.length * 0.02) s = new TextDecoder('windows-1252').decode(b);
host.insertAdjacentHTML('beforeend', `<pre class="pv-text">${esc(s)}</pre>`);
if (item.size > b.length) note(host, `Mostrando o início (${human(b.length)} de ${human(item.size)}).`);
}
async function image(host, item, url, state) {
await new Promise((resolve) => {
const img = new Image();
img.alt = `Pré-visualização de ${item.name}`;
img.className = 'pv-img';
img.decoding = 'async';
img.onload = () => {
if (state.dead) return resolve();
host.innerHTML = '';
host.appendChild(img);
host.classList.add('zoomable');
img.onclick = () => host.classList.toggle('actual');
note(host, `${img.naturalWidth} × ${img.naturalHeight} px — clique para alternar entre ajustar e tamanho real.`);
resolve();
};
img.onerror = () => { if (!state.dead) failure(host, item, 'A imagem não pôde ser decodificada.', true); resolve(); };
img.src = url;
});
}
function media(host, item, url, tag, state) {
const el = document.createElement(tag);
el.controls = true;
el.preload = 'metadata';
el.className = `pv-${tag}`;
if (tag === 'video') el.playsInline = true;
el.onerror = () => { if (!state.dead) failure(host, item, `Não foi possível reproduzir este ${tag === 'video' ? 'vídeo' : 'áudio'}.`, true); };
el.src = url;
host.innerHTML = '';
host.appendChild(el);
state.cleanup.push(() => { el.pause?.(); el.removeAttribute('src'); el.load?.(); });
}
let pdfjsPromise;
function loadPdfjs() {
pdfjsPromise ??= import('./vendor/pdf.min.mjs').then((m) => {
m.GlobalWorkerOptions.workerSrc = new URL('./vendor/pdf.worker.min.mjs', import.meta.url).href;
return m;
});
return pdfjsPromise;
}
async function pdf(host, item, url, state, large) {
const pdfjs = await loadPdfjs();
const task = pdfjs.getDocument({ url, disableAutoFetch: true, isEvalSupported: false });
state.cleanup.push(() => task.destroy());
const doc = await task.promise;
if (state.dead) return;
host.innerHTML = '<div class="pv-pdfbar"><button class="btn sm" data-d="-1" aria-label="Página anterior">‹</button><span class="pg"></span><button class="btn sm" data-d="1" aria-label="Próxima página">›</button></div><div class="pv-pdfwrap"><canvas class="pv-canvas"></canvas></div>';
const canvas = host.querySelector('canvas');
const label = host.querySelector('.pg');
let n = 1, busy = false;
async function draw() {
if (busy || state.dead) return;
busy = true;
try {
const page = await doc.getPage(n);
const base = page.getViewport({ scale: 1 });
const width = Math.max(240, (host.clientWidth || 320) - 16);
const scale = (width / base.width) * (large ? 1 : 1);
const dpr = window.devicePixelRatio || 1;
const vp = page.getViewport({ scale: scale * dpr });
canvas.width = vp.width; canvas.height = vp.height;
canvas.style.width = `${vp.width / dpr}px`;
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
label.textContent = `Página ${n} de ${doc.numPages}`;
} catch (e) {
if (!state.dead) { host.innerHTML = ''; failure(host, item, e, true); }
}
busy = false;
}
host.querySelectorAll('[data-d]').forEach((b) => (b.onclick = () => { n = Math.min(doc.numPages, Math.max(1, n + +b.dataset.d)); draw(); }));
await draw();
}
const colIndex = (ref) => [...ref.replace(/\d+/g, '')].reduce((a, c) => a * 26 + c.charCodeAt(0) - 64, 0) - 1;
const colName = (i) => { let s = ''; i++; while (i > 0) { s = String.fromCharCode(65 + ((i - 1) % 26)) + s; i = Math.floor((i - 1) / 26); } return s; };
async function office(host, item, url, state) {
const ents = await zipDir(url, item.size);
const find = (n) => ents.find((e) => e.name === n);
const ext = item.ext.toLowerCase();
let html = '';
if (ext === 'docx') {
const d = xml(await zipRead(url, find('word/document.xml')));
const paras = [...d.getElementsByTagName('w:p')].slice(0, 400).map((p) => {
let t = '';
p.querySelectorAll('t, tab, br').forEach((n) => { t += n.localName === 't' ? n.textContent : n.localName === 'tab' ? '\t' : '\n'; });
const h = p.querySelector('pStyle')?.getAttribute('w:val') || '';
const tag = /^(Heading|Ttulo|Title)/i.test(h) ? 'h4' : 'p';
return t.trim() ? `<${tag}>${esc(t)}</${tag}>` : '';
});
html = `<div class="pv-page">${paras.join('') || '<p class="sub">Sem texto.</p>'}</div>`;
} else if (ext === 'odt') {
const d = xml(await zipRead(url, find('content.xml')));
const els = [...d.getElementsByTagName('text:p'), ...[]].slice(0, 400);
html = `<div class="pv-page">${els.map((p) => (p.textContent.trim() ? `<p>${esc(p.textContent)}</p>` : '')).join('') || '<p class="sub">Sem texto.</p>'}</div>`;
} else if (ext === 'xlsx') {
const ss = find('xl/sharedStrings.xml');
const strings = ss ? [...xml(await zipRead(url, ss)).getElementsByTagName('si')].map((si) => si.textContent) : [];
const sheet = ents.filter((e) => /^xl\/worksheets\/sheet\d+\.xml$/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))[0];
const d = xml(await zipRead(url, sheet));
const rows = [...d.getElementsByTagName('row')].slice(0, 80);
let maxc = 0;
const grid = rows.map((r) => {
const cells = {};
[...r.getElementsByTagName('c')].forEach((c) => {
const i = colIndex(c.getAttribute('r') || 'A1');
const v = c.getElementsByTagName('v')[0]?.textContent ?? c.getElementsByTagName('t')[0]?.textContent ?? '';
cells[i] = c.getAttribute('t') === 's' ? strings[+v] ?? '' : v;
maxc = Math.max(maxc, i + 1);
});
return [r.getAttribute('r'), cells];
});
maxc = Math.min(maxc, 16);
html = `<div class="pv-sheet"><table><thead><tr><th></th>${Array.from({ length: maxc }, (_, i) => `<th>${colName(i)}</th>`).join('')}</tr></thead><tbody>${grid
.map(([n, cells]) => `<tr><th>${esc(n)}</th>${Array.from({ length: maxc }, (_, i) => `<td>${esc(cells[i] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
} else if (ext === 'ods') {
const d = xml(await zipRead(url, find('content.xml')));
const rows = [...d.getElementsByTagName('table:table-row')].slice(0, 80);
html = `<div class="pv-sheet"><table><tbody>${rows.map((r) => `<tr>${[...r.getElementsByTagName('table:table-cell')].slice(0, 16).map((c) => `<td>${esc(c.textContent)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
} else {
// pptx / odp: texto por slide
let slides;
if (ext === 'pptx') {
const files = ents.filter((e) => /^ppt\/slides\/slide\d+\.xml$/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })).slice(0, 60);
slides = [];
for (const f of files) {
const d = xml(await zipRead(url, f));
slides.push([...d.getElementsByTagName('a:p')].map((p) => p.textContent).filter((t) => t.trim()));
}
} else {
const d = xml(await zipRead(url, find('content.xml')));
slides = [...d.getElementsByTagName('draw:page')].slice(0, 60).map((pg) => [...pg.getElementsByTagName('text:p')].map((p) => p.textContent).filter((t) => t.trim()));
}
html = slides.map((s, i) => `<div class="pv-slide"><small>Slide ${i + 1}</small>${s.map((t, j) => (j === 0 ? `<h4>${esc(t)}</h4>` : `<p>${esc(t)}</p>`)).join('') || '<p class="sub">Sem texto.</p>'}</div>`).join('');
}
if (state.dead) return;
host.innerHTML = html;
note(host, 'Texto extraído do documento. A formatação, as imagens e os gráficos originais não são exibidos.');
}
async function zip(host, item, url, state) {
const ents = await zipDir(url, item.size);
if (state.dead) return;
host.innerHTML = `<ul class="pv-zip">${ents.slice(0, 400).map((e) => `<li><span>${esc(e.name)}</span><small>${e.name.endsWith('/') ? 'pasta' : human(e.usize)}</small></li>`).join('')}</ul>`;
note(host, `${ents.length} item(ns) no arquivo compactado.`);
}
// ------------------------------------------------------------------ API
/** Desenha a pré-visualização de `item` dentro de `host`. Devolve uma função de limpeza. */
export function renderPreview(host, item, { url, large = false }) {
const state = { dead: false, cleanup: [] };
host.dataset.url = url;
host.classList.remove('zoomable', 'actual');
host.innerHTML = '<div class="pv-loading"><i></i></div>';
const kind = kindOf(item);
(async () => {
try {
if (!item.size) { host.innerHTML = ''; note(host, 'Arquivo vazio.'); return; }
if (kind === 'image') await image(host, item, url, state);
else if (kind === 'video') media(host, item, url, 'video', state);
else if (kind === 'audio') media(host, item, url, 'audio', state);
else if (kind === 'pdf') await pdf(host, item, url, state, large);
else if (kind === 'office') await office(host, item, url, state);
else if (kind === 'zip') await zip(host, item, url, state);
else if (kind === 'text') { host.innerHTML = ''; await text(host, item, url, large); }
else { host.innerHTML = ''; await hex(host, item, url, large); }
} catch (e) {
if (!state.dead) await failure(host, item, e, kind !== 'hex' && kind !== 'text');
}
})();
return () => { state.dead = true; state.cleanup.forEach((f) => { try { f(); } catch { /* ignora */ } }); };
}
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+3
View File
@@ -0,0 +1,3 @@
# Bibliotecas de terceiros
- **pdf.js** 4.10.38 (build "legacy"), © Mozilla, licença Apache-2.0 (`PDFJS-LICENSE`). Usada só para pré-visualizar PDFs, localmente e sem internet. Origem: https://github.com/mozilla/pdf.js
+21
View File
@@ -0,0 +1,21 @@
/**
* @licstart The following is the entire license notice for the
* JavaScript code in this page
*
* Copyright 2024 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @licend The above is the entire license notice for the
* JavaScript code in this page
*/var t={9306:(t,e,i)=>{var s=i(4901),n=i(6823),r=TypeError;t.exports=function(t){if(s(t))return t;throw new r(n(t)+" is not a function")}},3506:(t,e,i)=>{var s=i(3925),n=String,r=TypeError;t.exports=function(t){if(s(t))return t;throw new r("Can't set "+n(t)+" as a prototype")}},7080:(t,e,i)=>{var s=i(4402).has;t.exports=function(t){s(t);return t}},679:(t,e,i)=>{var s=i(1625),n=TypeError;t.exports=function(t,e){if(s(e,t))return t;throw new n("Incorrect invocation")}},8551:(t,e,i)=>{var s=i(34),n=String,r=TypeError;t.exports=function(t){if(s(t))return t;throw new r(n(t)+" is not an object")}},7811:t=>{t.exports="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof DataView},7394:(t,e,i)=>{var s=i(4576),n=i(6706),r=i(2195),a=s.ArrayBuffer,o=s.TypeError;t.exports=a&&n(a.prototype,"byteLength","get")||function(t){if("ArrayBuffer"!==r(t))throw new o("ArrayBuffer expected");return t.byteLength}},3238:(t,e,i)=>{var s=i(4576),n=i(7476),r=i(7394),a=s.ArrayBuffer,o=a&&a.prototype,l=o&&n(o.slice);t.exports=function(t){if(0!==r(t))return!1;if(!l)return!1;try{l(t,0,0);return!1}catch(t){return!0}}},5169:(t,e,i)=>{var s=i(3238),n=TypeError;t.exports=function(t){if(s(t))throw new n("ArrayBuffer is detached");return t}},5636:(t,e,i)=>{var s=i(4576),n=i(9504),r=i(6706),a=i(7696),o=i(5169),l=i(7394),h=i(4483),c=i(1548),d=s.structuredClone,u=s.ArrayBuffer,p=s.DataView,g=Math.min,f=u.prototype,m=p.prototype,b=n(f.slice),v=r(f,"resizable","get"),y=r(f,"maxByteLength","get"),w=n(m.getInt8),A=n(m.setInt8);t.exports=(c||h)&&function(t,e,i){var s,n=l(t),r=void 0===e?n:a(e),f=!v||!v(t);o(t);if(c){t=d(t,{transfer:[t]});if(n===r&&(i||f))return t}if(n>=r&&(!i||f))s=b(t,0,r);else{var m=i&&!f&&y?{maxByteLength:y(t)}:void 0;s=new u(r,m);for(var x=new p(t),_=new p(s),E=g(r,n),S=0;S<E;S++)A(_,S,w(x,S))}c||h(t);return s}},4644:(t,e,i)=>{var s,n,r,a=i(7811),o=i(3724),l=i(4576),h=i(4901),c=i(34),d=i(9297),u=i(6955),p=i(6823),g=i(6699),f=i(6840),m=i(2106),b=i(1625),v=i(2787),y=i(2967),w=i(8227),A=i(3392),x=i(1181),_=x.enforce,E=x.get,S=l.Int8Array,C=S&&S.prototype,T=l.Uint8ClampedArray,M=T&&T.prototype,P=S&&v(S),D=C&&v(C),k=Object.prototype,R=l.TypeError,I=w("toStringTag"),L=A("TYPED_ARRAY_TAG"),O="TypedArrayConstructor",N=a&&!!y&&"Opera"!==u(l.opera),B=!1,H={Int8Array:1,Uint8Array:1,Uint8ClampedArray:1,Int16Array:2,Uint16Array:2,Int32Array:4,Uint32Array:4,Float32Array:4,Float64Array:8},U={BigInt64Array:8,BigUint64Array:8},getTypedArrayConstructor=function(t){var e=v(t);if(c(e)){var i=E(e);return i&&d(i,O)?i[O]:getTypedArrayConstructor(e)}},isTypedArray=function(t){if(!c(t))return!1;var e=u(t);return d(H,e)||d(U,e)};for(s in H)(r=(n=l[s])&&n.prototype)?_(r)[O]=n:N=!1;for(s in U)(r=(n=l[s])&&n.prototype)&&(_(r)[O]=n);if(!N||!h(P)||P===Function.prototype){P=function TypedArray(){throw new R("Incorrect invocation")};if(N)for(s in H)l[s]&&y(l[s],P)}if(!N||!D||D===k){D=P.prototype;if(N)for(s in H)l[s]&&y(l[s].prototype,D)}N&&v(M)!==D&&y(M,D);if(o&&!d(D,I)){B=!0;m(D,I,{configurable:!0,get:function(){return c(this)?this[L]:void 0}});for(s in H)l[s]&&g(l[s],L,s)}t.exports={NATIVE_ARRAY_BUFFER_VIEWS:N,TYPED_ARRAY_TAG:B&&L,aTypedArray:function(t){if(isTypedArray(t))return t;throw new R("Target is not a typed array")},aTypedArrayConstructor:function(t){if(h(t)&&(!y||b(P,t)))return t;throw new R(p(t)+" is not a typed array constructor")},exportTypedArrayMethod:function(t,e,i,s){if(o){if(i)for(var n in H){var r=l[n];if(r&&d(r.prototype,t))try{delete r.prototype[t]}catch(i){try{r.prototype[t]=e}catch(t){}}}D[t]&&!i||f(D,t,i?e:N&&C[t]||e,s)}},exportTypedArrayStaticMethod:function(t,e,i){var s,n;if(o){if(y){if(i)for(s in H)if((n=l[s])&&d(n,t))try{delete n[t]}catch(t){}if(P[t]&&!i)return;try{return f(P,t,i?e:N&&P[t]||e)}catch(t){}}for(s in H)!(n=l[s])||n[t]&&!i||f(n,t,e)}},getTypedArrayConstructor,isView:function isView(t){if(!c(t))return!1;var e=u(t);return"DataView"===e||d(H,e)||d(U,e)},isTypedArray,TypedArray:P,TypedArrayPrototype:D}},5370:(t,e,i)=>{var s=i(6198);t.exports=function(t,e,i){for(var n=0,r=arguments.length>2?i:s(e),a=new t(r);r>n;)a[n]=e[n++];return a}},9617:(t,e,i)=>{var s=i(5397),n=i(5610),r=i(6198),createMethod=function(t){return function(e,i,a){var o=s(e),l=r(o);if(0===l)return!t&&-1;var h,c=n(a,l);if(t&&i!=i){for(;l>c;)if((h=o[c++])!=h)return!0}else for(;l>c;c++)if((t||c in o)&&o[c]===i)return t||c||0;return!t&&-1}};t.exports={includes:createMethod(!0),indexOf:createMethod(!1)}},4527:(t,e,i)=>{var s=i(3724),n=i(4376),r=TypeError,a=Object.getOwnPropertyDescriptor,o=s&&!function(){if(void 0!==this)return!0;try{Object.defineProperty([],"length",{writable:!1}).length=1}catch(t){return t instanceof TypeError}}();t.exports=o?function(t,e){if(n(t)&&!a(t,"length").writable)throw new r("Cannot set read only .length");return t.length=e}:function(t,e){return t.length=e}},7680:(t,e,i)=>{var s=i(9504);t.exports=s([].slice)},7628:(t,e,i)=>{var s=i(6198);t.exports=function(t,e){for(var i=s(t),n=new e(i),r=0;r<i;r++)n[r]=t[i-r-1];return n}},9928:(t,e,i)=>{Line truncated
+21
View File
@@ -0,0 +1,21 @@
/**
* @licstart The following is the entire license notice for the
* JavaScript code in this page
*
* Copyright 2024 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @licend The above is the entire license notice for the
* JavaScript code in this page
*/var e={9306:(e,t,i)=>{var a=i(4901),r=i(6823),s=TypeError;e.exports=function(e){if(a(e))return e;throw new s(r(e)+" is not a function")}},3506:(e,t,i)=>{var a=i(3925),r=String,s=TypeError;e.exports=function(e){if(a(e))return e;throw new s("Can't set "+r(e)+" as a prototype")}},7080:(e,t,i)=>{var a=i(4402).has;e.exports=function(e){a(e);return e}},679:(e,t,i)=>{var a=i(1625),r=TypeError;e.exports=function(e,t){if(a(t,e))return e;throw new r("Incorrect invocation")}},8551:(e,t,i)=>{var a=i(34),r=String,s=TypeError;e.exports=function(e){if(a(e))return e;throw new s(r(e)+" is not an object")}},7811:e=>{e.exports="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof DataView},7394:(e,t,i)=>{var a=i(4576),r=i(6706),s=i(2195),n=a.ArrayBuffer,o=a.TypeError;e.exports=n&&r(n.prototype,"byteLength","get")||function(e){if("ArrayBuffer"!==s(e))throw new o("ArrayBuffer expected");return e.byteLength}},3238:(e,t,i)=>{var a=i(4576),r=i(7476),s=i(7394),n=a.ArrayBuffer,o=n&&n.prototype,g=o&&r(o.slice);e.exports=function(e){if(0!==s(e))return!1;if(!g)return!1;try{g(e,0,0);return!1}catch(e){return!0}}},5169:(e,t,i)=>{var a=i(3238),r=TypeError;e.exports=function(e){if(a(e))throw new r("ArrayBuffer is detached");return e}},5636:(e,t,i)=>{var a=i(4576),r=i(9504),s=i(6706),n=i(7696),o=i(5169),g=i(7394),c=i(4483),l=i(1548),h=a.structuredClone,C=a.ArrayBuffer,Q=a.DataView,E=Math.min,u=C.prototype,d=Q.prototype,f=r(u.slice),p=s(u,"resizable","get"),m=s(u,"maxByteLength","get"),y=r(d.getInt8),w=r(d.setInt8);e.exports=(l||c)&&function(e,t,i){var a,r=g(e),s=void 0===t?r:n(t),u=!p||!p(e);o(e);if(l){e=h(e,{transfer:[e]});if(r===s&&(i||u))return e}if(r>=s&&(!i||u))a=f(e,0,s);else{var d=i&&!u&&m?{maxByteLength:m(e)}:void 0;a=new C(s,d);for(var b=new Q(e),D=new Q(a),S=E(s,r),k=0;k<S;k++)w(D,k,y(b,k))}l||c(e);return a}},4644:(e,t,i)=>{var a,r,s,n=i(7811),o=i(3724),g=i(4576),c=i(4901),l=i(34),h=i(9297),C=i(6955),Q=i(6823),E=i(6699),u=i(6840),d=i(2106),f=i(1625),p=i(2787),m=i(2967),y=i(8227),w=i(3392),b=i(1181),D=b.enforce,S=b.get,k=g.Int8Array,R=k&&k.prototype,N=g.Uint8ClampedArray,G=N&&N.prototype,M=k&&p(k),U=R&&p(R),x=Object.prototype,L=g.TypeError,H=y("toStringTag"),J=w("TYPED_ARRAY_TAG"),v="TypedArrayConstructor",Y=n&&!!m&&"Opera"!==C(g.opera),K=!1,T={Int8Array:1,Uint8Array:1,Uint8ClampedArray:1,Int16Array:2,Uint16Array:2,Int32Array:4,Uint32Array:4,Float32Array:4,Float64Array:8},q={BigInt64Array:8,BigUint64Array:8},getTypedArrayConstructor=function(e){var t=p(e);if(l(t)){var i=S(t);return i&&h(i,v)?i[v]:getTypedArrayConstructor(t)}},isTypedArray=function(e){if(!l(e))return!1;var t=C(e);return h(T,t)||h(q,t)};for(a in T)(s=(r=g[a])&&r.prototype)?D(s)[v]=r:Y=!1;for(a in q)(s=(r=g[a])&&r.prototype)&&(D(s)[v]=r);if(!Y||!c(M)||M===Function.prototype){M=function TypedArray(){throw new L("Incorrect invocation")};if(Y)for(a in T)g[a]&&m(g[a],M)}if(!Y||!U||U===x){U=M.prototype;if(Y)for(a in T)g[a]&&m(g[a].prototype,U)}Y&&p(G)!==U&&m(G,U);if(o&&!h(U,H)){K=!0;d(U,H,{configurable:!0,get:function(){return l(this)?this[J]:void 0}});for(a in T)g[a]&&E(g[a],J,a)}e.exports={NATIVE_ARRAY_BUFFER_VIEWS:Y,TYPED_ARRAY_TAG:K&&J,aTypedArray:function(e){if(isTypedArray(e))return e;throw new L("Target is not a typed array")},aTypedArrayConstructor:function(e){if(c(e)&&(!m||f(M,e)))return e;throw new L(Q(e)+" is not a typed array constructor")},exportTypedArrayMethod:function(e,t,i,a){if(o){if(i)for(var r in T){var s=g[r];if(s&&h(s.prototype,e))try{delete s.prototype[e]}catch(i){try{s.prototype[e]=t}catch(e){}}}U[e]&&!i||u(U,e,i?t:Y&&R[e]||t,a)}},exportTypedArrayStaticMethod:function(e,t,i){var a,r;if(o){if(m){if(i)for(a in T)if((r=g[a])&&h(r,e))try{delete r[e]}catch(e){}if(M[e]&&!i)return;try{return u(M,e,i?t:Y&&M[e]||t)}catch(e){}}for(a in T)!(r=g[a])||r[e]&&!i||u(r,e,t)}},getTypedArrayConstructor,isView:function isView(e){if(!l(e))return!1;var t=C(e);return"DataView"===t||h(T,t)||h(q,t)},isTypedArray,TypedArray:M,TypedArrayPrototype:U}},5370:(e,t,i)=>{var a=i(6198);e.exports=function(e,t,i){for(var r=0,s=arguments.length>2?i:a(t),n=new e(s);s>r;)n[r]=t[r++];return n}},9617:(e,t,i)=>{var a=i(5397),r=i(5610),s=i(6198),createMethod=function(e){return function(t,i,n){var o=a(t),g=s(o);if(0===g)return!e&&-1;var c,l=r(n,g);if(e&&i!=i){for(;g>l;)if((c=o[l++])!=c)return!0}else for(;g>l;l++)if((e||l in o)&&o[l]===i)return e||l||0;return!e&&-1}};e.exports={includes:createMethod(!0),indexOf:createMethod(!1)}},4527:(e,t,i)=>{var a=i(3724),r=i(4376),s=TypeError,n=Object.getOwnPropertyDescriptor,o=a&&!function(){if(void 0!==this)return!0;try{Object.defineProperty([],"length",{writable:!1}).length=1}catch(e){return e instanceof TypeError}}();e.exports=o?function(e,t){if(r(e)&&!n(e,"length").writable)throw new s("Cannot set read only .length");return e.length=t}:function(e,t){return e.length=t}},7680:(e,t,i)=>{var a=i(9504);e.exports=a([].slice)},7628:(e,t,i)=>{var a=i(6198);e.exports=function(e,t){for(var i=a(e),r=new t(i),s=0;s<i;s++)r[s]=e[i-s-1];return r}},9928:(e,t,i)=>{Line truncated
+135 -62
View File
@@ -3,12 +3,17 @@
use guara_core::service::Engine;
use serde_json::json;
use std::io::Read;
use tiny_http::{Header, Method, Response, Server};
use std::sync::Arc;
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
const INDEX: &str = include_str!("../../../apps/gui/ui/index.html");
const CSS: &str = include_str!("../../../apps/gui/ui/app.css");
const JS: &str = include_str!("../../../apps/gui/ui/app.js");
const PREVIEW_JS: &str = include_str!("../../../apps/gui/ui/preview.js");
const LOGO: &str = include_str!("../../../apps/gui/ui/guara-logo.svg");
const ICON: &str = include_str!("../../../apps/gui/ui/guara.svg");
const PDF_JS: &str = include_str!("../../../apps/gui/ui/vendor/pdf.min.mjs");
const PDF_WORKER: &str = include_str!("../../../apps/gui/ui/vendor/pdf.worker.min.mjs");
pub fn run(port: u16, open: bool) -> Result<(), String> {
let server = Server::http(("127.0.0.1", port)).map_err(|e| e.to_string())?;
@@ -19,78 +24,146 @@ pub fn run(port: u16, open: bool) -> Result<(), String> {
.unwrap_or(port);
let mut tok = [0u8; 16];
getrandom::getrandom(&mut tok).map_err(|e| e.to_string())?;
let token: String = tok.iter().map(|b| format!("{b:02x}")).collect();
let token: Arc<str> = tok
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
.into();
let url = format!("http://127.0.0.1:{real_port}/?token={token}");
eprintln!("Guará UI em {url}\n(Ctrl+C para encerrar. Para ler discos brutos, rode como administrador/root.)");
if open {
open_browser(&url);
}
let eng = Engine::new();
let hosts = [
let eng = Arc::new(Engine::new());
let hosts: Arc<[String; 2]> = Arc::new([
format!("127.0.0.1:{real_port}"),
format!("localhost:{real_port}"),
];
for mut req in server.incoming_requests() {
let host_ok = req
.headers()
.iter()
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()));
if !host_ok {
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
continue;
}
let path = req.url().split('?').next().unwrap_or("/").to_string();
let ct = |v: &str| Header::from_bytes("Content-Type", v).unwrap();
match (req.method().clone(), path.as_str()) {
(Method::Get, "/") | (Method::Get, "/index.html") => {
let _ = req.respond(
Response::from_string(INDEX).with_header(ct("text/html; charset=utf-8")),
);
}
(Method::Get, "/app.css") => {
let _ = req
.respond(Response::from_string(CSS).with_header(ct("text/css; charset=utf-8")));
}
(Method::Get, "/app.js") => {
let _ = req.respond(
Response::from_string(JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/guara.svg") => {
let _ = req.respond(Response::from_string(ICON).with_header(ct("image/svg+xml")));
}
(Method::Post, p) if p.starts_with("/rpc/") => {
let ok = req
.headers()
.iter()
.any(|h| h.field.equiv("X-Guara-Token") && h.value.as_str() == token);
if !ok {
let _ =
req.respond(Response::from_string("token inválido").with_status_code(401));
continue;
}
let method = p.trim_start_matches("/rpc/").to_string();
let mut body = String::new();
let _ = req.as_reader().take(1 << 20).read_to_string(&mut body);
let params = serde_json::from_str(&body).unwrap_or(json!({}));
let (code, out) = match eng.rpc(&method, params) {
Ok(v) => (200, v),
Err(e) => (400, json!({ "error": e.to_string() })),
};
let _ = req.respond(
Response::from_string(out.to_string())
.with_status_code(code)
.with_header(ct("application/json")),
);
}
_ => {
let _ = req.respond(Response::from_string("não encontrado").with_status_code(404));
}
}
]);
for req in server.incoming_requests() {
let (eng, token, hosts) = (eng.clone(), token.clone(), hosts.clone());
// Uma thread por requisição: vídeo/áudio em streaming não pode travar a interface.
std::thread::spawn(move || handle(req, &eng, &token, &hosts[..]));
}
Ok(())
}
fn header(name: &str, value: &str) -> Header {
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
}
fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
let host_ok = req
.headers()
.iter()
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()));
if !host_ok {
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
return;
}
let full = req.url().to_string();
let (path, query) = full.split_once('?').unwrap_or((full.as_str(), ""));
let ct = |v: &str| header("Content-Type", v);
match (req.method().clone(), path) {
(Method::Get, "/") | (Method::Get, "/index.html") => {
let _ = req
.respond(Response::from_string(INDEX).with_header(ct("text/html; charset=utf-8")));
}
(Method::Get, "/app.css") => {
let _ =
req.respond(Response::from_string(CSS).with_header(ct("text/css; charset=utf-8")));
}
(Method::Get, "/app.js") => {
let _ = req.respond(
Response::from_string(JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/preview.js") => {
let _ = req.respond(
Response::from_string(PREVIEW_JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/guara-logo.svg") => {
let _ = req.respond(Response::from_string(LOGO).with_header(ct("image/svg+xml")));
}
(Method::Get, "/guara.svg") => {
let _ = req.respond(Response::from_string(ICON).with_header(ct("image/svg+xml")));
}
(Method::Get, "/vendor/pdf.min.mjs") => {
let _ = req.respond(
Response::from_string(PDF_JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/vendor/pdf.worker.min.mjs") => {
let _ = req.respond(
Response::from_string(PDF_WORKER).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, p) if p.starts_with("/file/") => {
let tok_ok = query
.split('&')
.any(|kv| kv.strip_prefix("t=") == Some(token));
if !tok_ok {
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
return;
}
let mut it = p.trim_start_matches("/file/").split('/');
let (job, id) = (
it.next().unwrap_or(""),
it.next().and_then(|s| s.parse::<u64>().ok()),
);
let range = req
.headers()
.iter()
.find(|h| h.field.equiv("Range"))
.map(|h| h.value.as_str().to_string());
let Some(id) = id else {
let _ = req.respond(Response::from_string("id inválido").with_status_code(400));
return;
};
match eng.file_slice(job, id, range.as_deref()) {
Ok(slice) => {
let (status, len) = (slice.status(), slice.len() as usize);
let headers: Vec<Header> =
slice.headers().iter().map(|(k, v)| header(k, v)).collect();
let resp =
Response::new(StatusCode(status), headers, slice.reader(), Some(len), None);
let _ = req.respond(resp);
}
Err(code) => {
let _ =
req.respond(Response::from_string("indisponível").with_status_code(code));
}
}
}
(Method::Post, p) if p.starts_with("/rpc/") => {
let ok = req
.headers()
.iter()
.any(|h| h.field.equiv("X-Guara-Token") && h.value.as_str() == token);
if !ok {
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
return;
}
let method = p.trim_start_matches("/rpc/").to_string();
let mut body = String::new();
let _ = req.as_reader().take(1 << 20).read_to_string(&mut body);
let params = serde_json::from_str(&body).unwrap_or(json!({}));
let (code, out) = match eng.rpc(&method, params) {
Ok(v) => (200, v),
Err(e) => (400, json!({ "error": e.to_string() })),
};
let _ = req.respond(
Response::from_string(out.to_string())
.with_status_code(code)
.with_header(ct("application/json")),
);
}
_ => {
let _ = req.respond(Response::from_string("não encontrado").with_status_code(404));
}
}
}
fn open_browser(url: &str) {
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
+1
View File
@@ -12,6 +12,7 @@ pub mod partition;
pub mod recover;
pub mod scan;
pub mod service;
pub mod settings;
pub mod signatures;
pub mod source;
pub mod types;
+8 -9
View File
@@ -92,17 +92,16 @@ fn detect_fat(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
let root_secs = (root_entries * 32).div_ceil(bps);
let data = total.saturating_sub(reserved + nfats * fatsz + root_secs);
let clusters = data / spc;
let kind = if clusters < 4085 {
FsKind::Fat12
} else if clusters < 65525 {
FsKind::Fat16
} else {
// O layout do BPB manda: raiz fixa zerada + FAT de 32 bits = FAT32, mesmo com poucos
// clusters (mkfs.vfat -F 32 em mídias pequenas gera isso e o Linux monta).
let is32 = root_entries == 0 && fat16 == 0 && fat32 != 0;
let kind = if is32 {
FsKind::Fat32
} else if clusters < 4085 {
FsKind::Fat12
} else {
FsKind::Fat16
};
// Coerência: FAT32 tem root_entries == 0 e fat16 == 0.
if (kind == FsKind::Fat32) != (root_entries == 0 && fat16 == 0) {
return None;
}
Some((kind, Some(total * bps)))
}
+39
View File
@@ -210,3 +210,42 @@ pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<String> {
.map(|b| format!("{b:02x}"))
.collect())
}
/// Lê `buf.len()` bytes do conteúdo lógico de um achado a partir de `offset`
/// (usado para streaming/pré-visualização sem gravar em disco).
pub fn read_found_at(
src: &dyn Source,
f: &Found,
offset: u64,
buf: &mut [u8],
) -> std::io::Result<usize> {
if offset >= f.size || buf.is_empty() {
return Ok(0);
}
let want = buf.len().min((f.size - offset) as usize);
match &f.data {
DataLoc::Resident { bytes } => {
let o = offset as usize;
let n = want.min(bytes.len().saturating_sub(o));
buf[..n].copy_from_slice(&bytes[o..o + n]);
Ok(n)
}
DataLoc::Extents { extents } => {
let mut pos = 0u64;
for e in extents {
let elen = e.len.min(f.size.saturating_sub(pos));
if offset < pos + elen {
let within = offset - pos;
let n = want.min((elen - within) as usize);
if e.sparse {
buf[..n].fill(0);
return Ok(n);
}
return src.read_at(e.offset + within, &mut buf[..n]);
}
pos += elen;
}
Ok(0)
}
}
}
+189 -36
View File
@@ -278,37 +278,18 @@ impl Engine {
Ok(json!({ "total": total, "items": page }))
}
pub fn preview(&self, id: &str, file: u64) -> Result<Value> {
/// Achado + origem para leitura posicional (streaming de pré-visualização).
pub fn open_file(&self, id: &str, file: u64) -> Result<(Found, Arc<dyn Source>)> {
let j = self.job(id)?;
let (f, src) = {
let s = j.st.lock().unwrap();
let i = *s
.index
.get(&file)
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
(
s.found[i].clone(),
j.src.clone().ok_or_else(|| Error::msg("sem origem"))?,
)
};
let mime = match f.ext.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
_ => return Ok(json!({ "mime": null })),
};
if f.size > 8 * 1024 * 1024 {
return Ok(json!({ "mime": null, "reason": "grande demais para pré-visualizar" }));
}
let tmp =
std::env::temp_dir().join(format!("guara-preview-{}-{}", std::process::id(), f.id));
let r = recover::write_file(src.as_ref(), &f, &tmp);
let bytes = std::fs::read(&tmp);
let _ = std::fs::remove_file(&tmp);
r?;
Ok(json!({ "mime": mime, "base64": base64(&bytes?) }))
let s = j.st.lock().unwrap();
let i = *s
.index
.get(&file)
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
Ok((
s.found[i].clone(),
j.src.clone().ok_or_else(|| Error::msg("sem origem"))?,
))
}
// ---------------------------------------------------------------- recuperação
@@ -534,12 +515,12 @@ impl Engine {
.unwrap_or_default();
self.results(&s("job")?, &q)?
}
"preview" => self.preview(
&s("job")?,
p.get("id")
.and_then(|v| v.as_u64())
.ok_or_else(|| Error::msg("id ausente"))?,
)?,
"settings_get" => json!(crate::settings::load()),
"settings_set" => {
let st: crate::settings::Settings = serde_json::from_value(p.clone())?;
crate::settings::save(&st)?;
json!({})
}
"recover_start" => {
let ids: Option<Vec<u64>> = p
.get("ids")
@@ -697,3 +678,175 @@ pub fn base64(data: &[u8]) -> String {
}
out
}
/// Tipo MIME para servir a pré-visualização. Formatos que poderiam executar
/// script (HTML/SVG/XML) saem como texto puro.
pub fn mime_for(ext: &str) -> &'static str {
match ext.to_ascii_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"avif" => "image/avif",
"ico" => "image/x-icon",
"mp4" | "m4v" => "video/mp4",
"mov" => "video/quicktime",
"webm" => "video/webm",
"mkv" => "video/x-matroska",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"ogg" | "opus" => "audio/ogg",
"m4a" => "audio/mp4",
"aac" => "audio/aac",
"flac" => "audio/flac",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
}
/// Trecho de um arquivo achado, pronto para responder a uma requisição HTTP
/// (com suporte a `Range`) tanto no modo `guara ui` quanto no app Tauri.
pub struct FileSlice {
found: Found,
src: Arc<dyn Source>,
pub start: u64,
/// Inclusivo.
pub end: u64,
pub total: u64,
pub partial: bool,
}
impl FileSlice {
pub fn len(&self) -> u64 {
if self.total == 0 {
0
} else {
self.end - self.start + 1
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn status(&self) -> u16 {
if self.partial {
206
} else {
200
}
}
pub fn headers(&self) -> Vec<(&'static str, String)> {
let mut h = vec![
("Content-Type", mime_for(&self.found.ext).to_string()),
("Accept-Ranges", "bytes".into()),
("Cache-Control", "no-store".into()),
("X-Content-Type-Options", "nosniff".into()),
// Conteúdo recuperado é não confiável: nunca executa script nem carrega nada.
(
"Content-Security-Policy",
"sandbox; default-src 'none'".into(),
),
("Access-Control-Allow-Origin", "*".into()),
("Content-Disposition", "inline".into()),
];
if self.partial {
h.push((
"Content-Range",
format!("bytes {}-{}/{}", self.start, self.end, self.total),
));
}
h
}
/// Lê o trecho inteiro em memória (limitado a `cap` bytes).
pub fn read_all(&self, cap: u64) -> std::io::Result<Vec<u8>> {
let n = self.len().min(cap) as usize;
let mut out = vec![0u8; n];
let mut got = 0;
while got < n {
let r = crate::recover::read_found_at(
self.src.as_ref(),
&self.found,
self.start + got as u64,
&mut out[got..],
)?;
if r == 0 {
break;
}
got += r;
}
out.truncate(got);
Ok(out)
}
pub fn reader(self) -> FileSliceReader {
FileSliceReader {
pos: self.start,
slice: self,
}
}
}
pub struct FileSliceReader {
slice: FileSlice,
pos: u64,
}
impl std::io::Read for FileSliceReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.pos > self.slice.end || self.slice.total == 0 {
return Ok(0);
}
let left = (self.slice.end - self.pos + 1) as usize;
let n = buf.len().min(left).min(1 << 20);
let r = crate::recover::read_found_at(
self.slice.src.as_ref(),
&self.slice.found,
self.pos,
&mut buf[..n],
)?;
self.pos += r as u64;
Ok(r)
}
}
impl Engine {
/// Resolve `/file/<job>/<id>` + cabeçalho Range. `Err(status)` em caso de falha.
pub fn file_slice(
&self,
job: &str,
id: u64,
range: Option<&str>,
) -> std::result::Result<FileSlice, u16> {
let (found, src) = self.open_file(job, id).map_err(|_| 404u16)?;
let total = found.size;
let (mut start, mut end, mut partial) = (0u64, total.saturating_sub(1), false);
if let Some(spec) = range.and_then(|r| r.strip_prefix("bytes=")) {
let spec = spec.split(',').next().unwrap_or("");
if let Some((a, b)) = spec.split_once('-') {
let parsed = match (a.trim().parse::<u64>(), b.trim().parse::<u64>()) {
(Ok(a), Ok(b)) => Some((a, b.min(total.saturating_sub(1)))),
(Ok(a), Err(_)) if b.trim().is_empty() => Some((a, total.saturating_sub(1))),
(Err(_), Ok(n)) if a.trim().is_empty() && n > 0 => {
Some((total.saturating_sub(n), total.saturating_sub(1)))
}
_ => None,
};
if let Some((a, b)) = parsed {
if a >= total || a > b {
return Err(416);
}
start = a;
end = b;
partial = true;
}
}
}
Ok(FileSlice {
found,
src,
start,
end,
total,
partial,
})
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Preferências do usuário (tema etc.), compartilhadas entre o app normal e o
//! reiniciado como administrador.
use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
/// "auto", "light" ou "dark".
pub theme: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
theme: "auto".into(),
}
}
}
/// Pasta de configuração do usuário *real*, mesmo quando rodando como root
/// via sudo/pkexec (para o tema não "mudar" ao elevar privilégios).
pub fn config_dir() -> PathBuf {
if let Ok(d) = std::env::var("GUARA_CONFIG_DIR") {
if !d.is_empty() {
return PathBuf::from(d);
}
}
#[cfg(unix)]
{
if let Some(home) = invoking_user_home() {
return default_dir(home);
}
}
default_dir(PathBuf::from(
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default(),
))
}
fn default_dir(home: PathBuf) -> PathBuf {
#[cfg(windows)]
{
if let Ok(a) = std::env::var("APPDATA") {
return PathBuf::from(a).join("Guara");
}
home.join("AppData").join("Roaming").join("Guara")
}
#[cfg(target_os = "macos")]
{
home.join("Library")
.join("Application Support")
.join("Guara")
}
#[cfg(all(unix, not(target_os = "macos")))]
{
match std::env::var("XDG_CONFIG_HOME") {
Ok(x)
if !x.is_empty()
&& std::env::var("SUDO_USER").is_err()
&& std::env::var("PKEXEC_UID").is_err() =>
{
PathBuf::from(x).join("guara")
}
_ => home.join(".config").join("guara"),
}
}
}
/// Se somos root por sudo/pkexec, a home de quem invocou.
#[cfg(unix)]
fn invoking_user_home() -> Option<PathBuf> {
// SAFETY: geteuid não tem pré-condições.
if unsafe { libc::geteuid() } != 0 {
return None;
}
let user = std::env::var("SUDO_USER").ok().filter(|u| u != "root");
let uid = std::env::var("PKEXEC_UID").ok();
let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
for l in passwd.lines() {
let f: Vec<&str> = l.split(':').collect();
if f.len() >= 6 && (user.as_deref() == Some(f[0]) || uid.as_deref() == Some(f[2])) {
return Some(PathBuf::from(f[5]));
}
}
None
}
fn file() -> PathBuf {
config_dir().join("settings.json")
}
pub fn load() -> Settings {
std::fs::read(file())
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
pub fn save(s: &Settings) -> Result<()> {
let dir = config_dir();
std::fs::create_dir_all(&dir)?;
let path = file();
std::fs::write(&path, serde_json::to_vec_pretty(s)?)?;
#[cfg(unix)]
{
// Como root, devolve a posse ao dono da pasta para o app normal conseguir regravar.
use std::os::unix::fs::MetadataExt;
if unsafe { libc::geteuid() } == 0 {
if let Ok(m) = std::fs::metadata(&dir) {
let _ = std::os::unix::fs::chown(&path, Some(m.uid()), Some(m.gid()));
}
}
}
Ok(())
}
+71
View File
@@ -231,3 +231,74 @@ fn pausa_e_retoma_encontra_o_mesmo() {
b.sort();
assert_eq!(a, b);
}
/// Streaming de pré-visualização: Range devolve exatamente os bytes do arquivo recuperado.
#[test]
fn file_slice_com_range_bate_com_o_arquivo_recuperado() {
use std::io::Read;
let Some(dir) = fixtures() else { return };
let path = dir.join("ntfs.img");
let eng = service::Engine::new();
let job = eng
.start_scan(
path.to_str().unwrap(),
ScanOptions {
mode: ScanMode::Quick,
..Default::default()
},
)
.unwrap();
loop {
let st = eng.status(&job).unwrap();
if st["status"] != "running" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let res = eng
.results(
&job,
&service::Query {
query: Some("relatorio.pdf".into()),
..Default::default()
},
)
.unwrap();
let id = res["items"][0]["id"].as_u64().unwrap();
let size = res["items"][0]["size"].as_u64().unwrap();
let want = expected(&dir, "ntfs")
.into_iter()
.find(|(_, p)| p.ends_with("relatorio.pdf"))
.unwrap()
.0;
let full = eng.file_slice(&job, id, None).unwrap();
assert_eq!((full.status(), full.len()), (200, size));
let mut all = vec![];
full.reader().read_to_end(&mut all).unwrap();
assert_eq!(sha256(&all), want);
let part = eng.file_slice(&job, id, Some("bytes=100-199")).unwrap();
assert_eq!((part.status(), part.len()), (206, 100));
assert_eq!(part.read_all(1 << 20).unwrap(), all[100..200]);
let tail = eng.file_slice(&job, id, Some("bytes=-50")).unwrap();
assert_eq!(tail.read_all(1 << 20).unwrap(), all[all.len() - 50..]);
assert_eq!(
eng.file_slice(&job, id, Some(&format!("bytes={size}-")))
.err(),
Some(416)
);
assert_eq!(eng.file_slice(&job, 999_999, None).err(), Some(404));
}
#[test]
fn configuracoes_de_tema_persistem() {
let dir = tempfile::tempdir().unwrap();
std::env::set_var("GUARA_CONFIG_DIR", dir.path());
assert_eq!(settings::load().theme, "auto");
settings::save(&settings::Settings {
theme: "dark".into(),
})
.unwrap();
assert_eq!(settings::load().theme, "dark");
}
+55
View File
@@ -86,3 +86,58 @@ fn varredura_rapida_nao_faz_carving() {
7
);
}
/// Regressão: mkfs.vfat -F 32 em mídia pequena (< 65525 clusters) é FAT32 pelo BPB.
#[test]
fn fat32_pequeno_com_poucos_clusters_e_reconhecido() {
use std::process::Command;
let dir = tempfile::tempdir().unwrap();
let img = dir.path().join("p.img");
let ok = Command::new("truncate")
.args(["-s", "32M"])
.arg(&img)
.status()
.map(|s| s.success())
.unwrap_or(false)
&& Command::new("mkfs.vfat")
.args(["-F", "32"])
.arg(&img)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let file = dir.path().join("arquivo.txt");
std::fs::write(&file, b"conteudo de teste\n".repeat(500)).unwrap();
let ok = ok
&& Command::new("mcopy")
.arg("-i")
.arg(&img)
.arg(&file)
.arg("::/")
.status()
.map(|s| s.success())
.unwrap_or(false)
&& Command::new("mdel")
.arg("-i")
.arg(&img)
.arg("::/arquivo.txt")
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
eprintln!("SKIP: mtools/dosfstools ausentes");
return;
}
let res = scan_path(
&img,
&ScanOptions {
mode: ScanMode::Quick,
..Default::default()
},
);
let layout = res.layout.unwrap();
assert_eq!(layout.partitions[0].fs, FsKind::Fat32);
assert_eq!(res.found.len(), 1);
let out = tempfile::tempdir().unwrap();
recover_all(&img, &res.found, out.path());
assert!(hashes_in(out.path()).contains_key(&sha256(&b"conteudo de teste\n".repeat(500))));
}
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Gera a identidade visual do Guará: pixels de tamanhos distintos, soltos à esquerda,
que se juntam até formar a cabeça de um lobo-guará à direita.
Uso: python3 scripts/make-logo.py (escreve apps/gui/ui/guara.svg e guara-logo.svg)
Determinístico (semente fixa): a mesma execução gera o mesmo desenho.
"""
import math
import random
from pathlib import Path
OUT = Path(__file__).resolve().parent.parent / "apps/gui/ui"
# Cabeça de lobo-guará de perfil, olhando para a direita (coordenadas 0..100).
WOLF = [
(-18, 100), (-2, 76), (10, 62), (16, 54), (21, 2), (30, 24), (41, 40), (47, 38), (57, 0), (62, 34), (72, 46),
(88, 55), (100, 60), (100, 69), (88, 73), (74, 77), (68, 84), (62, 100), (40, 100),
]
EYE = (63, 54, 2.4) # x, y, raio (em coords do lobo)
def inside(poly, x, y):
c = False
j = len(poly) - 1
for i in range(len(poly)):
xi, yi = poly[i]
xj, yj = poly[j]
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi:
c = not c
j = i
return c
def lerp(a, b, t):
return a + (b - a) * t
def hexmix(c1, c2, t):
a = [int(c1[i:i + 2], 16) for i in (1, 3, 5)]
b = [int(c2[i:i + 2], 16) for i in (1, 3, 5)]
return "#" + "".join(f"{round(lerp(x, y, t)):02x}" for x, y in zip(a, b))
def smooth(t):
t = max(0.0, min(1.0, t))
return t * t * (3 - 2 * t)
def pixels(x_from, x_to, c_left, c_right, seed=7):
rnd = random.Random(seed)
cell = 4.0
out = []
wolf_x0, wolf_w = 62, 98 # onde o lobo começa e quanto mede (em unidades do desenho)
for cy in range(25):
for cx in range(40):
x, y = cx * cell + cell / 2, cy * cell + cell / 2
if not (x_from <= x <= x_to):
continue
t = smooth((x - 14) / 96)
wx, wy = (x - wolf_x0) * 100 / wolf_w, y
in_wolf = inside(WOLF, wx, wy) and math.hypot(wx - EYE[0], wy - EYE[1]) > EYE[2]
if in_wolf:
keep = 0.06 + 0.94 * t ** 1.15
if t > 0.9:
keep = 1.0
if rnd.random() > keep:
continue
size = cell * (0.28 + 0.72 * t ** 0.9) * (1 if t > 0.9 else rnd.uniform(0.65, 1.15))
jit = (1 - t) ** 1.4 * cell * 0.9
else:
if rnd.random() > 0.2 * (1 - t) ** 1.2 or x > 96:
continue
size = cell * rnd.uniform(0.22, 0.85)
jit = cell * 0.9
size = min(size, cell * 1.05)
px = x - size / 2 + rnd.uniform(-jit, jit)
py = y - size / 2 + rnd.uniform(-jit, jit)
col = hexmix(c_left, c_right, max(0.0, min(1.0, t * 1.05 + rnd.uniform(-0.12, 0.12))))
op = 0.5 + 0.5 * t if not in_wolf or t < 0.9 else 1
out.append((px, py, size, col, op))
return out
def rects(px, dx=0.0, dy=0.0):
return "".join(
f'<rect x="{x + dx:.2f}" y="{y + dy:.2f}" width="{s:.2f}" height="{s:.2f}" rx="{s * 0.12:.2f}" fill="{c}" opacity="{o:.2f}"/>'
for x, y, s, c, o in px
)
def main():
# Marca larga (fundo transparente): ciano claro -> verde-azulado profundo.
wide = pixels(0, 160, "#67e8f9", "#0f766e")
(OUT / "guara-logo.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 100" role="img" aria-label="Guará">' + rects(wide) + "</svg>\n"
)
# Ícone quadrado: fundo verde-azulado escuro, pixels claros.
icon_px = pixels(30, 160, "#99f6e4", "#f0fdfa", seed=11)
(OUT / "guara.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará">'
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#14b8a6"/><stop offset="1" stop-color="#0c4a6e"/></linearGradient></defs>'
'<rect width="128" height="128" rx="29" fill="url(#g)"/>'
'<g transform="translate(-19 20) scale(0.88)">' + rects(icon_px) + "</g></svg>\n"
)
if __name__ == "__main__":
main()