feat(app): add egui context for background thread repaint requests

This commit is contained in:
Luna 2026-07-03 08:00:44 -07:00
parent 4de452c503
commit f3e6d3b5b7
4 changed files with 79 additions and 38 deletions

View file

@ -294,6 +294,10 @@ pub struct App {
/// menu or another exit path). The viewport `CloseRequested` handler /// menu or another exit path). The viewport `CloseRequested` handler
/// honors this — without it, the close button minimizes to tray. /// honors this — without it, the close button minimizes to tray.
quitting: bool, 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 /// Scratch struct for the per-channel options dialog. Distinct from
@ -691,6 +695,7 @@ impl App {
move_to_folder_target: None, move_to_folder_target: None,
tray, tray,
quitting: false, quitting: false,
egui_ctx: cc.egui_ctx.clone(),
} }
} }
@ -1098,6 +1103,7 @@ impl App {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
self.dedup_rx = Some(rx); self.dedup_rx = Some(rx);
let workers = crate::fingerprint::default_workers(); let workers = crate::fingerprint::default_workers();
let repaint_ctx = self.egui_ctx.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let res = crate::fingerprint::rebuild_and_group( let res = crate::fingerprint::rebuild_and_group(
&db, inputs, &valid_paths, workers, &progress.0, &progress.1, &db, inputs, &valid_paths, workers, &progress.0, &progress.1,
@ -1115,6 +1121,9 @@ impl App {
groups groups
}); });
let _ = tx.send(res); 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) { fn maintenance_screen(&mut self, ctx: &egui::Context) {
let report = self.health_report.clone().unwrap_or_default(); 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 // Actions are collected during rendering and applied after the closure
// to avoid borrowing `self` while the report is borrowed immutably. // to avoid borrowing `self` while the report is borrowed immutably.
let mut to_remove: Vec<PathBuf> = Vec::new(); let mut to_remove: Vec<PathBuf> = Vec::new();
@ -2515,8 +2491,11 @@ impl App {
self.remote_status = format!("Connecting to {}", client.name); self.remote_status = format!("Connecting to {}", client.name);
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
self.remote_rx = Some(rx); self.remote_rx = Some(rx);
let repaint_ctx = self.egui_ctx.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let _ = tx.send(client.library()); 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(); 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 ───────────────────────────────────── // ── System-tray event drain ─────────────────────────────────────
// Tray menu activations arrive on a background-thread channel. // Tray menu activations arrive on a background-thread channel.
// Drain them all each frame and translate into viewport commands. // Drain them all each frame and translate into viewport commands.

View file

@ -1329,7 +1329,12 @@ impl Downloader {
keep_original: bool, keep_original: bool,
) { ) {
let (tx, rx) = channel(); 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 // Spawn before the thread so the watchdog gets a pid (ffmpeg can
// also hang on a bad input). On spawn failure, surface it. // also hang on a bad input). On spawn failure, surface it.
let mut child = match cmd.spawn() { let mut child = match cmd.spawn() {

View file

@ -378,7 +378,7 @@ struct SettingsPayload {
#[serde(skip_deserializing, default)] #[serde(skip_deserializing, default)]
available_binds: Option<Vec<BindOption>>, available_binds: Option<Vec<BindOption>>,
/// Selected bind mode (localhost, tailscale, lan, all). Clients can send this on POST to change. /// Selected bind mode (localhost, tailscale, lan, all). Clients can send this on POST to change.
#[serde(skip_deserializing, default)] #[serde(default)]
bind_mode: Option<String>, bind_mode: Option<String>,
/// Whether a password is required for downloads (sent by server only). /// Whether a password is required for downloads (sent by server only).
#[serde(skip_deserializing, default)] #[serde(skip_deserializing, default)]
@ -1218,14 +1218,28 @@ async fn auth_middleware(
} }
/// True if the query string carries `token=<expected>` (the feed capability /// True if the query string carries `token=<expected>` (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 { fn query_has_token(query: &str, expected: &str) -> bool {
if expected.is_empty() { return false; } if expected.is_empty() { return false; }
query.split('&').any(|kv| { 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 ────────────────────────────────────────────────────────────────── // ── Handlers ──────────────────────────────────────────────────────────────────
async fn get_index() -> impl IntoResponse { async fn get_index() -> impl IntoResponse {

View file

@ -224,7 +224,7 @@ fn settings_roundtrip_and_persist() {
"max_concurrent":5,"use_bundled_ytdlp":false,"use_pot_provider":false, "max_concurrent":5,"use_bundled_ytdlp":false,"use_pot_provider":false,
"subtitles_enabled":true,"subtitles_auto":false,"subtitles_embed":true, "subtitles_enabled":true,"subtitles_auto":false,"subtitles_embed":true,
"subtitle_langs":"en","subtitle_format":"srt","youtube_player_clients":"tv,mweb", "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_mode":"h264-mp4","convert_crf":28,"convert_preset":"fast",
"convert_audio_format":"","convert_keep_original":true "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, "youtube_player_clients"), Some("tv,mweb"));
assert_eq!(field(&body, "sponsorblock_mode"), Some("remove")); assert_eq!(field(&body, "sponsorblock_mode"), Some("remove"));
assert_eq!(field(&body, "subtitle_format"), Some("srt")); 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. // …and so does config.toml on disk.
let cfg = std::fs::read_to_string(s.dir.join("config.toml")).unwrap(); 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("mode = \"h264-mp4\""), "config persisted convert mode:\n{cfg}");
assert!(cfg.contains("youtube_player_clients = \"tv,mweb\""), "config persisted clients"); assert!(cfg.contains("youtube_player_clients = \"tv,mweb\""), "config persisted clients");
assert!(cfg.contains("sponsorblock_mode = \"remove\""), "config persisted sponsorblock"); 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] #[test]