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
+262 -35

No files matched your search

+16
View File
@@ -64,7 +64,19 @@ pub fn carve_range(
let scan_len = got.min(CHUNK).min((end - pos) as usize);
ev.chunk(pos, &buf[..scan_len]);
let mut i = 0usize;
let mut since_check = 0u32;
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;
if abs < skip_until {
i += SECTOR as usize;
@@ -78,6 +90,10 @@ pub fn carve_range(
let allowed = cfg.categories.is_empty()
|| cfg.categories.contains(&category_for_ext(hit.ext));
if hit.len >= MIN_LEN && allowed {
if cancelled() {
*next = pos;
return CarveEnd::Cancelled;
}
let action = ev.hit(abs, &hit);
match action {
HitAction::Skip if hit.confidence != crate::types::Confidence::Low => {
+12 -2
View File
@@ -235,7 +235,12 @@ pub fn run(
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
}
@@ -306,9 +311,13 @@ impl Handler<'_> {
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);
for p in pend {
if cancelled() {
return false;
}
let off = p.found.first_offset().unwrap_or(0);
if p.mp4 {
self.rebuild_mp4(p, off);
@@ -339,6 +348,7 @@ impl Handler<'_> {
self.sink.replace(f);
}
}
true
}
}
+72
View File
@@ -336,6 +336,7 @@ impl Engine {
"error": s.error,
"report": s.report,
"elapsed": elapsed,
"stopping": s.status == Status::Running && j.cancelled(),
"source": j.source().is_some(),
"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()),
@@ -1098,11 +1099,78 @@ impl Engine {
"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"));
std::fs::remove_file(p)?;
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
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
@@ -1342,6 +1410,10 @@ impl Engine {
self.session_delete(&s("path")?)?;
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")?)? }),
"defaults" => {
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).
#[serde(default = "yes")]
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 {
@@ -46,6 +49,7 @@ impl Default for Settings {
notify: true,
maps: false,
updates: true,
session_retention_days: 0,
}
}
}
+24 -5
View File
@@ -29,6 +29,8 @@ struct Release {
struct Asset {
name: String,
browser_download_url: String,
#[serde(default)]
size: u64,
}
#[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())
}
fn curl_download(url: &str, dest: &Path) -> Result<()> {
let status = Command::new("curl")
/// Downloads `url` to `dest`, polling the file size to report `percent` (0-100) through
/// `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"])
.arg(dest)
.arg(url)
.status()
.spawn()
.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() {
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");
fs::create_dir_all(&tmp_dir)?;
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 {
let expected = curl_text(&checksum_asset.browser_download_url)?;
@@ -350,7 +364,12 @@ where
let download_path = tmp_dir.join(&asset.name);
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 {
on_progress("verifying");
+1
View File
@@ -143,6 +143,7 @@ fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
notify: false,
maps: true,
updates: false,
..Default::default()
})
.unwrap();
let s = settings::load();