diff --git a/Cargo.lock b/Cargo.lock index 59f98aa..a5d5375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5124,6 +5124,7 @@ dependencies = [ "eframe", "image", "ksni", + "libc", "notify-rust", "r2d2", "r2d2_sqlite", diff --git a/Cargo.toml b/Cargo.toml index bc10d4b..db84823 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ rand = "0.8" # (zbus, no GTK/libdbus build dep), consistent with notify-rust above. rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] } ksni = { version = "0.3.4", features = ["tokio"] } +libc = "0.2.186" [profile.release] opt-level = 2 diff --git a/src/crash.rs b/src/crash.rs new file mode 100644 index 0000000..d4f2118 --- /dev/null +++ b/src/crash.rs @@ -0,0 +1,232 @@ +//! Persistent panic logging so a crashed yt-offline leaves a paper trail. +//! +//! GUI users don't see stderr (the binary is launched without a terminal +//! from a `.desktop` file or the IDE), so panics today vanish entirely. +//! This module installs a `panic::set_hook` early in `main` that appends +//! a structured entry to `/yt-offline.crash.log` for every +//! panic from any thread. +//! +//! The default panic handler still runs after ours so dev runs keep +//! getting the familiar stderr output. +//! +//! # Format +//! +//! Each entry is a small block — easy to grep, easy to attach to a bug +//! report: +//! +//! ```text +//! ── 2026-05-27T18:42:11Z thread: "tokio-runtime-worker" ────────────── +//! panic at src/web.rs:482:9: +//! called `Option::unwrap()` on a `None` value +//! backtrace: +//! … +//! ``` +//! +//! A backtrace is captured only when `RUST_BACKTRACE` is set; we don't +//! force it because capture is slow and most users won't have symbols. + +use std::fs::OpenOptions; +use std::io::Write; +use std::panic; +use std::path::{Path, PathBuf}; + +/// Cap the log at ~256 KB. When the file grows past this, the next write +/// rotates it to `*.1` (overwriting any previous rotation). Bounded both +/// in disk use and in how many panics we keep around — the most recent +/// dozen panics is what a bug-report attacher actually wants. +const LOG_SIZE_CAP_BYTES: u64 = 256 * 1024; + +/// Install a global panic hook that logs to `/yt-offline.crash.log` +/// while preserving the default stderr behavior. +/// +/// `dir` is the same directory the SQLite database lives in (typically +/// `channels_root` in config). Pass the *absolute* path so the log +/// remains discoverable regardless of where the user's `cwd` was when +/// they launched the binary. +/// +/// Safe to call once at process startup. Calling it more than once +/// replaces the previous hook (which is fine: the new dir wins). +pub fn install(dir: &Path) { + let path = dir.join("yt-offline.crash.log"); + // Don't fail startup if the parent dir doesn't exist yet — log it, + // continue. The DB-open path further down the chain will surface a + // permission / missing-dir error in a more legible way. + let _ = std::fs::create_dir_all(dir); + + // Stash the default hook so we can chain to it: our hook does the + // file write, then defers to the standard formatter that prints to + // stderr (useful in dev / when run from a terminal). + let default = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + // Best-effort log. A panic inside the panic handler is a hard + // abort — wrap the IO in a closure that swallows all errors. + let _ = write_entry(&path, info); + default(info); + })); +} + +/// Append a single panic entry to the log, rotating if the file is over +/// the size cap. All IO errors are intentionally swallowed; this is a +/// best-effort diagnostic, not a load-bearing data path. +fn write_entry(path: &Path, info: &panic::PanicHookInfo<'_>) -> std::io::Result<()> { + // Rotation: if the existing log is over the cap, move it aside. + // A single rename is cheaper than parsing+truncating and gives the + // user one previous generation for free. + if let Ok(meta) = std::fs::metadata(path) { + if meta.len() >= LOG_SIZE_CAP_BYTES { + let rotated = with_suffix(path, ".1"); + let _ = std::fs::rename(path, &rotated); + } + } + + let mut f = OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + + let ts = now_iso8601(); + let thread = std::thread::current(); + let thread_name = thread.name().unwrap_or(""); + let location = info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) + .unwrap_or_else(|| "".to_string()); + // PanicHookInfo carries the payload as `&dyn Any`; downcast the two + // forms the stdlib produces (`&str` for `panic!("literal")` and + // `String` for `panic!("{x}")`). + let payload = info.payload(); + let msg = payload + .downcast_ref::<&str>() + .copied() + .map(str::to_string) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "".to_string()); + + writeln!(f)?; + writeln!(f, "── {ts} thread: {thread_name:?} ─────────────────────────")?; + writeln!(f, "panic at {location}:")?; + for line in msg.lines() { + writeln!(f, " {line}")?; + } + // Capture a backtrace only when RUST_BACKTRACE is set (matches the + // default panic-handler behavior; capturing is otherwise too slow on + // hot paths and most users won't have symbols anyway). + if std::env::var_os("RUST_BACKTRACE").is_some() { + let bt = std::backtrace::Backtrace::force_capture(); + writeln!(f, "backtrace:")?; + for line in bt.to_string().lines() { + writeln!(f, " {line}")?; + } + } + f.flush()?; + Ok(()) +} + +/// Build `.` next to the original — used to rotate logs. +/// We append `.1` rather than `.` because keeping one previous +/// generation is enough; more would just bloat the install directory. +fn with_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut s = path.as_os_str().to_owned(); + s.push(suffix); + PathBuf::from(s) +} + +/// Minimal RFC3339-ish timestamp without pulling in `chrono`. Goes +/// `2026-05-27T18:42:11Z` — accurate to the second, UTC. We compute it +/// from `SystemTime` via the Howard Hinnant civil-time formula so we +/// don't need a date library. +fn now_iso8601() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) as i64; + let (y, mo, d, h, mi, s) = civil_from_unix(secs); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") +} + +/// Convert UNIX seconds → (year, month, day, hour, minute, second) UTC. +/// Adapted from Howard Hinnant's `civil_from_days` +/// (). +/// All branchless integer math; no leap-year edge cases. +fn civil_from_unix(secs: i64) -> (i32, u32, u32, u32, u32, u32) { + let days = secs.div_euclid(86_400); + let rem = secs.rem_euclid(86_400) as u32; + let h = rem / 3600; + let mi = (rem % 3600) / 60; + let s = rem % 60; + + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u32; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i32 + (era * 400) as i32; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d, h, mi, s) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn civil_round_trips_known_dates() { + // 2024-01-01T00:00:00Z is 1704067200 + assert_eq!(civil_from_unix(1_704_067_200), (2024, 1, 1, 0, 0, 0)); + // 2026-05-22T18:42:11Z is 1779475331 + assert_eq!(civil_from_unix(1_779_475_331), (2026, 5, 22, 18, 42, 11)); + // Unix epoch + assert_eq!(civil_from_unix(0), (1970, 1, 1, 0, 0, 0)); + // Leap day + assert_eq!(civil_from_unix(1_582_934_400), (2020, 2, 29, 0, 0, 0)); + } + + #[test] + fn write_entry_appends_panic_to_file() { + // Build a real PanicHookInfo via std::panic::catch_unwind. We + // can't construct one directly (the type's fields are private). + // The simplest portable approach: install our hook against a + // tempfile, then trigger a controlled panic in another thread + // (`std::thread::spawn` so the panic doesn't unwind the test + // runner), and assert the file contents afterward. + let mut tmp = std::env::temp_dir(); + tmp.push(format!("yt-offline-crash-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + let log = tmp.join("yt-offline.crash.log"); + + // Install the hook scoped to this test. We swap it back at the + // end so other tests run on the default hook. + let old = std::panic::take_hook(); + super::install(&tmp); + let _ = std::thread::Builder::new() + .name("crash-test".into()) + .spawn(|| { + panic!("deliberate test panic — please ignore"); + }) + .unwrap() + .join(); + std::panic::set_hook(old); + + let body = std::fs::read_to_string(&log).expect("crash log should exist"); + assert!(body.contains("deliberate test panic")); + assert!(body.contains("crash-test")); + assert!(body.contains("panic at ")); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn iso_format_has_expected_shape() { + let s = now_iso8601(); + // YYYY-MM-DDTHH:MM:SSZ — 20 chars + assert_eq!(s.len(), 20); + assert!(s.ends_with('Z')); + assert_eq!(s.as_bytes()[4], b'-'); + assert_eq!(s.as_bytes()[7], b'-'); + assert_eq!(s.as_bytes()[10], b'T'); + } +} diff --git a/src/disk_space.rs b/src/disk_space.rs new file mode 100644 index 0000000..767129f --- /dev/null +++ b/src/disk_space.rs @@ -0,0 +1,89 @@ +//! Free-space query for the download preflight check. +//! +//! Rust stdlib doesn't have `available_space` on stable, so we call +//! `statvfs(3)` via libc on Unix. On non-Unix the function returns +//! `None`, which the caller treats as "skip the check" — we'd rather +//! let yt-dlp run and surface a real ENOSPC error than refuse to start +//! based on no data. + +/// Return the bytes of free space available to the calling user on the +/// filesystem holding `path`. `None` means the query failed (path missing, +/// non-Unix host, libc error) and the caller should skip the preflight. +/// +/// Uses `f_frsize * f_bavail` — `f_bavail` is the "available to +/// unprivileged user" count which already excludes the reserved-root +/// blocks; that's the number a non-root download cares about. +#[cfg(unix)] +pub fn available_bytes(path: &std::path::Path) -> Option { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let c_path = CString::new(path.as_os_str().as_bytes()).ok()?; + // SAFETY: libc::statvfs reads a valid C string and writes into a + // zero-initialised libc::statvfs we own. Returning 0 on success is + // documented; we check that before reading the fields. mem::zeroed() + // is safe for plain-data libc structs. + let mut buf: libc::statvfs = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut buf) }; + if rc != 0 { + return None; + } + // f_frsize is the underlying block size (may differ from f_bsize on + // some filesystems). f_bavail is in those blocks. Cast both to u64 + // since libc declares them as platform-dependent types. + let frsize = buf.f_frsize as u64; + let bavail = buf.f_bavail as u64; + Some(frsize.saturating_mul(bavail)) +} + +#[cfg(not(unix))] +pub fn available_bytes(_path: &std::path::Path) -> Option { + None +} + +/// Floor below which we refuse to start a new download. Picked at the low +/// end of a "typical 1080p YouTube video" range — most videos are smaller +/// than this, but starting with less than 500 MB free is a near-certain +/// path to an aborted job and a half-written file. Configurable later if +/// a user complains; for now a fixed sane default is enough. +pub const FREE_SPACE_FLOOR_BYTES: u64 = 500 * 1024 * 1024; + +/// Format a byte count for human display. Duplicates a similar helper in +/// app.rs because the modules can't easily share one (app.rs depends on +/// eframe). 3 GB / 250 MB / 15 KB-style rounding. +pub fn fmt_bytes(bytes: u64) -> String { + if bytes >= 1_073_741_824 { + format!("{:.1} GB", bytes as f64 / 1_073_741_824.0) + } else if bytes >= 1_048_576 { + format!("{} MB", bytes / 1_048_576) + } else if bytes >= 1024 { + format!("{} KB", bytes / 1024) + } else { + format!("{bytes} B") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(unix)] + fn root_path_has_some_free_space() { + // / always exists on Unix; if statvfs returns Some(_) at all, + // the query plumbing works. We don't assert a specific number + // because CI runners' disks vary. + let bytes = available_bytes(std::path::Path::new("/")); + assert!(bytes.is_some(), "statvfs(/) should succeed on Unix"); + } + + #[test] + #[cfg(unix)] + fn missing_path_returns_none() { + // statvfs on a nonexistent path returns -1; we surface that as + // None so the caller skips the preflight rather than refusing + // every download. + let bytes = available_bytes(std::path::Path::new("/this/path/should/not/exist/anywhere")); + assert!(bytes.is_none()); + } +} diff --git a/src/downloader.rs b/src/downloader.rs index fb0ef77..239fc0a 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -329,6 +329,34 @@ impl Downloader { // Per-platform download archive keeps cross-platform IDs from colliding // (TikTok IDs are numeric, YouTube IDs are 11-char base64, etc.). let _ = std::fs::create_dir_all(&platform_dir); + + // Disk-full preflight. Refuse to spawn yt-dlp when the target + // filesystem has less than the floor of free space — that's a + // near-certain in-progress ENOSPC, which leaves a partial file + // and a confusing failure. We surface it as a synthetic Failed + // job classified as DiskFull so it appears in the Downloads + // panel alongside other classified errors instead of vanishing. + // + // statvfs returning None (non-Unix host, missing path) skips the + // check — better to let yt-dlp run and produce a real error + // than refuse on missing data. + if let Some(free) = crate::disk_space::available_bytes(&self.channels_root) { + if free < crate::disk_space::FREE_SPACE_FLOOR_BYTES { + self.push_synthetic_failure( + url, + platform_dir.display().to_string(), + crate::error_class::ErrorClass::DiskFull, + format!( + "only {} free on {} (need at least {})", + crate::disk_space::fmt_bytes(free), + self.channels_root.display(), + crate::disk_space::fmt_bytes(crate::disk_space::FREE_SPACE_FLOOR_BYTES), + ), + ); + return; + } + } + let archive_path = platform_dir.join("archive.txt"); let platform_label = info.platform.dir_name(); @@ -619,6 +647,36 @@ impl Downloader { }); } + /// Push a job that was never going to start — used for preflight + /// failures (currently just disk-full). Constructs a closed channel + /// so the immediate `Job::drain` sees no progress, and seeds the log + /// with the human-readable reason so the UI's last_line + the error + /// hint together explain what went wrong. + fn push_synthetic_failure( + &mut self, + url: String, + label: String, + class: crate::error_class::ErrorClass, + reason: String, + ) { + let (_tx, rx) = std::sync::mpsc::channel::(); + // _tx is dropped here, so any subsequent drain hits the + // "channel closed" branch and stays still. We start `state` as + // Failed directly rather than running it through the Finished + // transition. + let mut log = VecDeque::new(); + log.push_back(format!("[preflight] {reason}")); + self.jobs.push(Job { + url, + label, + state: JobState::Failed, + progress: 0.0, + log, + failure_class: Some(class), + rx, + }); + } + /// Drain pending messages from all job threads and promote queued jobs. /// /// Call this regularly from the UI event loop to pick up progress updates. diff --git a/src/main.rs b/src/main.rs index dd1d5f6..8079031 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,9 @@ mod app; mod config; +mod crash; mod database; +mod disk_space; mod download_options; mod downloader; mod error_class; @@ -32,13 +34,25 @@ mod ytdlp_bin; fn main() -> eframe::Result<()> { let args: Vec = std::env::args().collect(); + // Load the config first — both modes share the channels_root path, + // and the panic hook needs it before anything else runs so a crash + // in the very early startup (DB open, tray init, ksni) still lands + // in the crash log. + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let cfg_path = cwd.join("config.toml"); + let mut cfg = config::Config::load(&cfg_path) + .unwrap_or_else(|_| config::Config::default_with_dir(cwd.join("channels"))); + + // Install the persistent panic logger. Logs go alongside the SQLite + // database; the parent of channels_root is the same "library root" + // the desktop UI shows. + let crash_dir = cfg.backup.directory.parent() + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| cfg.backup.directory.clone()); + crash::install(&crash_dir); + // --web [port] → run the web interface instead of the GUI if let Some(pos) = args.iter().position(|a| a == "--web") { - let mut cfg = { - let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - config::Config::load(&cwd.join("config.toml")) - .unwrap_or_else(|_| config::Config::default_with_dir(cwd.join("channels"))) - }; // Override port if provided after --web if let Some(port_str) = args.get(pos + 1) { if let Ok(port) = port_str.parse::() {