agent: code-atomiser-fix — migrate Tauri app_data_dir on bundle-identifier change
Commit14313cfchanged 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 in1608109ran 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:
@@ -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?;
|
||||
|
||||
@@ -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 —
|
||||
|
||||
334
src-tauri/src/tauri_app_data_migration.rs
Normal file
334
src-tauri/src/tauri_app_data_migration.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user