From 4de452c5035d6ae2741ab9bf4da77edd9f934bab Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 3 Jul 2026 07:03:27 -0700 Subject: [PATCH] feat(tray): integrate egui context for system tray events to ensure immediate UI updates --- src/app.rs | 80 ++++++++++++++++++++++++++++++++++++++--------- src/downloader.rs | 24 ++++++++++---- src/tray.rs | 37 +++++++++++++++++++--- src/ytdlp_bin.rs | 6 ++++ 4 files changed, 122 insertions(+), 25 deletions(-) diff --git a/src/app.rs b/src/app.rs index b2d6881..5af14ac 100644 --- a/src/app.rs +++ b/src/app.rs @@ -429,6 +429,13 @@ impl App { }; theme::apply(&cc.egui_ctx, &config.ui.theme); + // Hand the tray thread a clone of the egui context so its menu + // activations (Show/Hide/Quit) can wake the event loop; otherwise + // those events sit unread in the channel while the app is idle or + // hidden to tray and appear to do nothing. + if let Some(handle) = &tray { + let _ = handle.ctx.set(cc.egui_ctx.clone()); + } let theme_accents = theme::accents_for(&config.ui.theme); let default_view_mode = ViewMode::from_config(&config.ui.default_view_mode); // Typography & spacing baseline: slightly larger base text, more @@ -2207,19 +2214,6 @@ impl App { fn maintenance_screen(&mut self, ctx: &egui::Context) { let report = self.health_report.clone().unwrap_or_default(); - // Swap in the library once the deferred startup scan finishes. - let loaded_library = self.library_load_rx.as_ref().and_then(|rx| rx.try_recv().ok()); - if let Some(lib) = loaded_library { - self.library = lib; - self.status = format!( - "{} channels, {} videos", - self.library.len(), - self.library.iter().map(|c| c.total_videos()).sum::() - ); - self.library_load_rx = None; - ctx.request_repaint(); - } - // Receive a background remote-library fetch (federation). let remote_result = self.remote_rx.as_ref().and_then(|rx| rx.try_recv().ok()); if let Some(res) = remote_result { @@ -3248,6 +3242,13 @@ impl App { self.config.ui.theme = id.to_string(); theme::apply(ctx, id); self.theme_accents = theme::accents_for(id); + // Persist immediately: the theme + // applies live, so users expect it to + // stick without also hitting the + // separate "Apply & Save" button. + if let Err(e) = self.config.save(&self.config_path) { + self.status = format!("Error saving config: {e}"); + } } } }); @@ -3469,7 +3470,22 @@ impl App { ui.label("Download password:"); ui.vertical(|ui| { - ui.checkbox(&mut self.settings_password_enabled, "require for web downloads"); + // Persist the enable/disable state immediately so + // "whether password is enabled" survives a restart + // even if the user never clicks "Apply & Save". + // "Enabled" isn't stored as a flag — it's derived + // from whether a password_hash row exists — so + // turning it off clears the hash right away. + let toggled = ui + .checkbox(&mut self.settings_password_enabled, "require for web downloads") + .changed(); + if toggled && !self.settings_password_enabled { + match self.db.set_setting("password_hash", None) { + Ok(_) => self.status = "Download password disabled.".to_string(), + Err(e) => self.status = format!("DB error: {e}"), + } + self.settings_password_input.clear(); + } if self.settings_password_enabled { let hint = if self.settings_password_input.is_empty() { "leave blank to keep current" @@ -3482,6 +3498,24 @@ impl App { .desired_width(220.0) .hint_text(hint), ); + // A password can only be stored once it's been + // typed and hashed, so offer an explicit commit + // that persists it right away (independent of + // "Apply & Save"). + if !self.settings_password_input.is_empty() + && ui.button("💾 Set password").clicked() + { + match crate::web::hash_password(&self.settings_password_input) { + Some(hash) => match self.db.set_setting("password_hash", Some(&hash)) { + Ok(_) => { + self.settings_password_input.clear(); + self.status = "Download password set.".to_string(); + } + Err(e) => self.status = format!("DB error: {e}"), + }, + None => self.status = "Error hashing password".to_string(), + } + } } }); ui.end_row(); @@ -4661,6 +4695,24 @@ impl eframe::App for App { } } + // ── Deferred library-scan hand-off ────────────────────────────── + // The startup library scan runs on a background thread and delivers + // its result over `library_load_rx`. Drain it here in the main loop + // so the library populates on whatever screen is showing — the + // default is Library, so draining only inside maintenance_screen() + // left the library empty until the user pressed Rescan. + if let Some(lib) = self.library_load_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { + self.library = lib; + self.status = format!( + "{} channels, {} videos", + self.library.len(), + self.library.iter().map(|c| c.total_videos()).sum::() + ); + self.library_load_rx = None; + self.library_generation += 1; + ctx.request_repaint(); + } + // ── System-tray event drain ───────────────────────────────────── // Tray menu activations arrive on a background-thread channel. // Drain them all each frame and translate into viewport commands. diff --git a/src/downloader.rs b/src/downloader.rs index 66c7481..0074c0c 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -982,7 +982,7 @@ impl Downloader { let child_pid = Some(child.id()); thread::spawn(move || { - if let Some(stderr) = child.stderr.take() { + let stderr_handle = child.stderr.take().map(|stderr| { let tx = tx.clone(); let cookies_abs = cookies_abs.clone(); thread::spawn(move || { @@ -990,8 +990,8 @@ impl Downloader { let line = redact_sensitive(&line, &cookies_abs); let _ = tx.send(Msg::Line(format!("[stderr] {line}"))); } - }); - } + }) + }); if let Some(stdout) = child.stdout.take() { for line in BufReader::new(stdout).lines().map_while(Result::ok) { @@ -1026,6 +1026,13 @@ impl Downloader { } Err(_) => false, }; + // Wait for the stderr reader to flush all buffered lines before + // announcing completion — otherwise Finished can race ahead of + // trailing stderr output still in flight, and failure_class gets + // computed from an incomplete log (see Job::drain). + if let Some(h) = stderr_handle { + let _ = h.join(); + } let _ = tx.send(Msg::Finished(ok)); }); @@ -1337,15 +1344,20 @@ impl Downloader { }; let child_pid = Some(child.id()); thread::spawn(move || { - if let Some(stderr) = child.stderr.take() { + let stderr_handle = child.stderr.take().map(|stderr| { let tx = tx.clone(); thread::spawn(move || { for line in BufReader::new(stderr).lines().map_while(Result::ok) { let _ = tx.send(Msg::Line(format!("[ffmpeg] {line}"))); } - }); - } + }) + }); let ok = matches!(child.wait(), Ok(s) if s.success()); + // See the analogous join in spawn_job: wait for stderr to finish + // flushing before any Finished-triggered classification runs. + if let Some(h) = stderr_handle { + let _ = h.join(); + } if ok { // File bookkeeping on success. if same_ext { diff --git a/src/tray.rs b/src/tray.rs index d4e9bf5..f88f437 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -43,6 +43,15 @@ pub enum TrayEvent { /// We just hold the receiver end of the menu-event channel. pub struct TrayHandle { pub events: Receiver, + /// Shared slot for the egui context, filled in by [`App`](crate::app::App) + /// right after construction. The tray runs on a background thread and + /// only pushes events onto `events`; without a way to wake the egui + /// event loop, those events sit unread until the next *natural* repaint — + /// which never happens while the app is idle or hidden-to-tray, so + /// "Show"/"Hide" appear to do nothing. Storing the context here lets the + /// tray thread call `request_repaint()` after every send so the main loop + /// drains the event on the very next frame. + pub ctx: std::sync::Arc>, } /// Internal tray model implementing `ksni::Tray`. Each method is called @@ -52,6 +61,9 @@ pub struct TrayHandle { #[cfg(target_os = "linux")] struct TrayModel { tx: std::sync::mpsc::Sender, + /// Shared egui context (see [`TrayHandle::ctx`]). Used to wake the main + /// loop after emitting an event so it's drained immediately. + ctx: std::sync::Arc>, /// PNG bytes for the icon, decoded once at construction. ksni asks /// for raw ARGB so we keep both forms. icon_argb: Vec, @@ -59,6 +71,18 @@ struct TrayModel { icon_h: i32, } +#[cfg(target_os = "linux")] +impl TrayModel { + /// Emit a tray event and wake the egui loop so it's handled on the next + /// frame even when the app is idle or hidden to the tray. + fn emit(&self, evt: TrayEvent) { + let _ = self.tx.send(evt); + if let Some(ctx) = self.ctx.get() { + ctx.request_repaint(); + } + } +} + #[cfg(target_os = "linux")] impl ksni::Tray for TrayModel { fn id(&self) -> String { @@ -86,7 +110,7 @@ impl ksni::Tray for TrayModel { /// Activate fires on left-click of the tray icon. Surfacing the main /// window is the most common "what did the user mean" interpretation. fn activate(&mut self, _x: i32, _y: i32) { - let _ = self.tx.send(TrayEvent::Show); + self.emit(TrayEvent::Show); } fn menu(&self) -> Vec> { use ksni::menu::StandardItem; @@ -94,7 +118,7 @@ impl ksni::Tray for TrayModel { StandardItem { label: "Show Catacomb".into(), activate: Box::new(|m: &mut Self| { - let _ = m.tx.send(TrayEvent::Show); + m.emit(TrayEvent::Show); }), ..Default::default() } @@ -102,7 +126,7 @@ impl ksni::Tray for TrayModel { StandardItem { label: "Hide window".into(), activate: Box::new(|m: &mut Self| { - let _ = m.tx.send(TrayEvent::Hide); + m.emit(TrayEvent::Hide); }), ..Default::default() } @@ -112,7 +136,7 @@ impl ksni::Tray for TrayModel { label: "Quit".into(), icon_name: "application-exit".into(), activate: Box::new(|m: &mut Self| { - let _ = m.tx.send(TrayEvent::Quit); + m.emit(TrayEvent::Quit); }), ..Default::default() } @@ -145,8 +169,11 @@ pub fn start(icon_png_bytes: &[u8]) -> Option { } let (tx, rx) = std::sync::mpsc::channel(); + let ctx: std::sync::Arc> = + std::sync::Arc::new(std::sync::OnceLock::new()); let model = TrayModel { tx, + ctx: ctx.clone(), icon_argb: argb, icon_w: w as i32, icon_h: h as i32, @@ -176,7 +203,7 @@ pub fn start(icon_png_bytes: &[u8]) -> Option { }) .ok()?; - Some(TrayHandle { events: rx }) + Some(TrayHandle { events: rx, ctx }) } /// Non-Linux stub: there's no StatusNotifierItem tray backend on Windows or diff --git a/src/ytdlp_bin.rs b/src/ytdlp_bin.rs index 379d0e4..833bd08 100644 --- a/src/ytdlp_bin.rs +++ b/src/ytdlp_bin.rs @@ -27,7 +27,13 @@ use std::process::Command; /// Root directory holding everything bundled — venv + bin. pub fn bundled_root() -> PathBuf { + // Native Windows shells generally don't set `HOME`; fall back to + // `USERPROFILE` there so the bundled install has a stable location + // instead of silently relocating to the current working directory. let home = std::env::var_os("HOME") + .or_else(|| { + if cfg!(windows) { std::env::var_os("USERPROFILE") } else { None } + }) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")); home.join(".local").join("share").join("catacomb")