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)
363 lines
14 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|