Files
Lumotia/src-tauri/src/tauri_app_data_migration.rs
Jake ff8dda06d0 agent: lumotia — Phase A.7 fix startup-order race that silently orphaned legacy data
Critical bug surfaced by the dogfood drill: every upgrading Magnotia user
would silently keep a fresh empty Lumotia install while their Magnotia
data sat orphaned next to it. Drill caught it on the first real run
under sandboxed HOME.

ROOT CAUSE

src-tauri/src/lib.rs::run() previously called the migrations from inside
the Tauri setup hook (post `tauri::Builder::default()`). But three
sequential actions BEFORE the setup hook had already created the
destination directories:

  1. init_tracing() -> logs_dir() -> create_dir_all(app_data_dir/logs)
     creates the lumotia/ root.
  2. install_panic_hook() -> crashes_dir() -> create_dir_all() ditto.
  3. Tauri's WebKitGTK runtime / plugin chain creates the bundle-id-keyed
     consulting.corbel.lumotia/ dir eagerly when the WebContext spins up
     (mediakeys, storage, WebKitCache subdirs appeared even without our
     hook explicitly creating them).

By the time the setup-hook migrations fired, every legacy candidate
returned `TargetAlreadyExists` (paths.rs) or `BothExistLegacyPreserved`
(tauri_app_data_migration.rs) — both silent no-op codepaths. Legacy
data was left untouched, fresh Lumotia install gained no transcripts,
settings, or window state.

FIX

Migrate BEFORE any other code touches app_data_dir().

src-tauri/src/tauri_app_data_migration.rs:
  - NEW_BUNDLE_ID const ("consulting.corbel.lumotia"). MUST agree with
    tauri.conf.json#identifier; reviewer-enforced invariant.
  - Renamed private `legacy_tauri_app_data_dir_for` -> public
    `tauri_app_data_dir_for(identifier)`. Function is parameterised by
    bundle id; the "legacy" name was misleading after this change.
  - New `current_tauri_app_data_dir()` resolves the NEW bundle path
    from platform env vars (same convention Tauri 2 uses), so the
    pre-runtime migration can address its destination without
    needing an AppHandle.

src-tauri/src/lib.rs:
  - New `migrate_user_data_pre_runtime()` orchestrates the two
    migrations + ambiguity guard. Uses `eprintln!` for surface events
    (tracing not yet initialised at this stage; stderr lands in
    journald / foreground terminal which is the right transport for
    boot-phase output). FATAL errors call process::exit(1) — the
    setup-hook version returned Err from the closure, equivalent
    effect.
  - run() now calls migrate_user_data_pre_runtime() as its first line,
    BEFORE init_tracing(), install_panic_hook(), and the Tauri
    builder.
  - Setup-hook migration blocks deleted (~90 lines). Setup hook now
    starts with a one-line comment pointing at the pre-runtime fn.

VERIFICATION

Re-ran the dogfood drill (scripts/dogfood-rebrand-drill.sh) — 8/8 probes
pass after the fix (was 4/8). Both stderr lines fire:

  [lumotia-startup] migrated legacy magnotia data dir to lumotia:
      .../magnotia -> .../lumotia (renamed_db=true, elapsed_ms=0)
  [lumotia-startup] migrated Tauri app_data_dir from legacy bundle
      identifier: .../uk.co.corbel.magnotia ->
      .../consulting.corbel.lumotia (elapsed_ms=0)

On-disk post-state confirms: magnotia/ gone, lumotia/ has migrated db
+ recordings, uk.co.corbel.magnotia/ preserved as backup,
consulting.corbel.lumotia/localStorage/leveldb/ has migrated data.

- cargo fmt --check: clean
- cargo clippy --workspace --all-targets -- -D warnings: clean
- cargo test --workspace: 409/0 (no regression)
2026-05-14 13:59:08 +01:00

363 lines
14 KiB
Rust

//! 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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
// 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 `<new>.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"
);
}
}