agent: code-atomiser-fix — migrate Tauri app_data_dir on bundle-identifier change

Commit 14313cf changed the Tauri bundle identifier from
uk.co.corbel.magnotia to consulting.corbel.lumotia. Tauri 2 keys both
app_data_dir and the webview's data store (localStorage, IndexedDB,
cookies, service worker, cache) plus all Tauri-plugin state files
(window-state geometry, autostart enable flag) by the identifier.
Without an explicit migration, every user's webview state was
silently orphaned on first launch under the new identifier; the
JS-side migrateLocalStorageKey helper introduced in 1608109 ran
against an empty store and no-op'd.

Fix: in the Tauri setup hook, before webview navigation, resolve the
legacy app_data_dir under the OLD bundle identifier and recursively
copy it to the new identifier's app_data_dir via an atomic staging
rename. Idempotent: legacy preserved as a backup; if both paths
exist post-rename, a warning is logged and the new path is used
unchanged.

Regression tests cover the migrate-only, both-exist, no-legacy, and
idempotent (run twice) cases against synthetic legacy/new paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 14:35:42 +01:00
parent 6ca94cbff0
commit 3f4e5cc9a4
3 changed files with 400 additions and 1 deletions

View File

@@ -471,7 +471,12 @@ fn is_cross_device(err: &std::io::Error) -> bool {
false 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)?; std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? { for entry in std::fs::read_dir(from)? {
let entry = entry?; let entry = entry?;

View File

@@ -1,4 +1,5 @@
mod commands; mod commands;
mod tauri_app_data_migration;
// System tray uses Tauri's `tray-icon` feature which is desktop-only. // System tray uses Tauri's `tray-icon` feature which is desktop-only.
// Android has no tray surface — drop the module entirely on that target. // Android has no tray surface — drop the module entirely on that target.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
@@ -226,6 +227,65 @@ pub fn run() {
builder builder
.setup(|app| { .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 // One-shot legacy data-dir migration: rename ~/.local/share/magnotia
// (and macOS/Windows equivalents) to the convention-preserving // (and macOS/Windows equivalents) to the convention-preserving
// lumotia path on first launch after the rebrand. Idempotent — // lumotia path on first launch after the rebrand. Idempotent —

View File

@@ -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<PathBuf> {
legacy_tauri_app_data_dir_for(OLD_BUNDLE_ID)
}
fn legacy_tauri_app_data_dir_for(identifier: &str) -> Option<PathBuf> {
#[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 `<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");
}
}