From f3e6d3b5b7797981e990b55d9695a9ddd8824945 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 3 Jul 2026 08:00:44 -0700 Subject: [PATCH] feat(app): add egui context for background thread repaint requests --- src/app.rs | 82 ++++++++++++++++++++++++++++------------------- src/downloader.rs | 7 +++- src/web.rs | 20 ++++++++++-- tests/api.rs | 8 ++++- 4 files changed, 79 insertions(+), 38 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5af14ac..1be1e1f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -294,6 +294,10 @@ pub struct App { /// menu or another exit path). The viewport `CloseRequested` handler /// honors this — without it, the close button minimizes to tray. quitting: bool, + /// Clone of the egui context, kept so background worker threads (dedup, + /// remote fetch) can call `request_repaint()` on completion and wake the + /// event loop even when the app is idle or on a different screen. + egui_ctx: egui::Context, } /// Scratch struct for the per-channel options dialog. Distinct from @@ -691,6 +695,7 @@ impl App { move_to_folder_target: None, tray, quitting: false, + egui_ctx: cc.egui_ctx.clone(), } } @@ -1098,6 +1103,7 @@ impl App { let (tx, rx) = std::sync::mpsc::channel(); self.dedup_rx = Some(rx); let workers = crate::fingerprint::default_workers(); + let repaint_ctx = self.egui_ctx.clone(); std::thread::spawn(move || { let res = crate::fingerprint::rebuild_and_group( &db, inputs, &valid_paths, workers, &progress.0, &progress.1, @@ -1115,6 +1121,9 @@ impl App { groups }); let _ = tx.send(res); + // Wake the egui loop so the result is drained in update() even if + // the user navigated away from the Maintenance screen. + repaint_ctx.request_repaint(); }); } @@ -2214,39 +2223,6 @@ impl App { fn maintenance_screen(&mut self, ctx: &egui::Context) { let report = self.health_report.clone().unwrap_or_default(); - // 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 { - self.remote_rx = None; - match res { - Ok(lib) => { - let n: usize = lib.channels.iter().map(|c| c.videos.len()).sum(); - self.remote_status = - format!("{} channels · {} videos", lib.channels.len(), n); - self.remote_library = Some(lib); - } - Err(e) => { - self.remote_status = format!("Error: {e}"); - self.remote_library = None; - } - } - ctx.request_repaint(); - } - - // Drain the background dedup result if it's ready. - let mut dedup_done = None; - if let Some(rx) = &self.dedup_rx { - if let Ok(res) = rx.try_recv() { dedup_done = Some(res); } - } - if let Some(res) = dedup_done { - match res { - Ok(groups) => self.dedup_groups = groups, - Err(e) => self.dedup_error = Some(e), - } - self.dedup_running = false; - self.dedup_rx = None; - } - // Actions are collected during rendering and applied after the closure // to avoid borrowing `self` while the report is borrowed immutably. let mut to_remove: Vec = Vec::new(); @@ -2515,8 +2491,11 @@ impl App { self.remote_status = format!("Connecting to {}…", client.name); let (tx, rx) = std::sync::mpsc::channel(); self.remote_rx = Some(rx); + let repaint_ctx = self.egui_ctx.clone(); std::thread::spawn(move || { let _ = tx.send(client.library()); + // Wake the egui loop so update() drains the result promptly. + repaint_ctx.request_repaint(); }); } @@ -4713,6 +4692,43 @@ impl eframe::App for App { ctx.request_repaint(); } + // ── Background remote-library fetch hand-off ──────────────────── + // Federation peer fetches (start_remote_fetch) deliver over + // `remote_rx`. Drain in the main loop, not in a per-screen render: + // the result is displayed on the Remotes screen, so draining it only + // inside maintenance_screen() left the Remotes screen stuck on + // "Connecting…" and busy-looping repaints forever. + if let Some(res) = self.remote_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { + self.remote_rx = None; + match res { + Ok(lib) => { + let n: usize = lib.channels.iter().map(|c| c.videos.len()).sum(); + self.remote_status = + format!("{} channels · {} videos", lib.channels.len(), n); + self.remote_library = Some(lib); + } + Err(e) => { + self.remote_status = format!("Error: {e}"); + self.remote_library = None; + } + } + ctx.request_repaint(); + } + + // ── Background dedup result hand-off ──────────────────────────── + // Drain in the main loop so the scan completes (and the button + // re-enables) even if the user navigated away from Maintenance + // while the ffmpeg pass was running. + if let Some(res) = self.dedup_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { + match res { + Ok(groups) => self.dedup_groups = groups, + Err(e) => self.dedup_error = Some(e), + } + self.dedup_running = false; + self.dedup_rx = None; + 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 0074c0c..422c037 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -1329,7 +1329,12 @@ impl Downloader { keep_original: bool, ) { let (tx, rx) = channel(); - cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + // ffmpeg writes the encoded stream to `work_out` (a file) and its + // diagnostics to stderr (via -loglevel error -stats), so stdout is + // never used. Piping it without a reader would let a full 64KB pipe + // buffer deadlock ffmpeg against our child.wait() below, so discard + // it explicitly. + cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped()); // Spawn before the thread so the watchdog gets a pid (ffmpeg can // also hang on a bad input). On spawn failure, surface it. let mut child = match cmd.spawn() { diff --git a/src/web.rs b/src/web.rs index a3f4259..68ab3ed 100644 --- a/src/web.rs +++ b/src/web.rs @@ -378,7 +378,7 @@ struct SettingsPayload { #[serde(skip_deserializing, default)] available_binds: Option>, /// Selected bind mode (localhost, tailscale, lan, all). Clients can send this on POST to change. - #[serde(skip_deserializing, default)] + #[serde(default)] bind_mode: Option, /// Whether a password is required for downloads (sent by server only). #[serde(skip_deserializing, default)] @@ -1218,14 +1218,28 @@ async fn auth_middleware( } /// True if the query string carries `token=` (the feed capability -/// token). Empty `expected` never matches. +/// token). Empty `expected` never matches. The token is compared in constant +/// time so response latency doesn't leak how many leading bytes matched. fn query_has_token(query: &str, expected: &str) -> bool { if expected.is_empty() { return false; } query.split('&').any(|kv| { - kv.strip_prefix("token=").is_some_and(|v| v == expected) + kv.strip_prefix("token=").is_some_and(|v| ct_eq(v.as_bytes(), expected.as_bytes())) }) } +/// Constant-time byte-slice equality. Avoids the early-exit of `==` so a +/// network attacker can't binary-search a capability token by timing. +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + // ── Handlers ────────────────────────────────────────────────────────────────── async fn get_index() -> impl IntoResponse { diff --git a/tests/api.rs b/tests/api.rs index 64c8692..b257d34 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -224,7 +224,7 @@ fn settings_roundtrip_and_persist() { "max_concurrent":5,"use_bundled_ytdlp":false,"use_pot_provider":false, "subtitles_enabled":true,"subtitles_auto":false,"subtitles_embed":true, "subtitle_langs":"en","subtitle_format":"srt","youtube_player_clients":"tv,mweb", - "sponsorblock_mode":"remove", + "sponsorblock_mode":"remove","bind_mode":"all", "convert_mode":"h264-mp4","convert_crf":28,"convert_preset":"fast", "convert_audio_format":"","convert_keep_original":true }"#); @@ -237,12 +237,18 @@ fn settings_roundtrip_and_persist() { assert_eq!(field(&body, "youtube_player_clients"), Some("tv,mweb")); assert_eq!(field(&body, "sponsorblock_mode"), Some("remove")); assert_eq!(field(&body, "subtitle_format"), Some("srt")); + assert_eq!( + field(&body, "current_bind").map(|s| s.starts_with("0.0.0.0:")), + Some(true), + "bind_mode POST took effect (current_bind now 0.0.0.0): {body}" + ); // …and so does config.toml on disk. let cfg = std::fs::read_to_string(s.dir.join("config.toml")).unwrap(); assert!(cfg.contains("mode = \"h264-mp4\""), "config persisted convert mode:\n{cfg}"); assert!(cfg.contains("youtube_player_clients = \"tv,mweb\""), "config persisted clients"); assert!(cfg.contains("sponsorblock_mode = \"remove\""), "config persisted sponsorblock"); + assert!(cfg.contains("bind = \"0.0.0.0\""), "config persisted resolved bind address:\n{cfg}"); } #[test]