diff --git a/crates/core/src/paths.rs b/crates/core/src/paths.rs index 0f9d2a8..0e3540d 100644 --- a/crates/core/src/paths.rs +++ b/crates/core/src/paths.rs @@ -471,7 +471,12 @@ fn is_cross_device(err: &std::io::Error) -> bool { false } -fn copy_dir_recursive(from: &Path, to: &Path) -> Result<(), std::io::Error> { +/// Symlink-aware recursive directory copy used by the legacy data-dir +/// migration and by the Tauri-side `app_data_dir` bundle-identifier +/// migration in `src-tauri/src/tauri_app_data_migration.rs`. Exposed as +/// `pub` so callers in sibling crates can reuse the same hardened +/// implementation (see commit history for the symlink-loop defence). +pub fn copy_dir_recursive(from: &Path, to: &Path) -> Result<(), std::io::Error> { std::fs::create_dir_all(to)?; for entry in std::fs::read_dir(from)? { let entry = entry?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 59f580e..b0444ca 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +mod tauri_app_data_migration; // System tray uses Tauri's `tray-icon` feature which is desktop-only. // Android has no tray surface — drop the module entirely on that target. #[cfg(not(target_os = "android"))] @@ -226,6 +227,65 @@ pub fn run() { builder .setup(|app| { + // Tauri 2 keys both `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` renamed the bundle + // from `uk.co.corbel.magnotia` to `consulting.corbel.lumotia`, + // which would silently orphan every existing user's + // webview state under the new identifier. Migrate the + // legacy identifier-keyed app_data_dir first thing in + // setup, before the webview loads its first page and + // touches the on-disk store. Failure is logged but does + // NOT block startup: the hand-rolled `paths.rs` migration + // below covers the user's transcripts and models, so a + // total failure here only loses webview-keyed state. + // + // This is a separate concern from the + // `~/.local/share/magnotia` → `~/.local/share/lumotia` + // migration just below — that one lives under a + // hand-rolled path the app picks itself; this one lives + // under Tauri's identifier-keyed convention. + let t_app_data = Instant::now(); + match app.path().app_data_dir() { + Ok(new_app_data) => { + use crate::tauri_app_data_migration::{ + legacy_tauri_app_data_dir, migrate_tauri_app_data_dir_with_paths, + AppDataMigrationStatus, + }; + if let Some(legacy) = legacy_tauri_app_data_dir() { + match migrate_tauri_app_data_dir_with_paths(&legacy, &new_app_data) { + AppDataMigrationStatus::Migrated { old, new } => { + tracing::info!( + target: "lumotia_startup", + elapsed_ms = t_app_data.elapsed().as_millis(), + old = %old.display(), + new = %new.display(), + "migrated Tauri app_data_dir from legacy bundle identifier; legacy preserved as backup" + ); + } + AppDataMigrationStatus::BothExistLegacyPreserved { old, new } => { + tracing::warn!( + target: "lumotia_startup", + old = %old.display(), + new = %new.display(), + "both legacy and new Tauri app_data_dir exist; using new, legacy preserved" + ); + } + AppDataMigrationStatus::NoLegacyFound => {} + } + } + } + Err(e) => { + tracing::warn!( + target: "lumotia_startup", + error = %e, + "could not resolve Tauri app_data_dir; skipping bundle-identifier migration" + ); + } + } + // One-shot legacy data-dir migration: rename ~/.local/share/magnotia // (and macOS/Windows equivalents) to the convention-preserving // lumotia path on first launch after the rebrand. Idempotent — diff --git a/src-tauri/src/tauri_app_data_migration.rs b/src-tauri/src/tauri_app_data_migration.rs new file mode 100644 index 0000000..f80ac33 --- /dev/null +++ b/src-tauri/src/tauri_app_data_migration.rs @@ -0,0 +1,334 @@ +//! 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"; + +/// 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. The +/// AppHandle no longer knows the legacy identifier, so this is +/// hand-rolled per platform. +/// +/// 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 { + legacy_tauri_app_data_dir_for(OLD_BUNDLE_ID) +} + +fn legacy_tauri_app_data_dir_for(identifier: &str) -> Option { + #[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())?; + return 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())?; + return 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())?; + return 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"); + } +}