feat(app): add egui context for background thread repaint requests
This commit is contained in:
parent
4de452c503
commit
f3e6d3b5b7
4 changed files with 79 additions and 38 deletions
82
src/app.rs
82
src/app.rs
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
20
src/web.rs
20
src/web.rs
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue