Log to a file, and explain a failed card data swap
A Windows release build has no console, so mlog! lines and panics went nowhere. They now also go to mtgc.log in the app data folder, which the Info tab's Show log file button reveals, and each launch logs the version. A card data update logs each stage, and a failed swap logs every attempt plus what's blocking it: both files' details, whether the folder refuses changes in general, and on Windows whether the files can be opened exclusively and which processes the Restart Manager says hold them.
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2575,6 +2575,7 @@ dependencies = [
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-opener",
|
||||
"url",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -118,7 +118,14 @@ pub async fn update_card_data(app: AppHandle, db: Db<'_>) -> Res<Status> {
|
||||
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<Status> {
|
||||
.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<Status>
|
||||
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<()> {
|
||||
|
||||
177
src-tauri/src/diagnostics.rs
Normal file
177
src-tauri/src/diagnostics.rs
Normal file
@@ -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<String> {
|
||||
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<String> {
|
||||
let wide: Vec<Vec<u16>> = 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<String> {
|
||||
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<RM_PROCESS_INFO> = 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<String> = 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
110
src-tauri/src/logfile.rs
Normal file
110
src-tauri/src/logfile.rs
Normal file
@@ -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<Option<File>> = 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -824,6 +824,14 @@ export default function App() {
|
||||
{ICON_OPEN_LIBRARY}
|
||||
Import card data…
|
||||
</button>
|
||||
{status && !status.is_mobile && (
|
||||
<button
|
||||
title="Show the app's log file, to send along with a problem report"
|
||||
onClick={() => api.showLogFile().catch((err) => window.alert(String(err)))}
|
||||
>
|
||||
Show log file
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="panel info-section info-summary">
|
||||
|
||||
@@ -493,6 +493,8 @@ export const api = {
|
||||
syncSignIn: (serverUrl: string, deviceName?: string) =>
|
||||
invoke<SyncStatus>("sync_sign_in", { serverUrl, deviceName: deviceName || null }),
|
||||
syncCancelSignIn: () => invoke<void>("sync_cancel_sign_in"),
|
||||
/** Show the log file in the system's file manager. Desktop only. */
|
||||
showLogFile: () => invoke<void>("show_log_file"),
|
||||
/** Sign this device out. The library stays here. */
|
||||
syncSignOut: () => invoke<SyncStatus>("sync_sign_out"),
|
||||
/** Merge with the server and publish local edits. Resolves with `null` when
|
||||
|
||||
Reference in New Issue
Block a user