feat: responsive pause/stop, non-blocking update with progress bar, delete saved sessions (1.5.0)
CI / Format and clippy (push) Successful in 25s
Release / Create release (push) Successful in 13s
CI / Dependency audit (push) Successful in 11s
CI / Check desktop app (Linux) (push) Successful in 1m35s
Release / CLI (linux-x86_64) (push) Successful in 2m31s
Release / CLI (linux-aarch64) (push) Successful in 1m46s
CI / Tests (Linux) (push) Successful in 4m28s
Release / Desktop app (linux-x86_64) (push) Successful in 6m9s
Release / Desktop app (windows) (push) Successful in 4m13s
Release / Desktop app (linux-aarch64) (push) Successful in 9m51s

- carve: check cancellation inside chunks and per hit; reassembly stage is cancellable
- status exposes `stopping`; UI shows "Pausing…" immediately
- Tauri update commands run off the UI thread; download percentage reported
- delete autosaved sessions manually (one/all) and by retention setting
- chore(release): 1.5.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-11-03 16:36:34 -03:00
1 parent db5b972650
commit 0b9a16e8a0
20 files changed
+261 -34

No files matched your search

+10
View File
@@ -4,6 +4,16 @@
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
## [1.5.0] - 2026-11-03
### Added
- **Update progress bar.** The in-app update shows a real download percentage bar followed by the verify/install/restart phases.
- **Delete saved sessions.** Each autosaved session in the sidebar has a delete button, plus a "delete all" button. Settings gained "Delete autosaved sessions after" (never, 7, 30 or 90 days), applied on startup. Sessions open in the app are never removed automatically.
### Fixed
- **Updating no longer freezes the app.** The update check and install ran on the UI thread; they now run in the background.
- **Pause and Stop respond right away.** The signature scan only checked for the request once per 8 MiB block; it now checks continuously, and the UI shows "Pausing…" immediately.
## [1.4.0] - 2026-11-03 ## [1.4.0] - 2026-11-03
### Added ### Added
+10
View File
@@ -4,6 +4,16 @@
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.
## [1.5.0] - 2026-11-03
### Adicionado
- **Barra de progresso da atualização.** A atualização dentro do app mostra uma barra com a porcentagem real do download, seguida das fases de verificação, instalação e reinício.
- **Apagar sessões salvas.** Cada sessão salva sozinha na lateral tem um botão de apagar, além de um botão "apagar todas". As configurações ganharam "Apagar sessões salvas sozinhas após" (nunca, 7, 30 ou 90 dias), aplicado ao abrir. Sessões abertas no app nunca são removidas automaticamente.
### Corrigido
- **Atualizar não trava mais o app.** A verificação e a instalação rodavam na thread da interface; agora rodam em segundo plano.
- **Pausar e Parar respondem na hora.** A varredura por assinatura só checava o pedido a cada bloco de 8 MiB; agora checa continuamente, e a interface mostra "Pausando…" imediatamente.
## [1.4.0] - 2026-11-03 ## [1.4.0] - 2026-11-03
### Adicionado ### Adicionado
Generated
+2 -2
View File
@@ -416,7 +416,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-cli" name = "guara-cli"
version = "1.4.0" version = "1.5.0"
dependencies = [ dependencies = [
"clap", "clap",
"ctrlc", "ctrlc",
@@ -429,7 +429,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-core" name = "guara-core"
version = "1.4.0" version = "1.5.0"
dependencies = [ dependencies = [
"aes", "aes",
"argon2", "argon2",
+1 -1
View File
@@ -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 = "1.4.0" version = "1.5.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara" repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara"
+2 -2
View File
@@ -1342,7 +1342,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-core" name = "guara-core"
version = "1.4.0" version = "1.5.0"
dependencies = [ dependencies = [
"aes", "aes",
"argon2", "argon2",
@@ -1373,7 +1373,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-gui" name = "guara-gui"
version = "1.4.0" version = "1.5.0"
dependencies = [ dependencies = [
"guara-core", "guara-core",
"libc", "libc",
+1 -1
View File
@@ -3,7 +3,7 @@
[package] [package]
name = "guara-gui" name = "guara-gui"
description = "Guará: graphical file recovery interface" description = "Guará: graphical file recovery interface"
version = "1.4.0" version = "1.5.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
authors = ["Guará contributors"] authors = ["Guará contributors"]
+23 -7
View File
@@ -22,25 +22,41 @@ async fn rpc(
} }
#[tauri::command] #[tauri::command]
fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> { async fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
let status = state.0.update_check().map_err(|e| e.to_string())?; let eng = state.0.clone();
tauri::async_runtime::spawn_blocking(move || {
let status = eng.update_check().map_err(|e| e.to_string())?;
serde_json::to_value(status).map_err(|e| e.to_string()) serde_json::to_value(status).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
} }
#[tauri::command] #[tauri::command]
fn update_apply(app: tauri::AppHandle, state: tauri::State<'_, State>) -> Result<String, String> { async fn update_apply(app: tauri::AppHandle, state: tauri::State<'_, State>) -> Result<String, String> {
use tauri::Emitter; use tauri::Emitter;
#[cfg(any(target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "linux", target_os = "macos"))]
let exe = elevated_exe().unwrap_or_else(|_| std::env::current_exe().unwrap_or_default()); let exe = elevated_exe().unwrap_or_else(|_| std::env::current_exe().unwrap_or_default());
#[cfg(not(any(target_os = "linux", target_os = "macos")))] #[cfg(not(any(target_os = "linux", target_os = "macos")))]
let exe = std::env::current_exe().unwrap_or_default(); let exe = std::env::current_exe().unwrap_or_default();
state // Blocking download/install must not run on the UI thread, or the window freezes.
.0 let eng = state.0.clone();
.update_apply_desktop(&exe, move |phase| { tauri::async_runtime::spawn_blocking(move || {
let _ = app.emit("update-progress", serde_json::json!({ "phase": phase })); eng.update_apply_desktop(&exe, move |phase| {
let (name, pct) = match phase.split_once(':') {
Some((n, p)) => (n, p.parse::<u8>().ok()),
None => (phase, None),
};
let _ = app.emit(
"update-progress",
serde_json::json!({ "phase": name, "percent": pct }),
);
}) })
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
} }
/// `true` when the process already has sufficient privileges to read raw disks. /// `true` when the process already has sufficient privileges to read raw disks.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Guará", "productName": "Guará",
"version": "1.4.0", "version": "1.5.0",
"identifier": "dev.guara.app", "identifier": "dev.guara.app",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
+5
View File
@@ -542,3 +542,8 @@ dialog.lightbox[open] { display: grid; }
.update-card .upd-actions { display: flex; gap: 8px; flex-wrap: wrap; } .update-card .upd-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.update-card .sub { margin: 0; } .update-card .sub { margin: 0; }
pre.upd-notes { max-height: 50vh; overflow: auto; white-space: pre-wrap; font-size: 13px; } pre.upd-notes { max-height: 50vh; overflow: auto; white-space: pre-wrap; font-size: 13px; }
.update-card .upd-bar { margin-top: 10px; }
.list li.has-del { position: relative; }
.list li.has-del .del { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); opacity: 0; }
.list li.has-del:hover .del, .list li.has-del .del:focus-visible { opacity: 1; }
+50 -10
View File
@@ -53,7 +53,7 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async
// ------------------------------------------------------------------ settings // ------------------------------------------------------------------ settings
// (declared before use: `S` is defined further below, so the object lives here) // (declared before use: `S` is defined further below, so the object lives here)
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true }; const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true, session_retention_days: 0 };
async function saveSettings(patch) { async function saveSettings(patch) {
Object.assign(SETTINGS, patch); Object.assign(SETTINGS, patch);
try { await rpc('settings_set', SETTINGS); } catch { /* continue with local storage only */ } try { await rpc('settings_set', SETTINGS); } catch { /* continue with local storage only */ }
@@ -201,11 +201,36 @@ function renderSide() {
const rl = $('#recentList'); const rl = $('#recentList');
rl.dataset.empty = 'Nada salvo ainda'; rl.dataset.empty = 'Nada salvo ainda';
rl.innerHTML = (S.recent || []).map((r, i) => ` rl.innerHTML = (S.recent || []).map((r, i) => `
<li><button class="dev" data-r="${i}" title="${esc(r.path)}"><span class="ico">${icon('default')}</span> <li class="has-del"><button class="dev" data-r="${i}" title="${esc(r.path)}"><span class="ico">${icon('default')}</span>
<span><span class="t">${esc((r.target || '').split(/[\\/]/).pop() || 'Sessão')}</span><span class="s">${r.found.toLocaleString(LOC())} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString(LOC(), { dateStyle: 'short', timeStyle: 'short' })}</span></span></button></li>`).join(''); <span><span class="t">${esc((r.target || '').split(/[\\/]/).pop() || 'Sessão')}</span><span class="s">${r.found.toLocaleString(LOC())} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString(LOC(), { dateStyle: 'short', timeStyle: 'short' })}</span></span></button><button class="icon-btn small del" data-del="${i}" title="Apagar esta sessão" aria-label="Apagar esta sessão"><svg viewBox="0 0 24 24" width="14" height="14"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg></button></li>`).join('');
rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r]))); rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r])));
rl.querySelectorAll('.del').forEach((b) => (b.onclick = (e) => { e.stopPropagation(); deleteRecent(S.recent[+b.dataset.del]); }));
} }
function confirmDlg({ title, text, ok }) {
return new Promise((resolve) => {
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2><p>${text}</p><menu><button class="btn" value="cancel">Cancelar</button><button class="btn danger" value="ok">${esc(ok)}</button></menu></form>`;
d.onclose = () => resolve(d.returnValue === 'ok');
d.showModal();
});
}
async function deleteRecent(r) {
if (!r) return;
const name = (r.target || '').split(/[\\/]/).pop() || 'Sessão';
if (!await confirmDlg({ title: 'Apagar sessão salva', text: `A sessão de <code>${esc(name)}</code> será apagada de vez. Os arquivos do disco não são afetados.`, ok: 'Apagar' })) return;
try { await rpc('session_delete', { path: r.path }); toast('Sessão apagada'); } catch (e) { toast(e.message); }
loadRecent();
}
$('#clearRecentBtn')?.addEventListener('click', async () => {
if (!(S.recent || []).length) { toast('Nada para apagar'); return; }
if (!await confirmDlg({ title: 'Apagar todas as sessões salvas', text: 'Todas as sessões salvas sozinhas serão apagadas de vez. Sessões abertas agora são mantidas.', ok: 'Apagar tudo' })) return;
try { const r = await rpc('sessions_prune', { all: true }); toast(`Sessões apagadas: ${r.removed}`); } catch (e) { toast(e.message); }
loadRecent();
});
async function loadRecent() { async function loadRecent() {
try { S.recent = await rpc('sessions_list'); } catch { S.recent = []; } try { S.recent = await rpc('sessions_list'); } catch { S.recent = []; }
renderSide(); renderSide();
@@ -590,7 +615,9 @@ function paintStatus() {
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;
const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString(LOC())}/${st.enriching.total.toLocaleString(LOC())}</span>` : ''; const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString(LOC())}/${st.enriching.total.toLocaleString(LOC())}</span>` : '';
const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>' const stopping = running && st.stopping;
const buttons = stopping ? '<button class="btn sm" disabled>Pausando…</button>'
: 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="${st.source === false ? 'reconnect' : 'resume'}">${st.source === false ? 'Reconectar o disco' : 'Continuar'}</button><button class="btn sm" data-act="relink">Escolher a origem…</button>` : ''; : st.status === 'paused' ? `<button class="btn sm primary" data-act="${st.source === false ? 'reconnect' : 'resume'}">${st.source === false ? 'Reconectar o disco' : 'Continuar'}</button><button class="btn sm" data-act="relink">Escolher a origem…</button>` : '';
const relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : ''; const relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : '';
const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : ''); const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '');
@@ -651,8 +678,8 @@ async function chooseSource() {
async function onProgressClick(e) { async function onProgressClick(e) {
const act = e.target.closest('[data-act]')?.dataset.act; const act = e.target.closest('[data-act]')?.dataset.act;
if (!act) return; if (!act) return;
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); } if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); tick(); }
else if (act === 'stop') await rpc('scan_stop', { job: S.job }); else if (act === 'stop') { await rpc('scan_stop', { job: S.job }); tick(); }
else if (act === 'resume') { else if (act === 'resume') {
try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); } try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
catch (err) { toast(err.message); chooseSource(); } catch (err) { toast(err.message); chooseSource(); }
@@ -753,9 +780,11 @@ $('#settingsBtn')?.addEventListener('click', () => {
<label class="fld"><span>Idioma</span><select class="select" name="lang"><option value="auto">Auto</option><option value="pt">Português</option><option value="en">English</option></select></label> <label class="fld"><span>Idioma</span><select class="select" name="lang"><option value="auto">Auto</option><option value="pt">Português</option><option value="en">English</option></select></label>
<label class="check"><input type="checkbox" name="notify" ${SETTINGS.notify ? 'checked' : ''}><span><b>Notificações</b><br><span class="sub">Avisar quando uma tarefa longa terminar</span></span></label> <label class="check"><input type="checkbox" name="notify" ${SETTINGS.notify ? 'checked' : ''}><span><b>Notificações</b><br><span class="sub">Avisar quando uma tarefa longa terminar</span></span></label>
<label class="check"><input type="checkbox" name="maps" ${SETTINGS.maps ? 'checked' : ''}><span><b>Mapas online</b><br><span class="sub">Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)</span></span></label> <label class="check"><input type="checkbox" name="maps" ${SETTINGS.maps ? 'checked' : ''}><span><b>Mapas online</b><br><span class="sub">Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)</span></span></label>
<label class="fld"><span>Apagar sessões salvas sozinhas após</span><select class="select" name="retention"><option value="0">Nunca</option><option value="7">7 dias</option><option value="30">30 dias</option><option value="90">90 dias</option></select></label>
${tauri?.core?.invoke ? `<label class="check"><input type="checkbox" name="updates" ${SETTINGS.updates ? 'checked' : ''}><span><b>Atualizações automáticas</b><br><span class="sub">Ao abrir, consulta o Gitea para ver se há versão nova (uma consulta, sem enviar dados seus)</span><br><button type="button" class="btn sm" id="updNow">Verificar agora</button></span></label>` : ''} ${tauri?.core?.invoke ? `<label class="check"><input type="checkbox" name="updates" ${SETTINGS.updates ? 'checked' : ''}><span><b>Atualizações automáticas</b><br><span class="sub">Ao abrir, consulta o Gitea para ver se há versão nova (uma consulta, sem enviar dados seus)</span><br><button type="button" class="btn sm" id="updNow">Verificar agora</button></span></label>` : ''}
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Salvar</button></menu></form>`; <menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Salvar</button></menu></form>`;
d.querySelector('[name=lang]').value = SETTINGS.lang; d.querySelector('[name=lang]').value = SETTINGS.lang;
d.querySelector('[name=retention]').value = String([0, 7, 30, 90].includes(SETTINGS.session_retention_days) ? SETTINGS.session_retention_days : 0);
d.querySelector('#updNow')?.addEventListener('click', (e) => { d.querySelector('#updNow')?.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
if (UPD) applyUpdate(); if (UPD) applyUpdate();
@@ -763,11 +792,11 @@ $('#settingsBtn')?.addEventListener('click', () => {
}); });
d.onclose = () => { d.onclose = () => {
if (d.returnValue !== 'ok') return; if (d.returnValue !== 'ok') return;
const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked }; const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked, session_retention_days: +d.querySelector('[name=retention]').value };
if (d.querySelector('[name=updates]')) patch.updates = d.querySelector('[name=updates]').checked; if (d.querySelector('[name=updates]')) patch.updates = d.querySelector('[name=updates]').checked;
setLang(patch.lang); setLang(patch.lang);
patch.ui_lang = currentLang(); patch.ui_lang = currentLang();
saveSettings(patch); saveSettings(patch).then(() => rpc('sessions_prune', {}).then(loadRecent).catch(() => {}));
EX?.relang?.(); EX?.relang?.();
toast('Salvo'); toast('Salvo');
}; };
@@ -785,7 +814,15 @@ async function applyUpdate() {
updating = true; updating = true;
const updApply = $('#updApply'); const updApply = $('#updApply');
const updNow = $('#updNow'); const updNow = $('#updNow');
const setPhase = (txt) => { const setPhase = (txt, pct) => {
const card = $('#updateCard');
if (card) {
let bar = card.querySelector('.upd-bar');
if (!bar) { bar = document.createElement('div'); bar.className = 'bar upd-bar'; bar.innerHTML = '<i></i>'; card.appendChild(bar); }
bar.classList.toggle('indet', pct == null);
bar.firstChild.style.width = pct == null ? '35%' : `${pct}%`;
}
if (pct != null) txt = `${txt} ${pct}%`;
if (updApply) { updApply.disabled = true; updApply.textContent = txt; } if (updApply) { updApply.disabled = true; updApply.textContent = txt; }
if (updNow) { updNow.disabled = true; updNow.textContent = txt; } if (updNow) { updNow.disabled = true; updNow.textContent = txt; }
}; };
@@ -796,7 +833,8 @@ async function applyUpdate() {
try { try {
unlisten = await tauri.event.listen('update-progress', (event) => { unlisten = await tauri.event.listen('update-progress', (event) => {
const phase = event.payload?.phase; const phase = event.payload?.phase;
if (phase === 'downloading') setPhase('Baixando a atualização…'); const pct = event.payload?.percent;
if (phase === 'downloading') setPhase('Baixando a atualização…', pct);
else if (phase === 'verifying') setPhase('Verificando integridade…'); else if (phase === 'verifying') setPhase('Verificando integridade…');
else if (phase === 'installing') setPhase('Instalando a atualização…'); else if (phase === 'installing') setPhase('Instalando a atualização…');
else if (phase === 'restarting') setPhase('Reiniciando…'); else if (phase === 'restarting') setPhase('Reiniciando…');
@@ -809,6 +847,7 @@ async function applyUpdate() {
setPhase('Atualização concluída'); setPhase('Atualização concluída');
} catch (e) { } catch (e) {
updating = false; updating = false;
$('#updateCard .upd-bar')?.remove();
if (updApply) { updApply.disabled = false; updApply.textContent = 'Atualizar agora'; } if (updApply) { updApply.disabled = false; updApply.textContent = 'Atualizar agora'; }
if (updNow) { updNow.disabled = false; updNow.textContent = UPD?.version ? `atualizar para ${UPD.version}` : 'Verificar agora'; } if (updNow) { updNow.disabled = false; updNow.textContent = UPD?.version ? `atualizar para ${UPD.version}` : 'Verificar agora'; }
toast(`Falha na atualização: ${e}`); toast(`Falha na atualização: ${e}`);
@@ -914,6 +953,7 @@ function watchClose() {
return; return;
} }
await loadDevices(); await loadDevices();
try { await rpc('sessions_prune', {}); } catch { /* retention is best-effort */ }
await loadRecent(); await loadRecent();
renderHome(); renderHome();
})(); })();
+16
View File
@@ -619,4 +619,20 @@ export const EN = {
'{n} arquivo': '{n} file', '{n} arquivo': '{n} file',
'{n} arquivos': '{n} files', '{n} arquivos': '{n} files',
'{n} {n} · {n}': '{n} {n} · {n}', '{n} {n} · {n}': '{n} {n} · {n}',
'Apagar esta sessão': 'Delete this session',
'Apagar todas as sessões salvas sozinhas': 'Delete all autosaved sessions',
'Apagar sessão salva': 'Delete saved session',
'Apagar': 'Delete',
'Apagar tudo': 'Delete all',
'Apagar todas as sessões salvas': 'Delete all saved sessions',
'Todas as sessões salvas sozinhas serão apagadas de vez. Sessões abertas agora são mantidas.': 'All autosaved sessions will be permanently deleted. Sessions currently open are kept.',
'Sessão apagada': 'Session deleted',
'Nada para apagar': 'Nothing to delete',
'Apagar sessões salvas sozinhas após': 'Delete autosaved sessions after',
'Nunca': 'Never',
'7 dias': '7 days',
'30 dias': '30 days',
'90 dias': '90 days',
'será apagada de vez. Os arquivos do disco não são afetados.': 'will be permanently deleted. Files on the disk are not affected.',
'Sessões apagadas: {n}': 'Sessions deleted: {n}',
}; };
+1 -1
View File
@@ -24,7 +24,7 @@
<button class="side-action" id="openImageBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.8" fill="none"/></svg>Abrir arquivo de imagem…</button> <button class="side-action" id="openImageBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.8" fill="none"/></svg>Abrir arquivo de imagem…</button>
<div class="side-title">Sessões</div> <div class="side-title">Sessões</div>
<ul id="sessionList" class="list"></ul> <ul id="sessionList" class="list"></ul>
<div class="side-title">Recentes <small style="font-weight:400;text-transform:none;letter-spacing:0">(salvas sozinhas)</small></div> <div class="side-title">Recentes <small style="font-weight:400;text-transform:none;letter-spacing:0">(salvas sozinhas)</small> <button class="icon-btn small" id="clearRecentBtn" title="Apagar todas as sessões salvas sozinhas" aria-label="Apagar todas as sessões salvas sozinhas"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M5 7h14M10 7V5h4v2m-6 0l1 12h6l1-12" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
<ul id="recentList" class="list"></ul> <ul id="recentList" 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>
+16
View File
@@ -64,7 +64,19 @@ pub fn carve_range(
let scan_len = got.min(CHUNK).min((end - pos) as usize); let scan_len = got.min(CHUNK).min((end - pos) as usize);
ev.chunk(pos, &buf[..scan_len]); ev.chunk(pos, &buf[..scan_len]);
let mut i = 0usize; let mut i = 0usize;
let mut since_check = 0u32;
while i < scan_len { while i < scan_len {
// Pause/stop must take effect within a fraction of a second even on slow media,
// not only at 8 MiB chunk boundaries. The chunk is reprocessed on resume (hits at
// offsets already found are skipped by the caller).
since_check += 1;
if since_check >= 2048 {
since_check = 0;
if cancelled() {
*next = pos;
return CarveEnd::Cancelled;
}
}
let abs = pos + i as u64; let abs = pos + i as u64;
if abs < skip_until { if abs < skip_until {
i += SECTOR as usize; i += SECTOR as usize;
@@ -78,6 +90,10 @@ pub fn carve_range(
let allowed = cfg.categories.is_empty() let allowed = cfg.categories.is_empty()
|| cfg.categories.contains(&category_for_ext(hit.ext)); || cfg.categories.contains(&category_for_ext(hit.ext));
if hit.len >= MIN_LEN && allowed { if hit.len >= MIN_LEN && allowed {
if cancelled() {
*next = pos;
return CarveEnd::Cancelled;
}
let action = ev.hit(abs, &hit); let action = ev.hit(abs, &hit);
match action { match action {
HitAction::Skip if hit.confidence != crate::types::Confidence::Low => { HitAction::Skip if hit.confidence != crate::types::Confidence::Low => {
+12 -2
View File
@@ -235,7 +235,12 @@ pub fn run(
return Outcome::Interrupted(cp); return Outcome::Interrupted(cp);
} }
} }
handler.rebuild_pending(); if !handler.rebuild_pending(cancelled) {
// Paused/stopped during reassembly: the truncated candidates were already reported,
// and on resume (all ranges done) the scan finishes without redoing them.
cp.next_id = next_id.load(std::sync::atomic::Ordering::Relaxed);
return Outcome::Interrupted(cp);
}
Outcome::Completed Outcome::Completed
} }
@@ -306,9 +311,13 @@ impl Handler<'_> {
self.sink.replace(f); self.sink.replace(f);
} }
fn rebuild_pending(&mut self) { /// Reassembles queued fragmented files. Returns `false` if cancelled part-way.
fn rebuild_pending(&mut self, cancelled: &(dyn Fn() -> bool + Sync)) -> bool {
let pend = std::mem::take(&mut self.pending); let pend = std::mem::take(&mut self.pending);
for p in pend { for p in pend {
if cancelled() {
return false;
}
let off = p.found.first_offset().unwrap_or(0); let off = p.found.first_offset().unwrap_or(0);
if p.mp4 { if p.mp4 {
self.rebuild_mp4(p, off); self.rebuild_mp4(p, off);
@@ -339,6 +348,7 @@ impl Handler<'_> {
self.sink.replace(f); self.sink.replace(f);
} }
} }
true
} }
} }
+72
View File
@@ -336,6 +336,7 @@ impl Engine {
"error": s.error, "error": s.error,
"report": s.report, "report": s.report,
"elapsed": elapsed, "elapsed": elapsed,
"stopping": s.status == Status::Running && j.cancelled(),
"source": j.source().is_some(), "source": j.source().is_some(),
"source_path": j.src_path.lock().unwrap().clone(), "source_path": j.src_path.lock().unwrap().clone(),
"session_file": j.session_file.lock().unwrap().as_ref().map(|p| p.to_string_lossy().to_string()), "session_file": j.session_file.lock().unwrap().as_ref().map(|p| p.to_string_lossy().to_string()),
@@ -1098,11 +1099,78 @@ impl Engine {
"só sessões automáticas podem ser apagadas por aqui", "só sessões automáticas podem ser apagadas por aqui",
)); ));
} }
let running = self.jobs.lock().unwrap().values().any(|j| {
j.session_file.lock().unwrap().as_deref() == Some(p)
&& j.st.lock().unwrap().status == Status::Running
});
if running {
return Err(Error::msg(
"esta varredura ainda está em andamento; pause ou pare antes de apagar a sessão",
));
}
let _ = std::fs::remove_file(p.with_extension("meta.json")); let _ = std::fs::remove_file(p.with_extension("meta.json"));
std::fs::remove_file(p)?; std::fs::remove_file(p)?;
Ok(()) Ok(())
} }
/// `true` if an open job (scanning, paused, or loaded) still autosaves to this file.
fn session_in_use(&self, p: &Path) -> bool {
self.jobs
.lock()
.unwrap()
.values()
.any(|j| j.session_file.lock().unwrap().as_deref() == Some(p))
}
/// Deletes autosaved sessions older than `days` (`None` = the configured retention;
/// `Some(0)` = all of them). Sessions held by open jobs are always kept.
/// Returns how many were removed.
pub fn sessions_prune(&self, days: Option<u32>) -> usize {
let days = match days {
Some(d) => d,
None => match crate::settings::load().session_retention_days {
0 => return 0, // retention disabled: keep everything
d => d,
},
};
let dir = sessions_dir();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let cutoff = now - days as i64 * 86_400;
let mut removed = 0;
let Ok(rd) = std::fs::read_dir(&dir) else {
return 0;
};
for e in rd.flatten() {
let path = e.path();
let name = e.file_name().to_string_lossy().to_string();
if !name.ends_with(".json") || name.ends_with(".meta.json") {
continue;
}
let saved_at = std::fs::read(path.with_extension("meta.json"))
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v["saved_at"].as_i64())
.or_else(|| {
e.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
})
.unwrap_or(now);
if saved_at <= cutoff && !self.session_in_use(&path) {
let _ = std::fs::remove_file(path.with_extension("meta.json"));
if std::fs::remove_file(&path).is_ok() {
removed += 1;
}
}
}
removed
}
// ---------------------------------------------------------------- RPC // ---------------------------------------------------------------- RPC
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> { pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
@@ -1342,6 +1410,10 @@ impl Engine {
self.session_delete(&s("path")?)?; self.session_delete(&s("path")?)?;
json!({}) json!({})
} }
"sessions_prune" => {
let all = p.get("all").and_then(|v| v.as_bool()) == Some(true);
json!({ "removed": self.sessions_prune(if all { Some(0) } else { None }) })
}
"session_load" => json!({ "job": self.load_session(&s("path")?)? }), "session_load" => json!({ "job": self.load_session(&s("path")?)? }),
"defaults" => { "defaults" => {
let home = crate::owner::user_home().to_string_lossy().to_string(); let home = crate::owner::user_home().to_string_lossy().to_string();
+4
View File
@@ -27,6 +27,9 @@ pub struct Settings {
/// without sending any telemetry). /// without sending any telemetry).
#[serde(default = "yes")] #[serde(default = "yes")]
pub updates: bool, pub updates: bool,
/// Autosaved sessions older than this many days are deleted on startup (`0` = keep forever).
#[serde(default)]
pub session_retention_days: u32,
} }
fn auto() -> String { fn auto() -> String {
@@ -46,6 +49,7 @@ impl Default for Settings {
notify: true, notify: true,
maps: false, maps: false,
updates: true, updates: true,
session_retention_days: 0,
} }
} }
} }
+24 -5
View File
@@ -29,6 +29,8 @@ struct Release {
struct Asset { struct Asset {
name: String, name: String,
browser_download_url: String, browser_download_url: String,
#[serde(default)]
size: u64,
} }
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -52,13 +54,25 @@ fn curl_text(url: &str) -> Result<String> {
Ok(String::from_utf8_lossy(&out.stdout).into_owned()) Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} }
fn curl_download(url: &str, dest: &Path) -> Result<()> { /// Downloads `url` to `dest`, polling the file size to report `percent` (0-100) through
let status = Command::new("curl") /// `on_pct` while curl runs (no stderr parsing; `total` comes from the release asset size).
fn curl_download(url: &str, dest: &Path, total: u64, on_pct: &dyn Fn(u8)) -> Result<()> {
let mut child = Command::new("curl")
.args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"]) .args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"])
.arg(dest) .arg(dest)
.arg(url) .arg(url)
.status() .spawn()
.map_err(|e| Error::msg(format!("curl {url}: {e}")))?; .map_err(|e| Error::msg(format!("curl {url}: {e}")))?;
let status = loop {
if let Some(st) = child.try_wait().map_err(|e| Error::msg(e.to_string()))? {
break st;
}
let got = fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
if let Some(pct) = got.saturating_mul(100).checked_div(total) {
on_pct(pct.min(100) as u8);
}
std::thread::sleep(std::time::Duration::from_millis(150));
};
if !status.success() { if !status.success() {
return Err(Error::msg(format!("curl {url}: download falhou"))); return Err(Error::msg(format!("curl {url}: download falhou")));
} }
@@ -145,7 +159,7 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
let tmp_dir = std::env::temp_dir().join("guara-update"); let tmp_dir = std::env::temp_dir().join("guara-update");
fs::create_dir_all(&tmp_dir)?; fs::create_dir_all(&tmp_dir)?;
let archive_path = tmp_dir.join(&asset.name); let archive_path = tmp_dir.join(&asset.name);
curl_download(&asset.browser_download_url, &archive_path)?; curl_download(&asset.browser_download_url, &archive_path, 0, &|_| {})?;
if let Some(checksum_asset) = checksum_asset { if let Some(checksum_asset) = checksum_asset {
let expected = curl_text(&checksum_asset.browser_download_url)?; let expected = curl_text(&checksum_asset.browser_download_url)?;
@@ -350,7 +364,12 @@ where
let download_path = tmp_dir.join(&asset.name); let download_path = tmp_dir.join(&asset.name);
on_progress("downloading"); on_progress("downloading");
curl_download(&asset.browser_download_url, &download_path)?; curl_download(
&asset.browser_download_url,
&download_path,
asset.size,
&|pct| on_progress(&format!("downloading:{pct}")),
)?;
if let Some(checksum_asset) = checksum_asset { if let Some(checksum_asset) = checksum_asset {
on_progress("verifying"); on_progress("verifying");
+1
View File
@@ -143,6 +143,7 @@ fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
notify: false, notify: false,
maps: true, maps: true,
updates: false, updates: false,
..Default::default()
}) })
.unwrap(); .unwrap();
let s = settings::load(); let s = settings::load();
+5 -1
View File
@@ -37,9 +37,13 @@ The switch at the bottom of the sidebar offers **Auto** (follows the system), **
**Settings** (the gear at the bottom of the sidebar) has a **Language** selector: **Auto** follows the system language, or you can pick **Português** or **English**. The change applies right away. The same dialog controls automatic updates (below), notifications for long tasks and online maps for photos with GPS (off by default, because it downloads OpenStreetMap tiles). **Settings** (the gear at the bottom of the sidebar) has a **Language** selector: **Auto** follows the system language, or you can pick **Português** or **English**. The change applies right away. The same dialog controls automatic updates (below), notifications for long tasks and online maps for photos with GPS (off by default, because it downloads OpenStreetMap tiles).
## Saved sessions
Sessions are autosaved and listed under **Recent** in the sidebar. Hover one and click the ✕ to delete it, or use the trash button in the section title to delete them all; sessions open in the app are kept. **Settings > Delete autosaved sessions after** removes old ones on startup (never by default; 7, 30 or 90 days).
## Updates ## Updates
When the desktop app opens it asks Gitea for the latest release. If a newer version exists, a card appears at the bottom right with the versions and the release address. For the CLI, `guara update` checks for and installs updates with SHA-256 verification. **Settings > Automatic updates** turns the startup check off and has a **Check now** button. The check sends nothing besides what any browser sends to Gitea. `guara ui` in the browser does not update itself. When the desktop app opens it asks Gitea for the latest release. If a newer version exists, a card appears at the bottom right with the versions and the release address. For the CLI, `guara update` checks for and installs updates with SHA-256 verification. **Settings > Automatic updates** turns the startup check off and has a **Check now** button. Clicking **Update now** shows a progress bar with the download percentage, then the verify, install and restart steps; the app stays responsive meanwhile. The check sends nothing besides what any browser sends to Gitea. `guara ui` in the browser does not update itself.
<p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p> <p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p>
+5 -1
View File
@@ -37,9 +37,13 @@ O seletor no fim da barra lateral oferece **Auto** (acompanha o sistema), **Clar
Em **Configurações** (a engrenagem no fim da barra lateral) há o seletor **Idioma**: **Auto** acompanha o idioma do sistema, ou você escolhe **Português** ou **English**. A troca vale na hora. A mesma janela controla as atualizações automáticas (abaixo), as notificações de tarefas longas e os mapas online das fotos com GPS (desligados por padrão, porque baixam blocos do OpenStreetMap). Em **Configurações** (a engrenagem no fim da barra lateral) há o seletor **Idioma**: **Auto** acompanha o idioma do sistema, ou você escolhe **Português** ou **English**. A troca vale na hora. A mesma janela controla as atualizações automáticas (abaixo), as notificações de tarefas longas e os mapas online das fotos com GPS (desligados por padrão, porque baixam blocos do OpenStreetMap).
## Sessões salvas
As sessões são salvas sozinhas e listadas em **Recentes** na barra lateral. Passe o mouse sobre uma e clique no ✕ para apagá-la, ou use o botão de lixeira no título da seção para apagar todas; sessões abertas no app são mantidas. **Configurações > Apagar sessões salvas sozinhas após** remove as antigas ao abrir (nunca, por padrão; 7, 30 ou 90 dias).
## Atualizações ## Atualizações
Ao abrir, o app desktop consulta o Gitea para verificar se há uma versão mais nova. Havendo versão nova, aparece um cartão no canto inferior direito com as versões e o endereço da release. Para o CLI, `guara update` verifica e instala atualizações com conferência de SHA-256. **Configurações > Atualizações automáticas** desliga a verificação ao abrir e tem o botão **Verificar agora**. A consulta não envia nada além do que qualquer navegador envia ao Gitea. O `guara ui` no navegador não se atualiza sozinho. Ao abrir, o app desktop consulta o Gitea para verificar se há uma versão mais nova. Havendo versão nova, aparece um cartão no canto inferior direito com as versões e o endereço da release. Para o CLI, `guara update` verifica e instala atualizações com conferência de SHA-256. **Configurações > Atualizações automáticas** desliga a verificação ao abrir e tem o botão **Verificar agora**. Ao clicar em **Atualizar agora**, aparece uma barra de progresso com a porcentagem do download e depois as etapas de verificação, instalação e reinício; o app continua responsivo. A consulta não envia nada além do que qualquer navegador envia ao Gitea. O `guara ui` no navegador não se atualiza sozinho.
<p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma"></p> <p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma"></p>