From 30a1a68198c4bdd56b3c17caee41bd6e4e1ce68d Mon Sep 17 00:00:00 2001 From: Matt Alldian Date: Fri, 24 Jul 2026 19:12:17 -0400 Subject: [PATCH] fix(desktop): probe legacy app-data dir at most once per install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `migrate_legacy_app_data_dir` ran on every launch and called `legacy_dir.exists()` on `xyz.block.sprout.app` — another app's container. On macOS that stat is itself gated by TCC, so it triggers the "would like to access data from other apps" consent prompt before the function bails. Users who never ran Sprout have no legacy dir at all, so the probe could never lead to a copy — it only produced a consent prompt on every single launch. Gate the probe behind a `.legacy-app-data-probed` marker written inside the current app-data dir. Placement is deliberate: unlike the reset sentinel in `reset.rs` (which lives in the parent so it survives a wipe), this marker must be wiped alongside `app_data_dir` so a genuine post-reset migration can still run. The marker is written *before* the probe so an unwritable marker skips probing rather than prompting forever. Extracts the body into `migrate_legacy_app_data_dir_at` so the behavior is testable without a Tauri AppHandle. Tests: first-run copy, no-reprobe once marked, marking when the legacy dir is absent, and re-running after a reset wipes the marker. Verified the no-reprobe test fails without the guard. --- desktop/src-tauri/src/migration.rs | 45 +++++++++++++- desktop/src-tauri/src/migration_tests.rs | 75 ++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) 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)`.