diff --git a/src-tauri/src/commands/patcher.rs b/src-tauri/src/commands/patcher.rs index 435aa69..a040421 100644 --- a/src-tauri/src/commands/patcher.rs +++ b/src-tauri/src/commands/patcher.rs @@ -4,7 +4,6 @@ use crate::legacy_patcher::runner::{ run_legacy_patcher_loop, LegacyPatcherLoopError, DEFAULT_HOOK_TIMEOUT_MS, }; use crate::mods::ModLibraryState; -use crate::overlay; use crate::patcher::{PatcherPhase, PatcherState, StoredPatcherConfig}; use crate::state::SettingsState; use serde::{Deserialize, Serialize}; @@ -218,26 +217,25 @@ pub(crate) fn start_patcher_inner( let handle = thread::spawn(move || { // Phase 1: Build overlay (the slow part) - let overlay_root = - match overlay::ensure_overlay(&library_clone, &settings_snapshot, &workshop_paths) { - Ok(root) => root, - Err(e) => { - tracing::error!(error = ?e, "Overlay build failed"); - let error_response: AppErrorResponse = e.into(); - let _ = library_clone - .app_handle() - .emit("patcher-error", &error_response); - if let Ok(mut s) = state_arc.lock() { - s.phase = PatcherPhase::Idle; - } - // TRAY: Reset to default on error - let _ = crate::tray::set_tray_state( - app_handle_thread.clone(), - crate::tray::AppTrayState::Default, - ); - return; + let overlay_root = match library_clone.ensure_overlay(&settings_snapshot, &workshop_paths) { + Ok(root) => root, + Err(e) => { + tracing::error!(error = ?e, "Overlay build failed"); + let error_response: AppErrorResponse = e.into(); + let _ = library_clone + .app_handle() + .emit("patcher-error", &error_response); + if let Ok(mut s) = state_arc.lock() { + s.phase = PatcherPhase::Idle; } - }; + // TRAY: Reset to default on error + let _ = crate::tray::set_tray_state( + app_handle_thread.clone(), + crate::tray::AppTrayState::Default, + ); + return; + } + }; // Check stop flag between build and patcher loop if stop_flag.load(Ordering::SeqCst) { diff --git a/src-tauri/src/mods/mod.rs b/src-tauri/src/mods/mod.rs index 2fcf2d1..fa33420 100644 --- a/src-tauri/src/mods/mod.rs +++ b/src-tauri/src/mods/mod.rs @@ -437,17 +437,54 @@ pub(super) fn load_library_index(storage_dir: &Path) -> AppResult return Ok(LibraryIndex::default()); } - LibraryIndex::load_and_migrate(storage_dir) + match LibraryIndex::load_and_migrate(storage_dir) { + Ok(index) => Ok(index), + // Version conflicts and IO errors must surface — the former is a user-visible + // compatibility issue; the latter may indicate permissions or disk problems + // that the user needs to address (and not silently overwrite). + Err(e @ AppError::SchemaVersionTooNew { .. }) | Err(e @ AppError::Io(_)) => Err(e), + Err(e) => { + // JSON parse failure or structural mismatch means the file content is + // corrupt (e.g. truncated mid-write). Back it up for diagnostics and + // reset to defaults so the app can recover. + tracing::warn!( + "Library index content is corrupt ({}); resetting to defaults", + e + ); + let corrupt_path = path.with_extension("json.corrupt"); + if let Err(rename_err) = fs::rename(&path, &corrupt_path) { + tracing::warn!( + "Failed to rename corrupt library index to {}: {}", + corrupt_path.display(), + rename_err + ); + } + Ok(LibraryIndex::default()) + } + } } pub(super) fn save_library_index(storage_dir: &Path, index: &LibraryIndex) -> AppResult<()> { fs::create_dir_all(storage_dir)?; let path = library_index_path(storage_dir); - // Ensure the version field is always current when writing let mut to_save = index.clone(); to_save.version = schema_migration::CURRENT_VERSION; let contents = serde_json::to_string_pretty(&to_save)?; - fs::write(path, contents)?; + atomic_write_json(&path, &contents)?; + Ok(()) +} + +/// Write `contents` to `path` atomically via a sibling `.json.tmp` file. +/// +/// A plain `fs::write` can leave `path` empty if the process is killed +/// mid-write; the rename is atomic on all supported platforms so the +/// destination is either the old version or the new version, never partial. +pub(super) fn atomic_write_json(path: &Path, contents: &str) -> AppResult<()> { + let tmp = path.with_extension("json.tmp"); + + fs::write(&tmp, contents)?; + fs::rename(&tmp, path)?; + Ok(()) } diff --git a/src-tauri/src/mods/schema_migration.rs b/src-tauri/src/mods/schema_migration.rs index f93e60b..4d2587b 100644 --- a/src-tauri/src/mods/schema_migration.rs +++ b/src-tauri/src/mods/schema_migration.rs @@ -3,7 +3,7 @@ use serde_json::Value; use std::fs; use std::path::Path; -use super::{library_index_path, LibraryIndex, ROOT_FOLDER_ID}; +use super::{atomic_write_json, library_index_path, LibraryIndex, ROOT_FOLDER_ID}; /// Current schema version for the library index. /// Increment this when making breaking changes to the schema and add a @@ -50,7 +50,7 @@ impl LibraryIndex { if migrated { let contents = serde_json::to_string_pretty(&index)?; - fs::write(&path, contents)?; + atomic_write_json(&path, &contents)?; } Ok(index) diff --git a/src-tauri/src/overlay/mod.rs b/src-tauri/src/overlay/mod.rs index 408e017..63d78e1 100644 --- a/src-tauri/src/overlay/mod.rs +++ b/src-tauri/src/overlay/mod.rs @@ -30,132 +30,162 @@ pub struct OverlayProgress { pub total: u32, } -/// Ensure the overlay exists and is up-to-date for the current enabled mod set. -/// -/// Returns the overlay root directory (the prefix passed to the legacy patcher). -/// -/// Workshop project paths (if any) are loaded via `FsModContent` and prepended -/// to the enabled mod list so they take highest priority. -pub fn ensure_overlay( - library: &ModLibrary, - settings: &Settings, - workshop_project_paths: &[PathBuf], -) -> AppResult { - let storage_dir = library.storage_dir(settings)?; - - let game_dir = resolve_game_dir(settings)?; - - // Get active profile slug and enabled mods - let (profile_slug, enabled_mods) = library.get_enabled_mods_for_overlay(settings)?; - - // Use profile-specific overlay and state directories - let profile_dir = storage_dir.join("profiles").join(profile_slug.as_str()); - let overlay_root = profile_dir.join("overlay"); - - tracing::info!("Overlay: storage_dir={}", storage_dir.display()); - tracing::info!("Overlay: profile_slug={}", profile_slug); - tracing::info!("Overlay: overlay_root={}", overlay_root.display()); - tracing::info!("Overlay: game_dir={}", game_dir.display()); - - let enabled_ids = enabled_mods - .iter() - .map(|m| m.id.clone()) - .collect::>(); - tracing::info!( - "Overlay: enabled_mods={} ids=[{}]", - enabled_ids.len(), - enabled_ids.join(", ") - ); - - // Convert to Utf8PathBuf for ltk_overlay API - let utf8_game_dir = Utf8PathBuf::from_path_buf(game_dir.clone()) - .map_err(|p| AppError::Other(format!("Non-UTF-8 game directory path: {}", p.display())))?; - let utf8_overlay_root = Utf8PathBuf::from_path_buf(overlay_root.clone()) - .map_err(|p| AppError::Other(format!("Non-UTF-8 overlay root path: {}", p.display())))?; - let utf8_state_dir = Utf8PathBuf::from_path_buf(profile_dir).map_err(|p| { - AppError::Other(format!("Non-UTF-8 profile directory path: {}", p.display())) - })?; - - let available_wads = list_game_wads(&game_dir).unwrap_or_else(|e| { - tracing::warn!( - "Failed to enumerate game WADs for regex expansion: {}; \ - regex blocklist entries will match nothing", - e +impl ModLibrary { + /// Ensure the overlay exists and is up-to-date for the current enabled mod set. + /// + /// Returns the overlay root directory (the prefix passed to the legacy patcher). + /// + /// Workshop project paths (if any) are loaded via `FsModContent` and prepended + /// to the enabled mod list so they take highest priority. + pub fn ensure_overlay( + &self, + settings: &Settings, + workshop_project_paths: &[PathBuf], + ) -> AppResult { + let storage_dir = self.storage_dir(settings)?; + let game_dir = resolve_game_dir(settings)?; + let (profile_slug, enabled_mods) = self.get_enabled_mods_for_overlay(settings)?; + + let profile_dir = storage_dir.join("profiles").join(profile_slug.as_str()); + let overlay_root = profile_dir.join("overlay"); + + tracing::info!("Overlay: storage_dir={}", storage_dir.display()); + tracing::info!("Overlay: profile_slug={}", profile_slug); + tracing::info!("Overlay: overlay_root={}", overlay_root.display()); + tracing::info!("Overlay: game_dir={}", game_dir.display()); + + let enabled_ids = enabled_mods + .iter() + .map(|m| m.id.clone()) + .collect::>(); + tracing::info!( + "Overlay: enabled_mods={} ids=[{}]", + enabled_ids.len(), + enabled_ids.join(", ") ); - Vec::new() - }); - let blocked_wads = resolve_blocked_wads(settings, &available_wads); - tracing::info!("Overlay: blocked_wads count={}", blocked_wads.len()); - - // Build overlay using ltk_overlay crate - let app_handle_clone = library.app_handle().clone(); - let mut builder = - ltk_overlay::OverlayBuilder::new(utf8_game_dir, utf8_overlay_root, utf8_state_dir) - .with_blocked_wads(blocked_wads) - .with_progress(move |progress| { - // Convert ltk_overlay progress to our format - let stage = match progress.stage { - ltk_overlay::OverlayStage::Indexing => OverlayStage::Indexing, - ltk_overlay::OverlayStage::CollectingOverrides => OverlayStage::Collecting, - ltk_overlay::OverlayStage::PatchingWad => OverlayStage::Patching, - ltk_overlay::OverlayStage::ApplyingStringOverrides => OverlayStage::Strings, - ltk_overlay::OverlayStage::Complete => OverlayStage::Complete, - }; - - let _ = app_handle_clone.emit( - "overlay-progress", - OverlayProgress { - stage, - current_file: progress.current_file, - current: progress.current, - total: progress.total, - }, - ); - }); - let mut all_mods = Vec::new(); - for project_path in workshop_project_paths { - let utf8_path = Utf8PathBuf::from_path_buf(project_path.clone()).map_err(|p| { - AppError::Other(format!("Non-UTF-8 workshop project path: {}", p.display())) + let utf8_game_dir = Utf8PathBuf::from_path_buf(game_dir.clone()).map_err(|p| { + AppError::Other(format!("Non-UTF-8 game directory path: {}", p.display())) + })?; + let utf8_overlay_root = Utf8PathBuf::from_path_buf(overlay_root.clone()).map_err(|p| { + AppError::Other(format!("Non-UTF-8 overlay root path: {}", p.display())) + })?; + let utf8_state_dir = Utf8PathBuf::from_path_buf(profile_dir).map_err(|p| { + AppError::Other(format!("Non-UTF-8 profile directory path: {}", p.display())) })?; - let dir_name = project_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - let id = format!("workshop:{}", dir_name); - tracing::info!("Adding workshop project: id={}, path={}", id, utf8_path); - all_mods.push(ltk_overlay::EnabledMod { - id, - content: Box::new(ltk_overlay::FsModContent::new(utf8_path)), - enabled_layers: None, + + let available_wads = list_game_wads(&game_dir).unwrap_or_else(|e| { + tracing::warn!( + "Failed to enumerate game WADs for regex expansion: {}; \ + regex blocklist entries will match nothing", + e + ); + Vec::new() }); - } - all_mods.extend(enabled_mods); - builder.set_enabled_mods(all_mods); - - builder - .build() - .map_err(|e| AppError::Other(format!("Overlay build failed: {}", e)))?; - - // Capture per-mod WAD reports for the library badge UI. Failure to - // persist must not fail the patch — log and continue. - // - // Note: `OverlayBuilder::build()` emits its own `Complete` progress event - // *before* returning, so the frontend may see that event before the reports - // are persisted. We emit a dedicated `wad-reports-updated` event after - // persisting so the frontend knows the cache is ready to query. - let reports = builder.take_mod_wad_reports(); - if !reports.is_empty() { - if let Some(state) = library.app_handle().try_state::() { - if let Err(e) = state.record_reports(reports) { - tracing::warn!("Failed to persist per-mod WAD reports: {}", e); - } else { - let _ = library.app_handle().emit("wad-reports-updated", ()); + let blocked_wads = resolve_blocked_wads(settings, &available_wads); + tracing::info!("Overlay: blocked_wads count={}", blocked_wads.len()); + + Self::clean_corrupt_overlay_state(&utf8_state_dir); + + let app_handle_clone = self.app_handle().clone(); + let mut builder = + ltk_overlay::OverlayBuilder::new(utf8_game_dir, utf8_overlay_root, utf8_state_dir) + .with_blocked_wads(blocked_wads) + .with_progress(move |progress| { + let stage = match progress.stage { + ltk_overlay::OverlayStage::Indexing => OverlayStage::Indexing, + ltk_overlay::OverlayStage::CollectingOverrides => OverlayStage::Collecting, + ltk_overlay::OverlayStage::PatchingWad => OverlayStage::Patching, + ltk_overlay::OverlayStage::ApplyingStringOverrides => OverlayStage::Strings, + ltk_overlay::OverlayStage::Complete => OverlayStage::Complete, + }; + let _ = app_handle_clone.emit( + "overlay-progress", + OverlayProgress { + stage, + current_file: progress.current_file, + current: progress.current, + total: progress.total, + }, + ); + }); + + let mut all_mods = Vec::new(); + for project_path in workshop_project_paths { + let utf8_path = Utf8PathBuf::from_path_buf(project_path.clone()).map_err(|p| { + AppError::Other(format!("Non-UTF-8 workshop project path: {}", p.display())) + })?; + let dir_name = project_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + let id = format!("workshop:{}", dir_name); + tracing::info!("Adding workshop project: id={}, path={}", id, utf8_path); + all_mods.push(ltk_overlay::EnabledMod { + id, + content: Box::new(ltk_overlay::FsModContent::new(utf8_path)), + enabled_layers: None, + }); + } + all_mods.extend(enabled_mods); + builder.set_enabled_mods(all_mods); + + builder + .build() + .map_err(|e| AppError::Other(format!("Overlay build failed: {}", e)))?; + + // Capture per-mod WAD reports for the library badge UI. Failure to + // persist must not fail the patch — log and continue. + // + // Note: `OverlayBuilder::build()` emits its own `Complete` progress event + // *before* returning, so the frontend may see that event before the reports + // are persisted. We emit a dedicated `wad-reports-updated` event after + // persisting so the frontend knows the cache is ready to query. + let reports = builder.take_mod_wad_reports(); + if !reports.is_empty() { + if let Some(state) = self.app_handle().try_state::() { + if let Err(e) = state.record_reports(reports) { + tracing::warn!("Failed to persist per-mod WAD reports: {}", e); + } else { + let _ = self.app_handle().emit("wad-reports-updated", ()); + } } } + + Ok(overlay_root) } - Ok(overlay_root) + /// Scan `state_dir` for top-level JSON files that are empty or contain invalid + /// JSON and remove them so `ltk_overlay` does not fail to parse stale/corrupt + /// state files written by a previous run that was interrupted mid-write. + fn clean_corrupt_overlay_state(state_dir: &camino::Utf8Path) { + let entries = match std::fs::read_dir(state_dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let contents = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => continue, + }; + if contents.trim().is_empty() + || serde_json::from_str::(&contents).is_err() + { + tracing::warn!( + "Removing corrupt overlay state file before build: {}", + path.display() + ); + let _ = std::fs::remove_file(&path); + } + } + } } /// Resolve the user's blocklist settings into a concrete, deduped list of WAD