//! Tauri `app_data_dir` migration across the magnotia → lumotia bundle //! identifier change. //! //! Tauri 2 keys `app_data_dir` and the webview's data store (localStorage, //! IndexedDB, cookies, service worker storage, cache) plus all Tauri //! plugin state files (window-state geometry, autostart enable flag) by //! the bundle identifier. Commit `14313cf` changed the identifier from //! `uk.co.corbel.magnotia` to `consulting.corbel.lumotia`, which silently //! orphaned every existing user's webview state on first launch under //! the new identifier. The JS-side `migrateLocalStorageKey` helper added //! in `1608109` ran against an empty store and no-op'd. //! //! This module resolves the OLD identifier-keyed `app_data_dir` from //! platform conventions, then recursively copies it to the NEW path via //! an atomic staging rename. The legacy directory is preserved as a //! backup. Idempotent: on the second launch the new path already exists //! and nothing is touched. //! //! The hand-rolled `crates/core/src/paths.rs` migration that handles the //! `~/.local/share/magnotia` → `~/.local/share/lumotia` data dir //! (SQLite DB, models, recordings) is unrelated and remains the source //! of truth for the user's transcripts. The two migrations target //! different directories on disk. use std::path::{Path, PathBuf}; use lumotia_core::paths::copy_dir_recursive; /// Bundle identifier used before the magnotia → lumotia rebrand /// (commit `14313cf`). pub const OLD_BUNDLE_ID: &str = "uk.co.corbel.magnotia"; /// Bundle identifier in use post-rebrand. MUST match the `identifier` /// field in `src-tauri/tauri.conf.json` — if those two ever drift, /// the pre-runtime migration would copy data into a path Tauri never /// looks at. There is no compile-time check for this invariant; /// reviewer's job to catch a tauri.conf.json edit that doesn't update /// this const. pub const NEW_BUNDLE_ID: &str = "consulting.corbel.lumotia"; /// Outcome of an `app_data_dir` migration attempt. Mostly informational /// for the setup-hook tracing layer; the function never aborts startup. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AppDataMigrationStatus { /// Legacy path does not exist — fresh install or already migrated /// and the legacy backup has been deleted by the user. NoLegacyFound, /// Both legacy and new paths exist. This is also the steady state /// after the first successful migration (we preserve legacy as a /// backup), so the warning is informational rather than an /// indication of trouble. BothExistLegacyPreserved { old: PathBuf, new: PathBuf }, /// Migration succeeded: legacy copied to new path via atomic /// staging rename, legacy preserved as a backup. Migrated { old: PathBuf, new: PathBuf }, } /// Resolve the OLD Tauri `app_data_dir` from platform conventions. /// Used by the pre-runtime migration; the AppHandle isn't available /// yet at that stage AND the AppHandle is keyed by the NEW identifier /// anyway, so it couldn't surface the legacy path even if we had it. /// /// Returns `None` when the environment variable required to anchor the /// path is missing or empty — `HOME` on Unix, `APPDATA` on Windows. In /// that case the migration silently no-ops; there is no sensible /// fallback (the new path also depends on the same env vars and would /// be equally unrooted). pub fn legacy_tauri_app_data_dir() -> Option { tauri_app_data_dir_for(OLD_BUNDLE_ID) } /// Resolve the CURRENT (post-rebrand) Tauri `app_data_dir` from /// platform conventions. Used by the pre-runtime migration as the /// destination for the copy — pre-runtime means we run before /// `tauri::Builder::default()` so we can't call `app.path().app_data_dir()`. /// /// CRITICAL: this resolver MUST agree with Tauri 2's own resolution at /// runtime, otherwise the migration copies data into a path Tauri never /// reads. Both sides use the same XDG / Library / APPDATA conventions /// keyed by the bundle identifier, so they agree by construction as /// long as `NEW_BUNDLE_ID` matches `tauri.conf.json#identifier`. pub fn current_tauri_app_data_dir() -> Option { tauri_app_data_dir_for(NEW_BUNDLE_ID) } /// Platform-aware resolver shared by [`legacy_tauri_app_data_dir`] and /// [`current_tauri_app_data_dir`]. Public so integration tests in /// sibling crates can substitute an arbitrary identifier — production /// callers should prefer the two wrappers above. pub fn tauri_app_data_dir_for(identifier: &str) -> Option { // Exactly one of the four cfg blocks below is present per target // compile. Each is a tail expression that becomes the function's // return value. Avoiding explicit `return` keeps clippy's // needless_return lint happy on every platform. #[cfg(target_os = "linux")] { // XDG_DATA_HOME wins when set and non-empty, per the XDG Base // Directory specification. Tauri 2 honours the same precedence // when resolving `app_data_dir`, so the migration must too — // otherwise on a custom-XDG setup we'd copy from the wrong path. if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { if !xdg.is_empty() { return Some(PathBuf::from(xdg).join(identifier)); } } let home = std::env::var("HOME").ok().filter(|s| !s.is_empty())?; Some( PathBuf::from(home) .join(".local") .join("share") .join(identifier), ) } #[cfg(target_os = "macos")] { let home = std::env::var("HOME").ok().filter(|s| !s.is_empty())?; Some( PathBuf::from(home) .join("Library") .join("Application Support") .join(identifier), ) } #[cfg(target_os = "windows")] { let appdata = std::env::var("APPDATA").ok().filter(|s| !s.is_empty())?; Some(PathBuf::from(appdata).join(identifier)) } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { let _ = identifier; None } } /// Drive the migration end-to-end against the Tauri-resolved NEW path /// and an explicit OLD path (typically from /// [`legacy_tauri_app_data_dir`], factored out so tests can inject /// synthetic paths). /// /// Failure mode: any I/O error from the copy is logged at error /// severity, the partial staging dir is torn down, and the function /// returns `NoLegacyFound`. The user is better off with a working but /// fresh app than a refusing-to-start app — the hand-rolled /// `paths.rs` migration already covers the user's transcripts and /// models, so even a total-failure here only loses webview-keyed state /// (preferences, session storage, plugin geometry). pub fn migrate_tauri_app_data_dir_with_paths(old: &Path, new: &Path) -> AppDataMigrationStatus { let old_exists = old.exists(); let new_exists = new.exists(); match (old_exists, new_exists) { (false, _) => AppDataMigrationStatus::NoLegacyFound, (true, true) => { // Both exist. Refuse to merge — that risks clobbering user // state that's already been changed under the new // identifier. Caller logs a warning; legacy is preserved // as-is for manual recovery. AppDataMigrationStatus::BothExistLegacyPreserved { old: old.to_path_buf(), new: new.to_path_buf(), } } (true, false) => { // The interesting case. Copy via an atomic staging dir to // avoid leaving a half-written NEW path on copy failure. match copy_via_staging(old, new) { Ok(()) => AppDataMigrationStatus::Migrated { old: old.to_path_buf(), new: new.to_path_buf(), }, Err(e) => { tracing::error!( target: "lumotia_startup", err = ?e, old = %old.display(), new = %new.display(), "failed to migrate Tauri app_data_dir; new install will start fresh" ); AppDataMigrationStatus::NoLegacyFound } } } } } /// Copy `old` to `new` via a `.migrating` staging directory, then /// rename atomically into place. If the copy fails partway through, the /// partial staging dir is torn down and `new` does not appear. fn copy_via_staging(old: &Path, new: &Path) -> Result<(), std::io::Error> { let staging = staging_path_for(new); // Wipe any stray staging dir from a previous crashed migration. if staging.exists() { std::fs::remove_dir_all(&staging)?; } if let Some(parent) = new.parent() { std::fs::create_dir_all(parent)?; } if let Err(e) = copy_dir_recursive(old, &staging) { // Best-effort teardown; the original copy error is what the // caller wants to see. let _ = std::fs::remove_dir_all(&staging); return Err(e); } // Atomic rename. If `new` got created between the existence check // and here (concurrent process / race), the rename returns an // error on Windows and we surface it; on Unix it would clobber, // which we don't want either, so we re-check. if new.exists() { let _ = std::fs::remove_dir_all(&staging); return Err(std::io::Error::new( std::io::ErrorKind::AlreadyExists, "new app_data_dir appeared during migration; refusing to clobber", )); } std::fs::rename(&staging, new) } fn staging_path_for(new: &Path) -> PathBuf { // Same-parent staging keeps the rename on the same filesystem (no // EXDEV). We append a fixed suffix rather than a timestamp so a // crashed migration's residue is reliably cleaned up by the // pre-copy `remove_dir_all` on the next launch. let mut name = new .file_name() .map(|s| s.to_os_string()) .unwrap_or_default(); name.push(".migrating"); match new.parent() { Some(parent) => parent.join(name), None => PathBuf::from(name), } } #[cfg(test)] mod tests { use super::*; use std::fs; fn write_file(path: &Path, contents: &[u8]) { if let Some(parent) = path.parent() { fs::create_dir_all(parent).unwrap(); } fs::write(path, contents).unwrap(); } #[test] fn migrates_when_only_legacy_exists() { let tmp = tempfile::tempdir().unwrap(); let old = tmp.path().join("uk.co.corbel.magnotia"); let new = tmp.path().join("consulting.corbel.lumotia"); // Synthesise a fake legacy app_data_dir: a leveldb log under the // localStorage tree (proxy for the webview's keyed storage) and // a window-state.json sat next to it (proxy for plugin state). let leveldb = old.join("localStorage").join("leveldb").join("000003.log"); write_file(&leveldb, b"sentinel-leveldb-bytes"); let window_state = old.join("window-state.json"); write_file(&window_state, b"{\"main\":{\"x\":100,\"y\":200}}"); let status = migrate_tauri_app_data_dir_with_paths(&old, &new); assert!(matches!(status, AppDataMigrationStatus::Migrated { .. })); let migrated_leveldb = new.join("localStorage").join("leveldb").join("000003.log"); let migrated_window_state = new.join("window-state.json"); assert_eq!( fs::read(&migrated_leveldb).unwrap(), b"sentinel-leveldb-bytes" ); assert_eq!( fs::read(&migrated_window_state).unwrap(), b"{\"main\":{\"x\":100,\"y\":200}}" ); // Legacy preserved as a backup. The user can delete it manually // once they've confirmed their state survived. assert!(old.exists(), "legacy path must be preserved as a backup"); assert!(leveldb.exists()); // Staging directory is cleaned up. assert!(!tmp .path() .join("consulting.corbel.lumotia.migrating") .exists()); } #[test] fn no_op_when_legacy_missing() { let tmp = tempfile::tempdir().unwrap(); let old = tmp.path().join("uk.co.corbel.magnotia"); let new = tmp.path().join("consulting.corbel.lumotia"); let status = migrate_tauri_app_data_dir_with_paths(&old, &new); assert_eq!(status, AppDataMigrationStatus::NoLegacyFound); assert!(!new.exists()); } #[test] fn warns_but_does_not_copy_when_both_paths_exist() { let tmp = tempfile::tempdir().unwrap(); let old = tmp.path().join("uk.co.corbel.magnotia"); let new = tmp.path().join("consulting.corbel.lumotia"); let old_file = old.join("legacy.txt"); let new_file = new.join("new.txt"); write_file(&old_file, b"legacy"); write_file(&new_file, b"new"); let status = migrate_tauri_app_data_dir_with_paths(&old, &new); match status { AppDataMigrationStatus::BothExistLegacyPreserved { .. } => {} other => panic!("expected BothExistLegacyPreserved, got {other:?}"), } // Neither side mutated. assert_eq!(fs::read(&old_file).unwrap(), b"legacy"); assert_eq!(fs::read(&new_file).unwrap(), b"new"); // No legacy.txt landed in new — confirms we did not merge. assert!(!new.join("legacy.txt").exists()); } #[test] fn idempotent_when_legacy_already_migrated() { // First run: legacy → new copy. Second run: new exists, legacy // still exists (preserved as backup), and the function must // not re-copy or error. let tmp = tempfile::tempdir().unwrap(); let old = tmp.path().join("uk.co.corbel.magnotia"); let new = tmp.path().join("consulting.corbel.lumotia"); write_file(&old.join("file.txt"), b"v1"); let first = migrate_tauri_app_data_dir_with_paths(&old, &new); assert!(matches!(first, AppDataMigrationStatus::Migrated { .. })); // Simulate the user changing state on the new side after first // launch. If the second run merged or re-copied, this would be // clobbered back to "v1". fs::write(new.join("file.txt"), b"v2-edited-by-user").unwrap(); let second = migrate_tauri_app_data_dir_with_paths(&old, &new); assert!(matches!( second, AppDataMigrationStatus::BothExistLegacyPreserved { .. } )); assert_eq!( fs::read(new.join("file.txt")).unwrap(), b"v2-edited-by-user" ); } }