diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 0de8ea0a82..d36b715767 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -25,6 +25,14 @@ const CANONICAL_DEV_IDENTIFIER: &str = "xyz.block.buzz.app.dev"; const LEGACY_CANONICAL_DEV_IDENTIFIER: &str = "xyz.block.sprout.app.dev"; const LEGACY_RELEASE_IDENTIFIER: &str = "xyz.block.sprout.app"; +/// Marker recording that the legacy-app-data probe has already run. +/// +/// Lives *inside* the current app-data dir (not its parent, unlike the reset +/// sentinel) so a boot reset — which wipes `app_data_dir` and the legacy dir +/// together — also clears it. That ordering is load-bearing: after a reset the +/// probe must be free to re-run, find the legacy dir gone, and re-mark. +const LEGACY_APP_DATA_PROBE_SENTINEL: &str = ".legacy-app-data-probed"; + /// JSON files symlinked from worktree data directories to the canonical /// dev data directory. Only data files — never `agent-pids/` or `logs/`. /// `identity.key` is deliberately excluded because worktree instances @@ -192,6 +200,10 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { /// the current Buzz identifier directory. The Tauri identifier controls the app /// data path, so without this copy a product rename would look like a fresh /// install and users would lose their persisted identity and agent settings. +/// +/// Runs at most once per install: the legacy dir belongs to another app's +/// container, so probing it repeatedly re-triggers the macOS "access data from +/// other apps" prompt. See [`migrate_legacy_app_data_dir_at`]. pub fn migrate_legacy_app_data_dir(app: &tauri::AppHandle) { let current_dir = match app.path().app_data_dir() { Ok(dir) => dir, @@ -203,10 +215,41 @@ pub fn migrate_legacy_app_data_dir(app: &tauri::AppHandle) { let Some(legacy_dir) = legacy_app_data_dir(¤t_dir) else { return; }; + migrate_legacy_app_data_dir_at(&legacy_dir, ¤t_dir); +} + +/// Copy legacy app data from `legacy_dir` into `current_dir`, at most once ever. +/// +/// The one-shot guard exists for macOS TCC, not for speed. `legacy_dir` sits in +/// another app's container (`xyz.block.sprout.app`), and on macOS *any* access — +/// including the `exists()` stat itself — trips the "would like to access data +/// from other apps" consent prompt. Because the pre-sentinel code probed on every +/// launch and bailed immediately when the dir was absent, users who never ran +/// Sprout got a prompt on every single launch that could never lead to a copy. +/// +/// Writing the marker *before* the probe is deliberate: if the marker cannot be +/// written we skip the probe entirely rather than risk prompting forever. A +/// missed migration is recoverable; an unkillable system prompt is not. +fn migrate_legacy_app_data_dir_at(legacy_dir: &Path, current_dir: &Path) { + let sentinel = current_dir.join(LEGACY_APP_DATA_PROBE_SENTINEL); + if sentinel.exists() { + return; + } + if let Err(error) = + std::fs::create_dir_all(current_dir).and_then(|()| std::fs::write(&sentinel, b"")) + { + eprintln!( + "buzz-desktop: app-data-migration: cannot write probe marker {}: {error} — \ + skipping legacy probe to avoid repeating the macOS consent prompt", + sentinel.display() + ); + return; + } + if !legacy_dir.exists() { return; } - match copy_dir_all(&legacy_dir, ¤t_dir) { + match copy_dir_all(legacy_dir, current_dir) { Ok(()) => eprintln!( "buzz-desktop: app-data-migration: copied legacy data from {} to {}", legacy_dir.display(), diff --git a/desktop/src-tauri/src/migration_tests.rs b/desktop/src-tauri/src/migration_tests.rs index 0d49bd02aa..6b1f29e215 100644 --- a/desktop/src-tauri/src/migration_tests.rs +++ b/desktop/src-tauri/src/migration_tests.rs @@ -62,6 +62,81 @@ fn copy_dir_all_preserves_nested_files_without_overwriting() { ); } +#[test] +fn legacy_app_data_migration_copies_then_marks_probed() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join("xyz.block.sprout.app"); + let current = dir.path().join("xyz.block.buzz.app"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("identity.key"), "old-key").unwrap(); + + migrate_legacy_app_data_dir_at(&legacy, ¤t); + + assert_eq!( + std::fs::read_to_string(current.join("identity.key")).unwrap(), + "old-key", + "legacy data must still migrate on the first run" + ); + assert!(current.join(LEGACY_APP_DATA_PROBE_SENTINEL).exists()); +} + +/// The TCC fix: once probed, a later legacy dir is never touched again. Reading +/// the foreign container is what triggers the macOS consent prompt, so "did not +/// copy" here stands in for "did not prompt". +#[test] +fn legacy_app_data_migration_does_not_reprobe_after_marker() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join("xyz.block.sprout.app"); + let current = dir.path().join("xyz.block.buzz.app"); + std::fs::create_dir_all(¤t).unwrap(); + std::fs::write(current.join(LEGACY_APP_DATA_PROBE_SENTINEL), b"").unwrap(); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("identity.key"), "old-key").unwrap(); + + migrate_legacy_app_data_dir_at(&legacy, ¤t); + + assert!( + !current.join("identity.key").exists(), + "a marked install must not read the legacy container again" + ); +} + +/// The reported bug: no legacy dir at all (user never ran Sprout). The first +/// launch marks the install so subsequent launches skip the probe entirely. +#[test] +fn legacy_app_data_migration_marks_even_when_legacy_absent() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join("xyz.block.sprout.app"); + let current = dir.path().join("xyz.block.buzz.app"); + + migrate_legacy_app_data_dir_at(&legacy, ¤t); + + assert!( + current.join(LEGACY_APP_DATA_PROBE_SENTINEL).exists(), + "absent legacy dir must still mark, or the prompt repeats forever" + ); +} + +/// A reset wipes `app_data_dir` (and the legacy dir) together, clearing the +/// marker — so a genuine post-reset migration can still run. +#[test] +fn legacy_app_data_migration_reruns_after_marker_wiped() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join("xyz.block.sprout.app"); + let current = dir.path().join("xyz.block.buzz.app"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("identity.key"), "old-key").unwrap(); + + migrate_legacy_app_data_dir_at(&legacy, ¤t); + std::fs::remove_dir_all(¤t).unwrap(); + migrate_legacy_app_data_dir_at(&legacy, ¤t); + + assert_eq!( + std::fs::read_to_string(current.join("identity.key")).unwrap(), + "old-key" + ); +} + /// Helper: create a temp dir structure mimicking canonical + worktree layout. /// Packs live in a `.main` sibling (not canonical) to match real-world state. /// Returns `(parent_dir_handle, canonical_dir, worktree_dir)`.