diff --git a/Cargo.lock b/Cargo.lock index 82e9605..947d7a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2575,6 +2575,7 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-opener", "url", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/mtgc-core/src/bin/ingest.rs b/crates/mtgc-core/src/bin/ingest.rs index 4994e04..40e2f4c 100644 --- a/crates/mtgc-core/src/bin/ingest.rs +++ b/crates/mtgc-core/src/bin/ingest.rs @@ -19,7 +19,13 @@ fn main() -> ExitCode { eprintln!(" [{}] {:.1}% {}", p.stage, p.overall_percent, p.message) }) .and_then(|(tmp, stats)| { - mtgc_core::ingest::install_reference_db(&tmp, &dest)?; + let installed = mtgc_core::ingest::install_reference_db(&tmp, &dest, |attempt, e| { + eprintln!(" install attempt {attempt} failed: {e}") + }); + if installed.is_err() { + let _ = std::fs::remove_file(&tmp); + } + installed?; Ok(stats) }); match built { diff --git a/crates/mtgc-core/src/ingest.rs b/crates/mtgc-core/src/ingest.rs index a8adf8e..5cda693 100644 --- a/crates/mtgc-core/src/ingest.rs +++ b/crates/mtgc-core/src/ingest.rs @@ -163,13 +163,19 @@ pub fn build_reference_db( const INSTALL_ATTEMPTS: u32 = 5; const INSTALL_RETRY_DELAY: Duration = Duration::from_millis(200); -/// Move a built reference db over `dest`, consuming `built` even on failure. +/// Move a built reference db over `dest`, telling `on_failure` about each +/// failed attempt. On failure `built` stays put, for the caller to inspect and +/// remove. /// /// Anything with `dest` open must let go first: Windows refuses to replace a /// file SQLite has open. A virus scanner can also hold a file this size for a /// moment after it's written, and giving up here would throw away a download /// of several minutes, hence the retries. -pub fn install_reference_db(built: &Path, dest: &Path) -> std::io::Result<()> { +pub fn install_reference_db( + built: &Path, + dest: &Path, + mut on_failure: impl FnMut(u32, &std::io::Error), +) -> std::io::Result<()> { let mut attempt = 1; loop { // Rename straight over `dest`: rename replaces the destination @@ -179,14 +185,14 @@ pub fn install_reference_db(built: &Path, dest: &Path) -> std::io::Result<()> { // with no card data at all.) match std::fs::rename(built, dest) { Ok(()) => return Ok(()), - Err(_) if attempt < INSTALL_ATTEMPTS => { + Err(e) => { + on_failure(attempt, &e); + if attempt == INSTALL_ATTEMPTS { + return Err(e); + } attempt += 1; std::thread::sleep(INSTALL_RETRY_DELAY); } - Err(e) => { - let _ = std::fs::remove_file(built); - return Err(e); - } } } } @@ -1517,14 +1523,14 @@ mod tests { std::fs::write(&dest, b"old").unwrap(); std::fs::write(&built, b"new").unwrap(); - install_reference_db(&built, &dest).unwrap(); + install_reference_db(&built, &dest, |_, e| panic!("{e}")).unwrap(); assert_eq!(std::fs::read(&dest).unwrap(), b"new"); assert!(!built.exists()); } #[test] - fn a_failed_install_cleans_up_and_keeps_what_was_there() { + fn a_failed_install_reports_every_attempt_and_touches_neither_file() { let dir = tempfile::tempdir().unwrap(); // A folder can't be renamed over, on any platform. let dest = dir.path().join("reference.db"); @@ -1532,9 +1538,11 @@ mod tests { let built = dest.with_extension("db.tmp"); std::fs::write(&built, b"new").unwrap(); - install_reference_db(&built, &dest).unwrap_err(); + let mut attempts = Vec::new(); + install_reference_db(&built, &dest, |attempt, _| attempts.push(attempt)).unwrap_err(); + assert_eq!(attempts, [1, 2, 3, 4, 5]); assert!(dest.is_dir()); - assert!(!built.exists()); + assert!(built.exists()); } } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0e10973..8b7e6f1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,6 +30,10 @@ url = "2" tauri-plugin-clipboard-manager = "2" image = { version = "0.25", default-features = false, features = ["jpeg"] } +[target.'cfg(windows)'.dependencies] +# Restart Manager: naming what has a file open when it can't be replaced. +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_RestartManager"] } + [target.'cfg(target_os = "android")'.dependencies] # Delivers sync sign-ins back to the app through the `mtgc://` scheme. tauri-plugin-deep-link = "2" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8bb5a44..31e50cd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -118,7 +118,14 @@ pub async fn update_card_data(app: AppHandle, db: Db<'_>) -> Res { let result = tauri::async_runtime::spawn_blocking({ let cancel = cancel.clone(); move || { + let mut stage = ""; mtgc_core::ingest::build_reference_db(&reference_path, &cancel, |p| { + // A stage at a time, so a failure can be placed without + // logging every progress tick. + if p.stage != stage { + stage = p.stage; + crate::mlog!("update_card_data: {} ({:.0}%)", p.message, p.overall_percent); + } let _ = app_for_progress.emit("ingest-progress", p); }) } @@ -127,6 +134,10 @@ pub async fn update_card_data(app: AppHandle, db: Db<'_>) -> Res { .map_err(|e| e.to_string())?; db.lock().unwrap().ingest_cancel = None; + if let Err(e) = &result { + // The debug form keeps the OS error code the message drops. + crate::mlog!("update_card_data: build failed: {e:?}"); + } let (built, stats) = crate::logged("update_card_data", result.map_err(|e| e.to_string()))?; crate::mlog!( "update_card_data: built ({} oracle cards, {} printings)", @@ -186,6 +197,21 @@ pub fn import_reference_db(app: AppHandle, db: Db, path: String) -> Res Ok(status_of(&s)) } +/// Show the log file in the system's file manager, so a user can send it. +#[cfg(desktop)] +#[tauri::command] +pub fn show_log_file(app: AppHandle) -> Res<()> { + use tauri::Manager; + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + tauri_plugin_opener::reveal_item_in_dir(crate::logfile::log_path(&data_dir)).map_err(|e| e.to_string()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn show_log_file() -> Res<()> { + Err("Not available on this device.".into()) +} + /// Cancel an in-progress `update_card_data`, if one is running. No-op otherwise. #[tauri::command] pub fn cancel_update_card_data(db: Db) -> Res<()> { diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs new file mode 100644 index 0000000..bde78b9 --- /dev/null +++ b/src-tauri/src/diagnostics.rs @@ -0,0 +1,177 @@ +//! Why a file couldn't be replaced, for the log. +//! +//! "Access is denied" alone doesn't say whether one file is held, by what, or +//! whether the whole folder is refusing changes, and those need different +//! fixes. Everything here only looks, apart from a throwaway pair of files. + +use std::path::Path; + +/// Everything worth knowing after `built` couldn't be moved over `dest`, one +/// line each. +pub fn swap_failure_report(built: &Path, dest: &Path) -> Vec { + let mut lines = vec![format!("this process: pid {}", std::process::id())]; + lines.push(describe(built)); + lines.push(describe(dest)); + lines.push(probe_folder(dest)); + #[cfg(windows)] + { + lines.push(windows::try_exclusive_open(built)); + lines.push(windows::try_exclusive_open(dest)); + lines.extend(windows::holders(&[built, dest])); + } + lines +} + +fn describe(path: &Path) -> String { + match std::fs::metadata(path) { + Ok(m) => { + #[cfg(windows)] + let extra = { + use std::os::windows::fs::MetadataExt; + format!(" attributes={:#x}", m.file_attributes()) + }; + #[cfg(not(windows))] + let extra = ""; + format!( + "{}: {} bytes readonly={} modified={:?}{extra}", + path.display(), + m.len(), + m.permissions().readonly(), + m.modified().ok() + ) + } + Err(e) => format!("{}: {e:?}", path.display()), + } +} + +/// Replace one throwaway file with another beside `path`. If even this fails +/// the folder is the problem (permissions, Controlled Folder Access) rather +/// than whatever has the real file open. +fn probe_folder(path: &Path) -> String { + let a = path.with_extension("probe-a"); + let b = path.with_extension("probe-b"); + let result = std::fs::write(&a, b"a") + .and_then(|()| std::fs::write(&b, b"b")) + .and_then(|()| std::fs::rename(&a, &b)); + let _ = std::fs::remove_file(&a); + let _ = std::fs::remove_file(&b); + match result { + Ok(()) => "folder probe: replacing a small file here works".into(), + Err(e) => format!("folder probe: replacing a small file here fails too: {e:?}"), + } +} + +#[cfg(windows)] +mod windows { + use std::os::windows::ffi::OsStrExt; + use std::os::windows::fs::OpenOptionsExt; + use std::path::Path; + + use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS}; + use windows_sys::Win32::System::RestartManager::{ + RmEndSession, RmGetList, RmRegisterResources, RmStartSession, CCH_RM_SESSION_KEY, RM_PROCESS_INFO, + }; + + /// Whether anything at all has `path` open: with no sharing allowed, the + /// open only succeeds if there's no other handle. + pub fn try_exclusive_open(path: &Path) -> String { + let opened = std::fs::OpenOptions::new().read(true).write(true).share_mode(0).open(path); + match opened { + Ok(_) => format!("{}: nothing else has it open", path.display()), + Err(e) => format!("{}: can't open exclusively: {e:?}", path.display()), + } + } + + /// The processes the Restart Manager says have `paths` open. It sees + /// ordinary applications and services, not antivirus drivers working in + /// the kernel, so an empty answer with a failed exclusive open points at + /// one of those. + pub fn holders(paths: &[&Path]) -> Vec { + let wide: Vec> = paths + .iter() + .map(|p| p.as_os_str().encode_wide().chain(std::iter::once(0)).collect()) + .collect(); + let names: Vec<*const u16> = wide.iter().map(|w| w.as_ptr()).collect(); + + let mut session = 0u32; + let mut key = [0u16; CCH_RM_SESSION_KEY as usize + 1]; + // SAFETY: `key` has the room the API requires, and `names` points at + // NUL-terminated strings that outlive the session. The session is + // always ended below. + unsafe { + let started = RmStartSession(&mut session, 0, key.as_mut_ptr()); + if started != ERROR_SUCCESS { + return vec![format!("restart manager: couldn't start a session ({started})")]; + } + let lines = list(session, &names); + RmEndSession(session); + lines + } + } + + /// SAFETY: `session` must be a live Restart Manager session. + unsafe fn list(session: u32, names: &[*const u16]) -> Vec { + let registered = RmRegisterResources( + session, + names.len() as u32, + names.as_ptr(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + ); + if registered != ERROR_SUCCESS { + return vec![format!("restart manager: couldn't register the files ({registered})")]; + } + + let mut procs: Vec = vec![std::mem::zeroed(); 32]; + let mut needed = 0u32; + let mut count = procs.len() as u32; + let mut reasons = 0u32; + let listed = RmGetList(session, &mut needed, &mut count, procs.as_mut_ptr(), &mut reasons); + if listed != ERROR_SUCCESS && listed != ERROR_MORE_DATA { + return vec![format!("restart manager: couldn't list holders ({listed})")]; + } + if needed == 0 { + return vec!["restart manager: no process reported as holding these files".into()]; + } + let mut lines: Vec = procs + .iter() + .take((count as usize).min(procs.len())) + .map(|p| { + let name = String::from_utf16_lossy(&p.strAppName); + format!( + "restart manager: held by pid {}: {}", + p.Process.dwProcessId, + name.trim_end_matches('\0') + ) + }) + .collect(); + lines.push(format!("restart manager: {needed} holder(s) in all")); + lines + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_report_covers_both_files_and_leaves_nothing_behind() { + let dir = std::env::temp_dir().join(format!("mtgc-diag-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("reference.db"); + std::fs::write(&dest, b"old").unwrap(); + // Gone already, which a report mustn't trip over. + let built = dir.join("reference.db.tmp"); + + let report = swap_failure_report(&built, &dest).join("\n"); + + assert!(report.contains("reference.db: 3 bytes"), "got: {report}"); + assert!(report.contains("reference.db.tmp: Os"), "got: {report}"); + assert!(report.contains("replacing a small file here works"), "got: {report}"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0dbad53..77f4e00 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,7 @@ mod commands; +mod diagnostics; mod libraries; +mod logfile; mod news; mod share; mod state; @@ -11,19 +13,20 @@ use tauri::Manager; use state::AppState; -/// Write a line to the platform log. +/// Write a line to the platform log and to [the log file](logfile). /// /// On Android this reaches `adb logcat` under the tag `RustStdoutStderr`: tao's /// Android glue `dup2`s stdout/stderr into a pipe and forwards each line to the /// system log, so a plain `eprintln!` is all that's needed and no logging -/// dependency is warranted. On desktop it's just stderr. +/// dependency is warranted. On desktop it's just stderr, which a Windows +/// release build doesn't have, hence the file. /// /// This matters because Tauri command failures are *returned* to the WebView as /// values, not raised — so without logging them explicitly they never appear in /// logcat at all, and the UI only ever shows a truncated version. #[macro_export] macro_rules! mlog { - ($($arg:tt)*) => { eprintln!("[mtgc] {}", format_args!($($arg)*)) }; + ($($arg:tt)*) => { $crate::logfile::write(format_args!($($arg)*)) }; } /// Log a command failure (with its full, untruncated message) and pass the @@ -55,6 +58,15 @@ pub fn run() { .app_data_dir() .expect("resolve app data dir"); std::fs::create_dir_all(&data_dir).ok(); + logfile::init(&data_dir); + // The version first: it says whether a report is about this build. + mlog!( + "mtgc {} starting on {} {} (pid {})", + app.package_info().version, + std::env::consts::OS, + std::env::consts::ARCH, + std::process::id() + ); mlog!("data dir: {}", data_dir.display()); app.manage(Mutex::new(AppState::new(data_dir))); #[cfg(target_os = "android")] @@ -74,6 +86,7 @@ pub fn run() { commands::update_card_data, commands::cancel_update_card_data, commands::import_reference_db, + commands::show_log_file, sync::sync_status, sync::sync_sign_in, sync::sync_cancel_sign_in, diff --git a/src-tauri/src/logfile.rs b/src-tauri/src/logfile.rs new file mode 100644 index 0000000..6f3ef2c --- /dev/null +++ b/src-tauri/src/logfile.rs @@ -0,0 +1,110 @@ +//! The log file, so a problem on someone else's machine leaves a trace. +//! +//! A Windows release build has no console, so stderr goes nowhere there. Every +//! [`mlog!`](crate::mlog) line is also appended to `mtgc.log` in the app data +//! folder, which is safe to ask a user for: nothing logged here includes a +//! sync token. + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Past this size the log is set aside at the next launch. +const MAX_BYTES: u64 = 1 << 20; + +static FILE: Mutex> = Mutex::new(None); + +pub fn log_path(data_dir: &Path) -> PathBuf { + data_dir.join("mtgc.log") +} + +/// Start logging to the file. Best-effort: a log that can't be written must +/// never stop the app from opening. +pub fn init(data_dir: &Path) { + let path = log_path(data_dir); + rotate(&path); + match std::fs::OpenOptions::new().create(true).append(true).open(&path) { + Ok(f) => *FILE.lock().unwrap() = Some(f), + Err(e) => eprintln!("[mtgc] couldn't open {}: {e}", path.display()), + } + + // Release builds abort on a panic, so this is the only record of one. + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + write(format_args!("panic: {info}")); + previous(info); + })); +} + +/// Keep one previous log, once the current one has grown large enough. +fn rotate(path: &Path) { + if std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_BYTES) { + let _ = std::fs::rename(path, path.with_extension("old.log")); + } +} + +pub fn write(message: std::fmt::Arguments) { + eprintln!("[mtgc] {message}"); + if let Ok(mut file) = FILE.lock() { + if let Some(f) = file.as_mut() { + let _ = writeln!(f, "{} {message}", timestamp(SystemTime::now())); + } + } +} + +/// `2026-10-30T19:59:01Z`. +fn timestamp(t: SystemTime) -> String { + let secs = t.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64; + let (y, m, d) = civil_from_days(secs.div_euclid(86_400)); + let s = secs.rem_euclid(86_400); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", s / 3600, s / 60 % 60, s % 60) +} + +/// Year, month and day of a count of days since 1970-01-01, by Howard +/// Hinnant's `civil_from_days`. +fn civil_from_days(days: i64) -> (i64, i64, i64) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + 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 }; + (yoe + era * 400 + i64::from(m <= 2), m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn timestamps_read_as_utc_dates() { + let at = |secs| timestamp(UNIX_EPOCH + Duration::from_secs(secs)); + assert_eq!(at(0), "1970-01-01T00:00:00Z"); + assert_eq!(at(951_782_400), "2000-02-29T00:00:00Z"); + assert_eq!(at(1_793_390_341), "2026-10-30T19:59:01Z"); + assert_eq!(at(1_798_761_599), "2026-12-31T23:59:59Z"); + } + + #[test] + fn only_a_large_log_is_set_aside() { + let dir = std::env::temp_dir().join(format!("mtgc-log-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = log_path(&dir); + let old = dir.join("mtgc.old.log"); + + std::fs::write(&path, b"short").unwrap(); + rotate(&path); + assert!(path.exists() && !old.exists()); + + std::fs::write(&path, vec![b'x'; MAX_BYTES as usize + 1]).unwrap(); + rotate(&path); + assert!(!path.exists() && old.exists()); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 33c6a64..9a124f4 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -446,10 +446,20 @@ impl AppState { /// file, so it lets go first. Holding `&mut self` keeps every command out /// until the catalog is back. pub fn replace_reference(&mut self, built: &Path) -> Result<(), String> { + crate::mlog!("replace_reference: releasing {}", self.reference_path.display()); self.close(); - let installed = mtgc_core::ingest::install_reference_db(built, &self.reference_path); + let installed = mtgc_core::ingest::install_reference_db(built, &self.reference_path, |attempt, e| { + crate::mlog!("replace_reference: attempt {attempt} failed: {e:?}"); + }); + if installed.is_err() { + for line in crate::diagnostics::swap_failure_report(built, &self.reference_path) { + crate::mlog!("replace_reference: {line}"); + } + let _ = std::fs::remove_file(built); + } // Either way there's a db to reopen: if the swap failed, the previous one. let reopened = self.reopen(); + crate::mlog!("replace_reference: reopened: {reopened:?}"); installed.map_err(|e| format!("Couldn't install the new card data: {e}"))?; reopened } diff --git a/src/App.tsx b/src/App.tsx index 668e18e..7dcf92e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -824,6 +824,14 @@ export default function App() { {ICON_OPEN_LIBRARY} Import card data… + {status && !status.is_mobile && ( + + )}
diff --git a/src/api.ts b/src/api.ts index dbfdf2d..3980537 100644 --- a/src/api.ts +++ b/src/api.ts @@ -493,6 +493,8 @@ export const api = { syncSignIn: (serverUrl: string, deviceName?: string) => invoke("sync_sign_in", { serverUrl, deviceName: deviceName || null }), syncCancelSignIn: () => invoke("sync_cancel_sign_in"), + /** Show the log file in the system's file manager. Desktop only. */ + showLogFile: () => invoke("show_log_file"), /** Sign this device out. The library stays here. */ syncSignOut: () => invoke("sync_sign_out"), /** Merge with the server and publish local edits. Resolves with `null` when