feat: pré-visualização completa, tema, nova identidade e correções (0.2.0)
- 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>
No files matched your search
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
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
|
## [0.1.0] - 2026-10-18
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-cli"
|
name = "guara-cli"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
@@ -260,7 +260,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-core"
|
name = "guara-core"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/guara-core", "crates/guara-cli"]
|
members = ["crates/guara-core", "crates/guara-cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/lucasrguerra/guara"
|
repository = "https://github.com/lucasrguerra/guara"
|
||||||
|
|||||||
@@ -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>
|
<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>
|
<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
|
## 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)
|
## Limites conhecidos (v0.1)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "guara-gui"
|
name = "guara-gui"
|
||||||
description = "Guará: interface gráfica de recuperação de arquivos"
|
description = "Guará: interface gráfica de recuperação de arquivos"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/lucasrguerra/guara"
|
repository = "https://github.com/lucasrguerra/guara"
|
||||||
@@ -20,6 +20,9 @@ tauri-plugin-dialog = "2"
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
guara-core = { path = "../../../crates/guara-core" }
|
guara-core = { path = "../../../crates/guara-core" }
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 416 KiB |
@@ -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
|
/// 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]
|
#[tauri::command]
|
||||||
fn relaunch_elevated() -> Result<(), String> {
|
fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
{
|
||||||
let mut cmd = std::process::Command::new("pkexec");
|
use tauri::Emitter;
|
||||||
cmd.arg("env");
|
let exe = elevated_exe()?;
|
||||||
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"] {
|
let cfg = guara_core::settings::config_dir();
|
||||||
if let Ok(v) = std::env::var(k) {
|
let log = std::env::temp_dir().join(format!("guara-elevate-{}.log", std::process::id()));
|
||||||
cmd.arg(format!("{k}={v}"));
|
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}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
c.arg(format!("GUARA_CONFIG_DIR={}", cfg.display()));
|
||||||
cmd.arg(exe);
|
c.arg(format!("GUARA_REPLACE_PID={pid}"));
|
||||||
cmd.spawn().map_err(|e| format!("pkexec indisponível: {e}"))?;
|
c.arg(&exe);
|
||||||
std::process::exit(0);
|
c
|
||||||
}
|
};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
let mut cmd = {
|
||||||
let script = format!("do shell script \"'{}' &> /dev/null &\" with administrator privileges", exe.display());
|
let shell = format!(
|
||||||
std::process::Command::new("osascript").args(["-e", &script]).spawn().map_err(|e| e.to_string())?;
|
"GUARA_CONFIG_DIR='{}' GUARA_REPLACE_PID={} '{}' >/dev/null 2>&1 &",
|
||||||
std::process::exit(0);
|
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)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let _ = exe;
|
let _ = app;
|
||||||
Err("o Guará já roda como administrador no Windows".into())
|
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() {
|
fn main() {
|
||||||
|
let engine = Arc::new(Engine::new());
|
||||||
|
let for_protocol = engine.clone();
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.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])
|
.invoke_handler(tauri::generate_handler![rpc, relaunch_elevated])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("erro ao iniciar o Guará");
|
.expect("erro ao iniciar o Guará");
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Guara",
|
"productName": "Guara",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"identifier": "dev.guara.app",
|
"identifier": "dev.guara.app",
|
||||||
"build": { "frontendDist": "../ui" },
|
"build": {
|
||||||
|
"frontendDist": "../ui"
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"withGlobalTauri": true,
|
"withGlobalTauri": true,
|
||||||
"windows": [
|
"windows": [
|
||||||
@@ -16,7 +18,9 @@
|
|||||||
"resizable": true
|
"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": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
@@ -24,6 +28,12 @@
|
|||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"shortDescription": "Recuperação de arquivos",
|
"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.",
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,10 +7,10 @@
|
|||||||
--text-2: #6e6e73;
|
--text-2: #6e6e73;
|
||||||
--text-3: #8e8e93;
|
--text-3: #8e8e93;
|
||||||
--line: rgba(0, 0, 0, 0.09);
|
--line: rgba(0, 0, 0, 0.09);
|
||||||
--accent: #d9541e;
|
--accent: #0f766e;
|
||||||
--accent-2: #ff8a3d;
|
--accent-2: #14b8a6;
|
||||||
--accent-ink: #ffffff;
|
--accent-ink: #ffffff;
|
||||||
--accent-soft: rgba(217, 84, 30, 0.1);
|
--accent-soft: rgba(15, 118, 110, 0.11);
|
||||||
--ok: #1a8f3c;
|
--ok: #1a8f3c;
|
||||||
--warn: #b26a00;
|
--warn: #b26a00;
|
||||||
--bad: #d12b2b;
|
--bad: #d12b2b;
|
||||||
@@ -34,10 +34,10 @@
|
|||||||
--text-2: #a1a1a6;
|
--text-2: #a1a1a6;
|
||||||
--text-3: #7d7d82;
|
--text-3: #7d7d82;
|
||||||
--line: rgba(255, 255, 255, 0.1);
|
--line: rgba(255, 255, 255, 0.1);
|
||||||
--accent: #ff7a3d;
|
--accent: #2dd4bf;
|
||||||
--accent-2: #ff9d5c;
|
--accent-2: #5eead4;
|
||||||
--accent-ink: #1a0b03;
|
--accent-ink: #042f2e;
|
||||||
--accent-soft: rgba(255, 122, 61, 0.15);
|
--accent-soft: rgba(45, 212, 191, 0.15);
|
||||||
--ok: #32d74b;
|
--ok: #32d74b;
|
||||||
--warn: #ffb340;
|
--warn: #ffb340;
|
||||||
--bad: #ff6961;
|
--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; }
|
* { box-sizing: border-box; }
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
body {
|
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 { 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; }
|
.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; }
|
.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); }
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* { animation: none !important; transition: none !important; }
|
* { 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); }
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Guará — interface. Fala com o núcleo por `rpc(método, parâmetros)`:
|
// 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.
|
// • 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 tauri = window.__TAURI__;
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
let token = params.get('token');
|
let token = params.get('token');
|
||||||
@@ -21,6 +23,32 @@ async function rpc(method, p = {}) {
|
|||||||
return body;
|
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
|
// ------------------------------------------------------------------ utilidades
|
||||||
const $ = (s, el = document) => el.querySelector(s);
|
const $ = (s, el = document) => el.querySelector(s);
|
||||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
@@ -173,7 +201,7 @@ function renderHome() {
|
|||||||
S.device = null; S.job = null;
|
S.device = null; S.job = null;
|
||||||
renderSide();
|
renderSide();
|
||||||
main.innerHTML = `<div class="page">
|
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>
|
<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="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>
|
<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 class="split"><div class="table" id="table"></div><aside class="preview" id="preview" aria-live="polite"></aside></div>
|
||||||
</div><div id="barHost"></div>`;
|
</div><div id="barHost"></div>`;
|
||||||
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
|
$('#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;
|
let t;
|
||||||
$('#q').oninput = (e) => { clearTimeout(t); t = setTimeout(() => { S.filters.query = e.target.value; resetSel(); tick(); }, 250); };
|
$('#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(); };
|
$('#conf').onchange = (e) => { S.filters.minConfidence = e.target.value || null; resetSel(); tick(); };
|
||||||
$('#sort').onchange = (e) => { S.filters.sort = e.target.value; tick(); };
|
$('#sort').onchange = (e) => { S.filters.sort = e.target.value; tick(); };
|
||||||
|
wireTable();
|
||||||
paintPreview();
|
paintPreview();
|
||||||
}
|
}
|
||||||
const resetSel = () => { S.selected = new Set(); S.allFiltered = false; };
|
const resetSel = () => { S.selected = new Set(); S.allFiltered = false; };
|
||||||
@@ -366,10 +397,10 @@ function paintStatus() {
|
|||||||
$('#scanSub').innerHTML = `<span class="mono">${esc(st.target)}</span>`;
|
$('#scanSub').innerHTML = `<span class="mono">${esc(st.target)}</span>`;
|
||||||
const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0;
|
const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0;
|
||||||
const eta = rate && running ? dur((p.total - p.done) / rate) : null;
|
const 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 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>' : ''}
|
<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" id="saveBtn">Salvar sessão…</button>' : ''}</div></div>
|
${!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="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat"><b>${st.found.toLocaleString('pt-BR')}</b><span>arquivos encontrados</span></div>
|
<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(--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(--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 class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
|
||||||
</div></div>`;
|
</div></div>`);
|
||||||
$('#pauseBtn')?.addEventListener('click', async () => { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); });
|
setHTML($('#warnArea'), (st.warnings || []).map((w) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('')
|
||||||
$('#stopBtn')?.addEventListener('click', async () => { await rpc('scan_stop', { job: S.job }); });
|
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : ''));
|
||||||
$('#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>` : '');
|
|
||||||
// chips
|
// chips
|
||||||
const total = st.found;
|
const total = st.found;
|
||||||
$('#chips').innerHTML = [`<button class="chip" data-c="" aria-pressed="${!S.filters.category}">Tudo<small>${total}</small></button>`]
|
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('');
|
.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(); }));
|
|
||||||
const sel = $('#conf'); if (sel && sel.value !== (S.filters.minConfidence || '')) sel.value = S.filters.minConfidence || '';
|
const sel = $('#conf'); if (sel && sel.value !== (S.filters.minConfidence || '')) sel.value = S.filters.minConfidence || '';
|
||||||
paintBar();
|
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() {
|
function paintList() {
|
||||||
const el = $('#table');
|
const el = $('#table');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const st = S.status;
|
const st = S.status;
|
||||||
if (!S.items.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const allSel = S.items.every((i) => S.selected.has(i.id)) || S.allFiltered;
|
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>`
|
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((i) => {
|
+ S.items.map(rowHtml).join('')
|
||||||
const [cl, cc] = CONF[i.confidence];
|
+ (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>` : ''));
|
||||||
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));
|
|
||||||
paintBar();
|
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;
|
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();
|
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 });
|
function detailsHtml(i) {
|
||||||
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}">`;
|
const [cl, cc] = CONF[i.confidence];
|
||||||
} catch { /* sem prévia */ }
|
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() {
|
function paintPreview() {
|
||||||
const el = $('#preview');
|
const el = $('#preview');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
stopPv?.(); stopPv = null;
|
||||||
const i = S.current;
|
const i = S.current;
|
||||||
if (!i) { el.innerHTML = `<div class="thumb">Selecione um arquivo para ver os detalhes</div>`; return; }
|
if (!i) { el.innerHTML = '<div class="viewer"><div class="pv-note">Selecione um arquivo para pré-visualizar</div></div>'; return; }
|
||||||
const [cl, cc] = CONF[i.confidence];
|
el.innerHTML = `<div class="viewer" id="pvHost"></div><div class="sub" style="font-size:12.5px">${esc(KIND_LABEL[kindOf(i)])}</div>${detailsHtml(i)}
|
||||||
el.innerHTML = `<div class="thumb">${esc(i.ext ? i.ext.toUpperCase() : 'ARQUIVO')}</div>
|
<div class="row"><button class="btn sm" id="pvFull">Tela cheia</button><button class="btn primary sm" id="recOne">Recuperar este arquivo</button></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>
|
stopPv = renderPreview($('#pvHost'), i, { url: fileUrl(S.job, i.id) });
|
||||||
<dt>Modificado</dt><dd>${date(i.modified)}</dd><dt>Origem</dt><dd>${i.origin === 'carved' ? 'Assinatura de formato' : esc(i.origin.toUpperCase())}</dd>
|
$('#pvFull').onclick = openLightbox;
|
||||||
<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>`;
|
|
||||||
$('#recOne').onclick = () => recover({ ids: [i.id] });
|
$('#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 selCount() { return S.allFiltered ? S.total : S.selected.size; }
|
||||||
|
|
||||||
function paintBar() {
|
function paintBar() {
|
||||||
@@ -465,14 +561,24 @@ function paintBar() {
|
|||||||
const st = S.status;
|
const st = S.status;
|
||||||
const n = selCount();
|
const n = selCount();
|
||||||
const canAll = st && st.found > 0 && st.status !== 'error';
|
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
|
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>` : '';
|
? '<button class="btn sm" data-bar="all">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}
|
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" id="recSel">Recuperar selecionados</button>' : '<button class="btn primary" id="recAll">Recuperar todos da lista</button>'}</div>`;
|
${n ? '<button class="btn primary" data-bar="sel">Recuperar selecionados</button>' : '<button class="btn primary" data-bar="rec">Recuperar todos da lista</button>'}`;
|
||||||
$('#selFiltered')?.addEventListener('click', () => { S.allFiltered = true; paintList(); });
|
let bar = host.firstElementChild;
|
||||||
$('#recSel')?.addEventListener('click', () => recover(S.allFiltered ? { filter: currentFilter() } : { ids: [...S.selected] }));
|
if (!bar) {
|
||||||
$('#recAll')?.addEventListener('click', () => recover({ filter: currentFilter() }));
|
// 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() {
|
function currentFilter() {
|
||||||
@@ -540,6 +646,8 @@ async function openJobFromList(s) { openJob(s); }
|
|||||||
const v = await rpc('version');
|
const v = await rpc('version');
|
||||||
$('#versionTag').textContent = `v${v.version}`;
|
$('#versionTag').textContent = `v${v.version}`;
|
||||||
S.defaults = await rpc('defaults');
|
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) {
|
} 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>`;
|
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;
|
return;
|
||||||
|
|||||||
@@ -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 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará">
|
<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
|
||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 828 B After Width: | Height: | Size: 29 KiB |
@@ -26,13 +26,14 @@
|
|||||||
<ul id="sessionList" class="list"></ul>
|
<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>
|
<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>
|
</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>
|
</aside>
|
||||||
<div class="scrim" id="scrim"></div>
|
<div class="scrim" id="scrim"></div>
|
||||||
<main class="main" id="main" tabindex="-1"></main>
|
<main class="main" id="main" tabindex="-1"></main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dialog id="dlg"></dialog>
|
<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>
|
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||||
<script src="app.js" type="module"></script>
|
<script src="app.js" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 */ } }); };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -3,12 +3,17 @@
|
|||||||
use guara_core::service::Engine;
|
use guara_core::service::Engine;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::io::Read;
|
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 INDEX: &str = include_str!("../../../apps/gui/ui/index.html");
|
||||||
const CSS: &str = include_str!("../../../apps/gui/ui/app.css");
|
const CSS: &str = include_str!("../../../apps/gui/ui/app.css");
|
||||||
const JS: &str = include_str!("../../../apps/gui/ui/app.js");
|
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 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> {
|
pub fn run(port: u16, open: bool) -> Result<(), String> {
|
||||||
let server = Server::http(("127.0.0.1", port)).map_err(|e| e.to_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);
|
.unwrap_or(port);
|
||||||
let mut tok = [0u8; 16];
|
let mut tok = [0u8; 16];
|
||||||
getrandom::getrandom(&mut tok).map_err(|e| e.to_string())?;
|
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}");
|
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.)");
|
eprintln!("Guará UI em {url}\n(Ctrl+C para encerrar. Para ler discos brutos, rode como administrador/root.)");
|
||||||
if open {
|
if open {
|
||||||
open_browser(&url);
|
open_browser(&url);
|
||||||
}
|
}
|
||||||
let eng = Engine::new();
|
let eng = Arc::new(Engine::new());
|
||||||
let hosts = [
|
let hosts: Arc<[String; 2]> = Arc::new([
|
||||||
format!("127.0.0.1:{real_port}"),
|
format!("127.0.0.1:{real_port}"),
|
||||||
format!("localhost:{real_port}"),
|
format!("localhost:{real_port}"),
|
||||||
];
|
]);
|
||||||
for mut req in server.incoming_requests() {
|
for req in server.incoming_requests() {
|
||||||
let host_ok = req
|
let (eng, token, hosts) = (eng.clone(), token.clone(), hosts.clone());
|
||||||
.headers()
|
// Uma thread por requisição: vídeo/áudio em streaming não pode travar a interface.
|
||||||
.iter()
|
std::thread::spawn(move || handle(req, &eng, &token, &hosts[..]));
|
||||||
.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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
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) {
|
fn open_browser(url: &str) {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
|
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod partition;
|
|||||||
pub mod recover;
|
pub mod recover;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
pub mod settings;
|
||||||
pub mod signatures;
|
pub mod signatures;
|
||||||
pub mod source;
|
pub mod source;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
@@ -92,17 +92,16 @@ fn detect_fat(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
|
|||||||
let root_secs = (root_entries * 32).div_ceil(bps);
|
let root_secs = (root_entries * 32).div_ceil(bps);
|
||||||
let data = total.saturating_sub(reserved + nfats * fatsz + root_secs);
|
let data = total.saturating_sub(reserved + nfats * fatsz + root_secs);
|
||||||
let clusters = data / spc;
|
let clusters = data / spc;
|
||||||
let kind = if clusters < 4085 {
|
// O layout do BPB manda: raiz fixa zerada + FAT de 32 bits = FAT32, mesmo com poucos
|
||||||
FsKind::Fat12
|
// clusters (mkfs.vfat -F 32 em mídias pequenas gera isso e o Linux monta).
|
||||||
} else if clusters < 65525 {
|
let is32 = root_entries == 0 && fat16 == 0 && fat32 != 0;
|
||||||
FsKind::Fat16
|
let kind = if is32 {
|
||||||
} else {
|
|
||||||
FsKind::Fat32
|
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)))
|
Some((kind, Some(total * bps)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -210,3 +210,42 @@ pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<String> {
|
|||||||
.map(|b| format!("{b:02x}"))
|
.map(|b| format!("{b:02x}"))
|
||||||
.collect())
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -278,37 +278,18 @@ impl Engine {
|
|||||||
Ok(json!({ "total": total, "items": page }))
|
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 j = self.job(id)?;
|
||||||
let (f, src) = {
|
let s = j.st.lock().unwrap();
|
||||||
let s = j.st.lock().unwrap();
|
let i = *s
|
||||||
let i = *s
|
.index
|
||||||
.index
|
.get(&file)
|
||||||
.get(&file)
|
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
|
||||||
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
|
Ok((
|
||||||
(
|
s.found[i].clone(),
|
||||||
s.found[i].clone(),
|
j.src.clone().ok_or_else(|| Error::msg("sem origem"))?,
|
||||||
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?) }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- recuperação
|
// ---------------------------------------------------------------- recuperação
|
||||||
@@ -534,12 +515,12 @@ impl Engine {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
self.results(&s("job")?, &q)?
|
self.results(&s("job")?, &q)?
|
||||||
}
|
}
|
||||||
"preview" => self.preview(
|
"settings_get" => json!(crate::settings::load()),
|
||||||
&s("job")?,
|
"settings_set" => {
|
||||||
p.get("id")
|
let st: crate::settings::Settings = serde_json::from_value(p.clone())?;
|
||||||
.and_then(|v| v.as_u64())
|
crate::settings::save(&st)?;
|
||||||
.ok_or_else(|| Error::msg("id ausente"))?,
|
json!({})
|
||||||
)?,
|
}
|
||||||
"recover_start" => {
|
"recover_start" => {
|
||||||
let ids: Option<Vec<u64>> = p
|
let ids: Option<Vec<u64>> = p
|
||||||
.get("ids")
|
.get("ids")
|
||||||
@@ -697,3 +678,175 @@ pub fn base64(data: &[u8]) -> String {
|
|||||||
}
|
}
|
||||||
out
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -231,3 +231,74 @@ fn pausa_e_retoma_encontra_o_mesmo() {
|
|||||||
b.sort();
|
b.sort();
|
||||||
assert_eq!(a, b);
|
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");
|
||||||
|
}
|
||||||
@@ -86,3 +86,58 @@ fn varredura_rapida_nao_faz_carving() {
|
|||||||
7
|
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))));
|
||||||
|
}
|
||||||
@@ -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()
|
||||||