From d0e8376595a339dc35ca2bd1538a0b8a563df787 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Mon, 13 Jul 2026 18:26:32 -0700 Subject: [PATCH 1/7] docs: design native File Provider approval onboarding --- ...le-provider-native-start-syncing-design.md | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-file-provider-native-start-syncing-design.md diff --git a/docs/superpowers/specs/2026-07-14-file-provider-native-start-syncing-design.md b/docs/superpowers/specs/2026-07-14-file-provider-native-start-syncing-design.md new file mode 100644 index 00000000..aa2a14c9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-file-provider-native-start-syncing-design.md @@ -0,0 +1,116 @@ +# File Provider Native Start Syncing Approval Design + +## Goal + +Make macOS onboarding match the native File Provider approval flow used by +Dropbox and Google Drive. When macOS shows the native "Start Syncing" prompt and +the user clicks OK, Locality should treat that as the File Provider location +being enabled. Onboarding must not ask the user to click a separate Finder +"Enable" control after that. + +## Problem + +Locality currently describes File Provider approval as "Enable Locality in +Finder" and falls back to Finder/System Settings guidance for a disabled File +Provider domain. That copy implies a second enablement step after macOS's native +prompt. For desktop apps such as Dropbox and Google Drive, accepting the native +prompt is the action that enables the File Provider location. Locality should +follow that platform convention. + +The mismatch creates an onboarding dead end: after the user clicks OK in the +native macOS prompt, Locality can still show guidance to enable Locality +elsewhere even though the next correct action is simply to verify that macOS has +materialized the CloudStorage root. + +## Design + +Use the macOS native File Provider prompt as the approval surface. + +When setup registers the Locality File Provider domain and receives a +recoverable "registered but not enabled" result, onboarding enters an +`approval_required` state whose copy tells the user to click OK in the macOS +"Start Syncing" prompt. The primary action remains `Allow in macOS`, but its job +is to trigger or foreground the native approval path, not to send users looking +for a Finder Enable control. + +After the user takes the `Allow in macOS` action, the backend should immediately +try the workspace mount setup again. If macOS has accepted approval and the +CloudStorage root exists, setup completes. If approval has been accepted but the +CloudStorage root has not appeared yet, onboarding moves to +`waiting_for_cloudstorage_root` with `Check again`. If the domain is still +disabled because the user clicked "Don't allow" or dismissed the native prompt, +onboarding stays in `approval_required` and can offer `Allow in macOS` again. + +Finder/System Settings guidance remains only a recovery fallback for cases where +the native prompt cannot be surfaced or the File Provider domain remains +disabled after retry. It should not be presented as the normal next step after +the native prompt. + +## Data Flow + +1. Onboarding starts mount creation. +2. Desktop backend registers the macOS File Provider domain through the existing + helper, which uses `NSFileProviderManager.add`. +3. macOS may show the native "Start Syncing" prompt. +4. If the helper reports the domain is registered but not user-enabled, + onboarding returns `approval_required`. +5. User clicks `Allow in macOS`. +6. Backend triggers or foregrounds the native approval surface and reruns mount + creation. +7. Outcomes: + - user clicked OK and CloudStorage is ready: mount creation completes; + - user clicked OK but CloudStorage is delayed: `waiting_for_cloudstorage_root`; + - user clicked "Don't allow" or did not approve: `approval_required`; + - non-File-Provider setup failure: `failed`. + +## Components + +- `platform/macos/LocalityFileProvider/Sources/LocalityFileProviderCtl/main.swift` + should stop telling users that a disabled domain requires a separate Finder or + System Settings enablement step. +- `apps/desktop/src-tauri/src/main.rs` should classify and report disabled + domains using native prompt language, and the `allow_in_macos` action should + retry setup after surfacing approval instead of always returning a manual + check-again state. +- `apps/desktop/src/onboarding-mount.ts` should update labels, headlines, + progress copy, instructions, and supplementary notes to reflect the native + "Start Syncing" prompt. +- Existing path validation and File Provider root safety behavior stays + unchanged. + +## Error Handling + +- If `NSFileProviderManager.add` or domain lookup fails because the File Provider + app or extension is unavailable, onboarding stays in `failed` with the + existing development/install guidance. +- If the user denies or dismisses the native prompt, `userEnabled` remains false; + onboarding stays recoverable in `approval_required`. +- If `userEnabled` is true but `getUserVisibleURL` or the CloudStorage root is + not ready, onboarding uses `waiting_for_cloudstorage_root` and `Check again`. +- Path validation, read-only ordinary directory handling, and provider-root + containment errors remain unchanged and continue to use path-validation + messaging. + +## Testing + +Add focused tests for: + +- frontend copy no longer mentioning a Finder/File Provider Enable step in the + normal approval instructions; +- frontend approval progress and headline copy referencing the native macOS + prompt; +- backend curated approval message referencing the native "Start Syncing" + prompt; +- backend `allow_in_macos` behavior retrying setup after approval surfacing, + using an injectable/testable runner so it does not call real macOS APIs; +- disabled-domain helper/error copy no longer implying a second Finder Enable + step; +- existing `waiting_for_cloudstorage_root`, generic failure, and path validation + behavior continuing to work. + +## Scope + +This change does not alter File Provider identity, projection layout, durable +state schema, mount path validation, daemon sync, push/pull behavior, or +recovery reset logic. It only changes onboarding state transitions and +user-facing guidance around the macOS File Provider approval prompt. From b64eaa23058293bc224963c25c831fa9f5c8b8f3 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Mon, 13 Jul 2026 21:03:09 -0700 Subject: [PATCH 2/7] docs: plan native File Provider approval onboarding --- ...7-14-file-provider-native-start-syncing.md | 466 ++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-file-provider-native-start-syncing.md diff --git a/docs/superpowers/plans/2026-07-14-file-provider-native-start-syncing.md b/docs/superpowers/plans/2026-07-14-file-provider-native-start-syncing.md new file mode 100644 index 00000000..d61e06d5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-file-provider-native-start-syncing.md @@ -0,0 +1,466 @@ +# File Provider Native Start Syncing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make macOS onboarding treat the native "Start Syncing" OK action as File Provider approval and stop asking users to click a separate Finder/System Settings Enable control. + +**Architecture:** Keep durable sync and mount validation unchanged. Update onboarding state/copy in the TypeScript UI and Rust backend, and make the `allow_in_macos` action surface approval then immediately retry mount setup so a native OK can complete onboarding. Keep helper/error copy aligned with the native prompt without broadening File Provider reset behavior. + +**Tech Stack:** TypeScript, Vitest, Rust/Tauri, Cargo tests, Swift File Provider helper copy + +--- + +## File Structure + +- `apps/desktop/src/onboarding-mount.test.ts`: frontend RED/GREEN coverage for native "Start Syncing" approval copy and absence of normal Finder Enable instructions. +- `apps/desktop/src/onboarding-mount.ts`: frontend labels, headline, instructions, and supplementary note for macOS native prompt approval. +- `apps/desktop/src-tauri/src/main.rs`: backend onboarding state/copy and injectable runner for testing `allow_in_macos` retry behavior without calling real macOS APIs. +- `apps/desktop/src/onboarding-errors.test.ts`: frontend setup-error fixture updated to the new disabled-domain copy. +- `platform/macos/LocalityFileProvider/Sources/LocalityFileProviderCtl/main.swift`: disabled-domain helper message updated to native prompt language. +- `crates/loc-cli/src/file_provider.rs`: helper error expectation updated so developer guidance does not require a separate Finder Enable action. +- `docs/desktop-app.md`: product doc updated to describe the native prompt as the normal approval path. + +## Task 1: Frontend Native Prompt Copy + +**Files:** +- Modify: `apps/desktop/src/onboarding-mount.test.ts` +- Modify: `apps/desktop/src/onboarding-mount.ts` + +- [ ] **Step 1: Write the failing frontend copy tests** + +In `apps/desktop/src/onboarding-mount.test.ts`, update and add tests so approval copy is about the native macOS prompt: + +```ts +it("labels and describes the native macOS approval prompt", () => { + expect(mountOnboardingPrimaryLabel(report({ primaryAction: "allow_in_macos" }), false)).toBe( + "Allow in macOS", + ); + expect(mountOnboardingPrimaryLabel(report({ primaryAction: "allow_in_macos" }), true)).toBe( + "Opening macOS prompt", + ); + expect(mountOnboardingHeadline(report({ state: "approval_required" }))).toBe( + "Approve the macOS Start Syncing prompt.", + ); +}); +``` + +Replace the existing instruction expectation with: + +```ts +it("explains that OK in the native prompt enables the File Provider location", () => { + const instructions = + mountOnboardingInstructions?.(report({ launchStrategy: "instructions_only" })) ?? null; + + expect(instructions).toBe( + `Click OK in the macOS "Start Syncing" prompt, then Locality will check the folder again. If you clicked Don't allow, choose Allow in macOS to try again.`, + ); + expect(instructions).not.toContain("enable the File Provider"); + expect(instructions).not.toContain("System Settings"); +}); +``` + +- [ ] **Step 2: Run the frontend tests and verify RED** + +Run: + +```bash +cd apps/desktop +npm install +npm test -- --run src/onboarding-mount.test.ts +``` + +Expected before implementation: tests fail because existing copy says `Opening Finder`, `Allow Locality in Finder.`, and `enable the File Provider`. + +- [ ] **Step 3: Implement minimal frontend copy changes** + +In `apps/desktop/src/onboarding-mount.ts`, make these exact copy changes: + +```ts +if (busy && report?.primaryAction === "allow_in_macos") { + return "Opening macOS prompt"; +} +``` + +```ts +case "approval_required": + return "Approve the macOS Start Syncing prompt."; +``` + +```ts +return ( + 'Click OK in the macOS "Start Syncing" prompt, then Locality will check the folder again. ' + + "If you clicked Don't allow, choose Allow in macOS to try again." +); +``` + +- [ ] **Step 4: Run the frontend tests and verify GREEN** + +Run: + +```bash +cd apps/desktop +npm test -- --run src/onboarding-mount.test.ts +``` + +Expected: all `onboarding-mount` tests pass. + +- [ ] **Step 5: Commit frontend copy** + +Run: + +```bash +git add apps/desktop/src/onboarding-mount.ts apps/desktop/src/onboarding-mount.test.ts apps/desktop/package-lock.json apps/desktop/package.json +git commit -m "fix: describe native File Provider approval prompt" +``` + +If `npm install` does not change `package-lock.json` or `package.json`, omit those files from `git add`. + +## Task 2: Backend `allow_in_macos` Retries Mount Setup + +**Files:** +- Modify: `apps/desktop/src-tauri/src/main.rs` + +- [ ] **Step 1: Write the failing backend behavior test** + +In the `#[cfg(test)] mod tests` section of `apps/desktop/src-tauri/src/main.rs`, add: + +```rust +#[test] +fn workspace_mount_onboarding_allow_action_retries_mount_setup_after_native_prompt() { + let mut launch_count = 0usize; + let mut requests = Vec::new(); + + let report = super::run_workspace_mount_onboarding_with( + super::WorkspaceMountOnboardingRequest { + path: "~/Library/CloudStorage/Locality/notion-main".to_string(), + action: "allow_in_macos".to_string(), + }, + |request| { + requests.push(( + request.connector.clone(), + request.path.clone(), + request.mount_id.clone(), + )); + Ok("Mounted Notion at /Users/test/Library/CloudStorage/Locality/notion-main with macOS File Provider.".to_string()) + }, + || { + launch_count += 1; + super::WorkspaceMountOnboardingLaunchStrategy::InstructionsOnly + }, + ); + + assert_eq!(launch_count, 1); + assert_eq!( + requests, + vec![( + "notion".to_string(), + "~/Library/CloudStorage/Locality/notion-main".to_string(), + "notion-main".to_string(), + )] + ); + assert_eq!(report.state, "created"); + assert_eq!(report.message, "Mounted Notion at /Users/test/Library/CloudStorage/Locality/notion-main with macOS File Provider."); +} +``` + +Add a second test for denial/dismissal staying recoverable: + +```rust +#[test] +fn workspace_mount_onboarding_allow_action_stays_approval_required_when_domain_remains_disabled() { + let mut launch_count = 0usize; + + let report = super::run_workspace_mount_onboarding_with( + super::WorkspaceMountOnboardingRequest { + path: "~/Library/CloudStorage/Locality/notion-main".to_string(), + action: "allow_in_macos".to_string(), + }, + |_request| { + Err("Could not open macOS File Provider domain `loc`: The Locality File Provider is registered but not enabled. Click OK in the macOS Start Syncing prompt, then try again.".to_string()) + }, + || { + launch_count += 1; + super::WorkspaceMountOnboardingLaunchStrategy::InstructionsOnly + }, + ); + + assert_eq!(launch_count, 1); + let expected_state = if cfg!(target_os = "macos") { + "approval_required" + } else { + "failed" + }; + assert_eq!(report.state, expected_state); + if cfg!(target_os = "macos") { + assert_eq!(report.primary_action, "allow_in_macos"); + assert!(report.message.contains("Start Syncing")); + } +} +``` + +- [ ] **Step 2: Run backend tests and verify RED** + +Run: + +```bash +cargo test -p locality-desktop workspace_mount_onboarding_allow_action -- --nocapture +``` + +Expected before implementation: compile fails because `run_workspace_mount_onboarding_with` does not exist. + +- [ ] **Step 3: Extract the injectable backend runner** + +In `apps/desktop/src-tauri/src/main.rs`, replace the body of `run_workspace_mount_onboarding_blocking` with: + +```rust +fn run_workspace_mount_onboarding_blocking( + request: WorkspaceMountOnboardingRequest, +) -> WorkspaceMountOnboardingReport { + run_workspace_mount_onboarding_with( + request, + create_desktop_mount_blocking, + launch_macos_file_provider_approval_surface, + ) +} +``` + +Add this helper below it: + +```rust +fn run_workspace_mount_onboarding_with( + request: WorkspaceMountOnboardingRequest, + mut create_mount: CreateMount, + mut launch_approval: LaunchApproval, +) -> WorkspaceMountOnboardingReport +where + CreateMount: FnMut(CreateDesktopMountRequest) -> Result, + LaunchApproval: FnMut() -> WorkspaceMountOnboardingLaunchStrategy, +{ + let action = match WorkspaceMountOnboardingAction::parse(request.action.trim()) { + Ok(action) => action, + Err(message) => { + return workspace_mount_onboarding_report( + MacosWorkspaceMountOnboardingState::Failed, + message, + WorkspaceMountOnboardingPrimaryAction::RetrySetup, + WorkspaceMountOnboardingLaunchStrategy::None, + ); + } + }; + + if matches!(action, WorkspaceMountOnboardingAction::AllowInMacos) { + #[cfg(target_os = "macos")] + { + let _ = launch_approval(); + } + #[cfg(not(target_os = "macos"))] + { + let _ = launch_approval; + return workspace_mount_onboarding_report( + MacosWorkspaceMountOnboardingState::Failed, + "macOS File Provider approval is only available on macOS.", + WorkspaceMountOnboardingPrimaryAction::RetrySetup, + WorkspaceMountOnboardingLaunchStrategy::None, + ); + } + } + + match create_mount(CreateDesktopMountRequest { + connector: "notion".to_string(), + path: request.path, + mount_id: "notion-main".to_string(), + connection_id: None, + read_only: false, + notion_root_page: None, + google_docs_workspace_folder: None, + }) { + Ok(message) => workspace_mount_onboarding_report( + MacosWorkspaceMountOnboardingState::Created, + message, + WorkspaceMountOnboardingPrimaryAction::RetrySetup, + WorkspaceMountOnboardingLaunchStrategy::None, + ), + Err(message) => classify_workspace_mount_onboarding_failure(&message), + } +} +``` + +- [ ] **Step 4: Update backend curated approval message** + +In `workspace_mount_onboarding_curated_message`, replace the approval message with: + +```rust +Some("Click OK in the macOS \"Start Syncing\" prompt. Locality will continue once macOS enables the CloudStorage folder.") +``` + +Update `workspace_mount_onboarding_curated_message_matches_recoverable_state` to expect the same string. + +- [ ] **Step 5: Run backend tests and verify GREEN** + +Run: + +```bash +cargo test -p locality-desktop workspace_mount_onboarding -- --nocapture +``` + +Expected: all workspace mount onboarding tests pass. + +- [ ] **Step 6: Commit backend behavior** + +Run: + +```bash +git add apps/desktop/src-tauri/src/main.rs +git commit -m "fix: retry onboarding after native File Provider approval" +``` + +## Task 3: Disabled-Domain Helper And Error Copy + +**Files:** +- Modify: `apps/desktop/src/onboarding-errors.test.ts` +- Modify: `platform/macos/LocalityFileProvider/Sources/LocalityFileProviderCtl/main.swift` +- Modify: `crates/loc-cli/src/file_provider.rs` + +- [ ] **Step 1: Write failing copy expectations** + +In `apps/desktop/src/onboarding-errors.test.ts`, update the disabled-provider fixture: + +```ts +classifyMountSetupError( + 'Could not open macOS File Provider domain `loc`: The Locality File Provider is registered but not enabled. Click OK in the macOS "Start Syncing" prompt, then try again.', +) +``` + +In `crates/loc-cli/src/file_provider.rs`, update `macos_file_provider_unavailable_error_is_actionable` to assert native prompt copy: + +```rust +assert!(message.contains("make install-macos-file-provider")); +assert!(message.contains("Start Syncing")); +assert!(!message.contains("enable the File Provider")); +assert!(!message.contains("right now..")); +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: + +```bash +cd apps/desktop +npm test -- --run src/onboarding-errors.test.ts +``` + +Then run: + +```bash +cargo test -p loc-cli macos_file_provider_unavailable_error_is_actionable -- --nocapture +``` + +Expected before implementation: Rust helper-copy test fails because the message still says `enable the File Provider`. The TypeScript test may pass because classification still keys on `registered but not enabled`; keep it as fixture coverage. + +- [ ] **Step 3: Implement helper copy changes** + +In `platform/macos/LocalityFileProvider/Sources/LocalityFileProviderCtl/main.swift`, replace the disabled-domain open error with: + +```swift +throw UsageError( + "The Locality File Provider is registered but not enabled. Click OK in the macOS \"Start Syncing\" prompt, then try again." +) +``` + +Replace the missing CloudStorage-root error with: + +```swift +throw UsageError( + "File Provider domain \(mountId) exists but macOS has not created \(url.path). Click OK in the macOS \"Start Syncing\" prompt if it is still visible, then try again." +) +``` + +In `crates/loc-cli/src/file_provider.rs`, update `FileProviderHelperError::message()` for `macos_file_provider_application_unavailable`: + +```rust +format!( + "{message}. The Locality macOS File Provider app or extension is not available to macOS. For local development, run `make install-macos-file-provider`, then reopen Locality and click OK in the macOS \"Start Syncing\" prompt if macOS asks." +) +``` + +- [ ] **Step 4: Run tests and verify GREEN** + +Run: + +```bash +cd apps/desktop +npm test -- --run src/onboarding-errors.test.ts +cargo test -p loc-cli macos_file_provider_unavailable_error_is_actionable -- --nocapture +``` + +Expected: both focused tests pass. + +- [ ] **Step 5: Commit helper copy** + +Run: + +```bash +git add apps/desktop/src/onboarding-errors.test.ts platform/macos/LocalityFileProvider/Sources/LocalityFileProviderCtl/main.swift crates/loc-cli/src/file_provider.rs +git commit -m "fix: align File Provider disabled copy with native prompt" +``` + +## Task 4: Product Documentation And Focused Verification + +**Files:** +- Modify: `docs/desktop-app.md` + +- [ ] **Step 1: Update desktop product doc** + +In `docs/desktop-app.md`, update the macOS File Provider gate paragraph to say: + +```markdown +On macOS, this step is a blocked File Provider gate. If the Locality File +Provider is registered but not yet enabled, the onboarding screen stays on step +4, offers an `Allow in macOS` action, and relies on the native macOS +"Start Syncing" prompt as the approval surface. Clicking OK in that prompt means +the File Provider location is enabled; Locality should then retry setup and move +to folder verification rather than asking the user to click an additional Finder +or System Settings Enable control. If macOS has accepted approval but has not +yet materialized `~/Library/CloudStorage/Locality`, the screen remains blocked +with `Check again` until the folder exists and the mount root passes +verification. +``` + +- [ ] **Step 2: Run full focused verification** + +Run: + +```bash +cd apps/desktop +npm test -- --run src/onboarding-mount.test.ts src/onboarding-errors.test.ts +``` + +Run: + +```bash +cargo test -p locality-desktop workspace_mount_onboarding -- --nocapture +cargo test -p loc-cli macos_file_provider_unavailable_error_is_actionable -- --nocapture +``` + +Expected: all focused frontend and Rust tests pass. + +- [ ] **Step 3: Check working tree and commit docs** + +Run: + +```bash +git status --short +git add docs/desktop-app.md +git commit -m "docs: document native File Provider approval prompt" +``` + +- [ ] **Step 4: Final status** + +Run: + +```bash +git status --short +git log --oneline -5 +``` + +Expected: no uncommitted changes except intentionally ignored dependency folders; recent commits include the spec and implementation commits. From a9329cfe75b990152a8e65a9fb91e6aaf8cfae05 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Mon, 13 Jul 2026 21:04:10 -0700 Subject: [PATCH 3/7] fix: describe native File Provider approval prompt --- apps/desktop/src/onboarding-mount.test.ts | 24 +++++++++++++---------- apps/desktop/src/onboarding-mount.ts | 9 ++++----- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/onboarding-mount.test.ts b/apps/desktop/src/onboarding-mount.test.ts index 26daea6d..a7bc29bd 100644 --- a/apps/desktop/src/onboarding-mount.test.ts +++ b/apps/desktop/src/onboarding-mount.test.ts @@ -43,9 +43,12 @@ describe("mount onboarding helpers", () => { ); }); - it("switches to progress copy while the action is running", () => { + it("labels and describes the native macOS approval prompt", () => { expect(mountOnboardingPrimaryLabel(report({ primaryAction: "allow_in_macos" }), true)).toBe( - "Opening Finder", + "Opening macOS prompt", + ); + expect(mountOnboardingHeadline(report({ state: "approval_required" }))).toBe( + "Approve the macOS Start Syncing prompt.", ); }); @@ -70,19 +73,20 @@ describe("mount onboarding helpers", () => { ).toBe(false); }); - it("keeps the System Settings fallback in the approval instructions", () => { - expect( - mountOnboardingInstructions?.(report({ launchStrategy: "instructions_only" })) ?? null, - ).toBe( - "Open Finder, choose Locality under Locations, enable the File Provider, then return here " + - "and click Check again. If Finder does not show Locality, open System Settings, go to " + - "Privacy & Security, then enable Locality under Extensions or File Providers.", + it("explains that OK in the native prompt enables the File Provider location", () => { + const instructions = + mountOnboardingInstructions?.(report({ launchStrategy: "instructions_only" })) ?? null; + + expect(instructions).toBe( + `Click OK in the macOS "Start Syncing" prompt, then Locality will check the folder again. If you clicked Don't allow, choose Allow in macOS to try again.`, ); + expect(instructions).not.toContain("enable the File Provider"); + expect(instructions).not.toContain("System Settings"); }); it("maps backend states to the step 4 headline", () => { expect(mountOnboardingHeadline(report({ state: "approval_required" }))).toBe( - "Allow Locality in Finder.", + "Approve the macOS Start Syncing prompt.", ); expect(mountOnboardingHeadline(report({ state: "waiting_for_cloudstorage_root" }))).toBe( "Waiting for the Locality folder to appear.", diff --git a/apps/desktop/src/onboarding-mount.ts b/apps/desktop/src/onboarding-mount.ts index 47727694..9027f101 100644 --- a/apps/desktop/src/onboarding-mount.ts +++ b/apps/desktop/src/onboarding-mount.ts @@ -26,7 +26,7 @@ export function mountOnboardingPrimaryLabel( busy: boolean, ) { if (busy && report?.primaryAction === "allow_in_macos") { - return "Opening Finder"; + return "Opening macOS prompt"; } if (busy) { return "Checking setup"; @@ -48,7 +48,7 @@ export function mountOnboardingHeadline( ) { switch (report?.state) { case "approval_required": - return "Allow Locality in Finder."; + return "Approve the macOS Start Syncing prompt."; case "waiting_for_cloudstorage_root": return "Waiting for the Locality folder to appear."; case "failed": @@ -71,9 +71,8 @@ export function mountOnboardingInstructions( return null; } return ( - "Open Finder, choose Locality under Locations, enable the File Provider, then return here " + - "and click Check again. If Finder does not show Locality, open System Settings, go to " + - "Privacy & Security, then enable Locality under Extensions or File Providers." + 'Click OK in the macOS "Start Syncing" prompt, then Locality will check the folder again. ' + + "If you clicked Don't allow, choose Allow in macOS to try again." ); } From 2c27e65cdf3447d8da852b9f2498d53264c06b03 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Mon, 13 Jul 2026 21:05:42 -0700 Subject: [PATCH 4/7] fix: retry onboarding after native File Provider approval --- apps/desktop/src-tauri/src/main.rs | 106 +++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 4cef5fe6..b29d4316 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1211,6 +1211,22 @@ async fn create_desktop_mount_command( fn run_workspace_mount_onboarding_blocking( request: WorkspaceMountOnboardingRequest, ) -> WorkspaceMountOnboardingReport { + run_workspace_mount_onboarding_with( + request, + create_desktop_mount_blocking, + launch_macos_file_provider_approval_surface, + ) +} + +fn run_workspace_mount_onboarding_with( + request: WorkspaceMountOnboardingRequest, + mut create_mount: CreateMount, + mut launch_approval: LaunchApproval, +) -> WorkspaceMountOnboardingReport +where + CreateMount: FnMut(CreateDesktopMountRequest) -> Result, + LaunchApproval: FnMut() -> WorkspaceMountOnboardingLaunchStrategy, +{ let action = match WorkspaceMountOnboardingAction::parse(request.action.trim()) { Ok(action) => action, Err(message) => { @@ -1226,16 +1242,7 @@ fn run_workspace_mount_onboarding_blocking( if matches!(action, WorkspaceMountOnboardingAction::AllowInMacos) { #[cfg(target_os = "macos")] { - let launch_strategy = launch_macos_file_provider_approval_surface(); - return workspace_mount_onboarding_report( - MacosWorkspaceMountOnboardingState::ApprovalRequired, - workspace_mount_onboarding_curated_message( - MacosWorkspaceMountOnboardingState::ApprovalRequired, - ) - .expect("approval_required message"), - WorkspaceMountOnboardingPrimaryAction::CheckAgain, - launch_strategy, - ); + let _ = launch_approval(); } #[cfg(not(target_os = "macos"))] { @@ -1248,7 +1255,7 @@ fn run_workspace_mount_onboarding_blocking( } } - match create_desktop_mount_blocking(CreateDesktopMountRequest { + match create_mount(CreateDesktopMountRequest { connector: "notion".to_string(), path: request.path, mount_id: "notion-main".to_string(), @@ -9104,7 +9111,7 @@ fn workspace_mount_onboarding_curated_message( ) -> Option<&'static str> { match state { MacosWorkspaceMountOnboardingState::ApprovalRequired => { - Some("Enable Locality in Finder, then return here and click Check again.") + Some("Click OK in the macOS \"Start Syncing\" prompt. Locality will continue once macOS enables the CloudStorage folder.") } MacosWorkspaceMountOnboardingState::WaitingForCloudStorageRoot => { Some("Locality is still waiting for the CloudStorage folder to appear.") @@ -12477,13 +12484,86 @@ mod tests { assert_eq!(report.message, message); } + #[test] + fn workspace_mount_onboarding_allow_action_retries_mount_setup_after_native_prompt() { + let mut launch_count = 0usize; + let mut requests = Vec::new(); + + let report = super::run_workspace_mount_onboarding_with( + super::WorkspaceMountOnboardingRequest { + path: "~/Library/CloudStorage/Locality/notion-main".to_string(), + action: "allow_in_macos".to_string(), + }, + |request| { + requests.push(( + request.connector.clone(), + request.path.clone(), + request.mount_id.clone(), + )); + Ok("Mounted Notion at /Users/test/Library/CloudStorage/Locality/notion-main with macOS File Provider.".to_string()) + }, + || { + launch_count += 1; + super::WorkspaceMountOnboardingLaunchStrategy::InstructionsOnly + }, + ); + + assert_eq!(launch_count, 1); + assert_eq!( + requests, + vec![( + "notion".to_string(), + "~/Library/CloudStorage/Locality/notion-main".to_string(), + "notion-main".to_string(), + )] + ); + assert_eq!(report.state, "created"); + assert_eq!( + report.message, + "Mounted Notion at /Users/test/Library/CloudStorage/Locality/notion-main with macOS File Provider." + ); + } + + #[test] + fn workspace_mount_onboarding_allow_action_stays_approval_required_when_domain_remains_disabled( + ) { + let mut launch_count = 0usize; + + let report = super::run_workspace_mount_onboarding_with( + super::WorkspaceMountOnboardingRequest { + path: "~/Library/CloudStorage/Locality/notion-main".to_string(), + action: "allow_in_macos".to_string(), + }, + |_request| { + Err("Could not open macOS File Provider domain `loc`: The Locality File Provider is registered but not enabled. Click OK in the macOS Start Syncing prompt, then try again.".to_string()) + }, + || { + launch_count += 1; + super::WorkspaceMountOnboardingLaunchStrategy::InstructionsOnly + }, + ); + + let expected_state = if cfg!(target_os = "macos") { + assert_eq!(launch_count, 1); + "approval_required" + } else { + assert_eq!(launch_count, 0); + "failed" + }; + assert_eq!(report.state, expected_state); + if cfg!(target_os = "macos") { + assert_eq!(report.primary_action, "allow_in_macos"); + assert!(report.message.contains("Start Syncing")); + } + } + #[test] fn workspace_mount_onboarding_curated_message_matches_recoverable_state() { assert_eq!( super::workspace_mount_onboarding_curated_message( super::MacosWorkspaceMountOnboardingState::ApprovalRequired ), - Some("Enable Locality in Finder, then return here and click Check again.") + Some("Click OK in the macOS \"Start Syncing\" prompt. Locality will continue once macOS enables the CloudStorage folder.") ); assert_eq!( super::workspace_mount_onboarding_curated_message( From 780d8961bbf5f4b6f131584f98f86ea81a9b97b1 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Mon, 13 Jul 2026 21:07:21 -0700 Subject: [PATCH 5/7] fix: align File Provider disabled copy with native prompt --- apps/desktop/src-tauri/src/main.rs | 2 +- apps/desktop/src/onboarding-errors.test.ts | 2 +- crates/loc-cli/src/file_provider.rs | 5 +++-- .../Sources/LocalityFileProviderCtl/main.swift | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index b29d4316..602e4139 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -12471,7 +12471,7 @@ mod tests { #[test] fn workspace_mount_onboarding_preserves_application_unavailable_guidance() { - let message = "Could not register macOS File Provider: The application cannot be used right now. The Locality macOS File Provider app or extension is not available to macOS. For local development, run `make install-macos-file-provider`, then reopen Locality and enable the File Provider if macOS asks."; + let message = "Could not register macOS File Provider: The application cannot be used right now. The Locality macOS File Provider app or extension is not available to macOS. For local development, run `make install-macos-file-provider`, then reopen Locality and click OK in the macOS \"Start Syncing\" prompt if macOS asks."; assert!(super::recoverable_macos_file_provider_activation_error( message diff --git a/apps/desktop/src/onboarding-errors.test.ts b/apps/desktop/src/onboarding-errors.test.ts index b1d6bfc4..e94b3897 100644 --- a/apps/desktop/src/onboarding-errors.test.ts +++ b/apps/desktop/src/onboarding-errors.test.ts @@ -5,7 +5,7 @@ describe("classifyMountSetupError", () => { it("detects disabled macOS File Provider setup", () => { expect( classifyMountSetupError( - "Could not open macOS File Provider domain `loc`: The Locality File Provider is registered but not enabled. Enable Locality in Finder or System Settings, then try again.", + 'Could not open macOS File Provider domain `loc`: The Locality File Provider is registered but not enabled. Click OK in the macOS "Start Syncing" prompt, then try again.', ), ).toMatchObject({ kind: "file-provider-disabled" }); }); diff --git a/crates/loc-cli/src/file_provider.rs b/crates/loc-cli/src/file_provider.rs index e4baa6c3..b21300c7 100644 --- a/crates/loc-cli/src/file_provider.rs +++ b/crates/loc-cli/src/file_provider.rs @@ -88,7 +88,7 @@ impl FileProviderHelperError { Self::Failed(message) if macos_file_provider_application_unavailable(message) => { let message = message.trim_end_matches('.'); format!( - "{message}. The Locality macOS File Provider app or extension is not available to macOS. For local development, run `make install-macos-file-provider`, then reopen Locality and enable the File Provider if macOS asks." + "{message}. The Locality macOS File Provider app or extension is not available to macOS. For local development, run `make install-macos-file-provider`, then reopen Locality and click OK in the macOS \"Start Syncing\" prompt if macOS asks." ) } Self::Failed(message) => message.clone(), @@ -3029,7 +3029,8 @@ mod tests { .message(); assert!(message.contains("make install-macos-file-provider")); - assert!(message.contains("enable the File Provider")); + assert!(message.contains("Start Syncing")); + assert!(!message.contains("enable the File Provider")); assert!(!message.contains("right now..")); } diff --git a/platform/macos/LocalityFileProvider/Sources/LocalityFileProviderCtl/main.swift b/platform/macos/LocalityFileProvider/Sources/LocalityFileProviderCtl/main.swift index 5d49def3..efdc4350 100644 --- a/platform/macos/LocalityFileProvider/Sources/LocalityFileProviderCtl/main.swift +++ b/platform/macos/LocalityFileProvider/Sources/LocalityFileProviderCtl/main.swift @@ -137,13 +137,13 @@ private enum Command { } guard domain.userEnabled else { throw UsageError( - "The Locality File Provider is registered but not enabled. Enable Locality in Finder or System Settings, then try again." + "The Locality File Provider is registered but not enabled. Click OK in the macOS \"Start Syncing\" prompt, then try again." ) } let url = try userVisibleDomainURL(for: domain) guard FileManager.default.fileExists(atPath: url.path) else { throw UsageError( - "File Provider domain \(mountId) exists but macOS has not created \(url.path). Enable the Locality File Provider extension in System Settings, then try again." + "File Provider domain \(mountId) exists but macOS has not created \(url.path). Click OK in the macOS \"Start Syncing\" prompt if it is still visible, then try again." ) } return FileProviderCtlReport( From 2aaa10df946888ba28916c45008dd35502c6b219 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Mon, 13 Jul 2026 21:08:47 -0700 Subject: [PATCH 6/7] docs: document native File Provider approval prompt --- docs/desktop-app.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/desktop-app.md b/docs/desktop-app.md index dbafc51d..01a3897a 100644 --- a/docs/desktop-app.md +++ b/docs/desktop-app.md @@ -169,10 +169,14 @@ The app can track setup internally with human concepts: On macOS, this step is a blocked File Provider gate. If the Locality File Provider is registered but not yet enabled, the onboarding screen stays on step -4, offers an `Allow in macOS` action, and opens Finder at the Locality File -Provider root when possible. If macOS has accepted approval but has not yet -materialized `~/Library/CloudStorage/Locality`, the screen remains blocked with -`Check again` until the folder exists and the mount root passes verification. +4, offers an `Allow in macOS` action, and relies on the native macOS +"Start Syncing" prompt as the approval surface. Clicking OK in that prompt means +the File Provider location is enabled; Locality should then retry setup and move +to folder verification rather than asking the user to click an additional Finder +or System Settings Enable control. If macOS has accepted approval but has not +yet materialized `~/Library/CloudStorage/Locality`, the screen remains blocked +with `Check again` until the folder exists and the mount root passes +verification. The final ready screen must not appear until File Provider approval, the CloudStorage root, and the mount root are all verified successfully. From 4535c82d39c497524787cc8f9b8c0ee5545066e0 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Tue, 14 Jul 2026 13:21:27 -0700 Subject: [PATCH 7/7] fix: handle denied File Provider approval retries --- Makefile | 9 + apps/desktop/src-tauri/src/main.rs | 260 ++++++++++++- apps/desktop/src/onboarding-mount.test.ts | 13 + apps/desktop/src/onboarding-mount.ts | 12 +- scripts/install-macos-prompt-test-app.sh | 356 ++++++++++++++++++ scripts/install-macos-prompt-test-app.test.sh | 246 ++++++++++++ 6 files changed, 878 insertions(+), 18 deletions(-) create mode 100755 scripts/install-macos-prompt-test-app.sh create mode 100755 scripts/install-macos-prompt-test-app.test.sh diff --git a/Makefile b/Makefile index 84acf1a9..c97e86e2 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ endif LOC_DEBUG := target/debug/loc$(EXE_SUFFIX) LOC_RELEASE := target/release/loc$(EXE_SUFFIX) LOCALITY_FILE_PROVIDER_TARGET ?= notion-main +PROMPT_TEST_APP_ARGS ?= ifeq ($(OS),Windows_NT) CLEAN_START_PLAN_CMD := & '.\scripts\clean-start.ps1' @@ -148,6 +149,10 @@ prepare-macos-file-provider: ## Stage the macOS File Provider extension for Taur install-macos-file-provider: ## Install/register the local macOS File Provider development bundle. platform/macos/LocalityFileProvider/scripts/install-dev-bundle.sh +.PHONY: install-macos-prompt-test-app +install-macos-prompt-test-app: build-tauri ## Build, reset, install, register, and launch a fresh macOS File Provider prompt test app. + scripts/install-macos-prompt-test-app.sh $(PROMPT_TEST_APP_ARGS) + .PHONY: prepare-desktop-dev-sidecars prepare-desktop-dev-sidecars: ## Build debug desktop sidecars used by Tauri dev. $(DESKTOP_NPM) run dev:prepare @@ -204,6 +209,10 @@ test-linux-publish-config: ## Validate Linux package publish configuration. test-macos-publish-config: ## Validate macOS package publish configuration. tests/macos_publish_config.sh +.PHONY: test-macos-prompt-test-app-installer +test-macos-prompt-test-app-installer: ## Validate the macOS prompt test app installer dry-run plan. + bash scripts/install-macos-prompt-test-app.test.sh + .PHONY: test-windows-publish-config test-windows-publish-config: ## Validate Windows package publish configuration. tests/windows_publish_config.sh diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 602e4139..80e9cb47 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -367,6 +367,7 @@ impl WorkspaceMountOnboardingPrimaryAction { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum WorkspaceMountOnboardingLaunchStrategy { OpenFinder, + OpenSystemSettings, InstructionsOnly, None, } @@ -375,6 +376,7 @@ impl WorkspaceMountOnboardingLaunchStrategy { fn as_str(self) -> &'static str { match self { Self::OpenFinder => "open_finder", + Self::OpenSystemSettings => "open_system_settings", Self::InstructionsOnly => "instructions_only", Self::None => "none", } @@ -1213,19 +1215,34 @@ fn run_workspace_mount_onboarding_blocking( ) -> WorkspaceMountOnboardingReport { run_workspace_mount_onboarding_with( request, + prepare_macos_file_provider_approval_retry, create_desktop_mount_blocking, launch_macos_file_provider_approval_surface, + launch_macos_file_provider_settings, + macos_workspace_mount_domain_user_enabled, ) } -fn run_workspace_mount_onboarding_with( +fn run_workspace_mount_onboarding_with< + CreateMount, + PrepareApprovalRetry, + LaunchApproval, + LaunchSettings, + DomainUserEnabled, +>( request: WorkspaceMountOnboardingRequest, + mut prepare_approval_retry: PrepareApprovalRetry, mut create_mount: CreateMount, mut launch_approval: LaunchApproval, + mut launch_settings: LaunchSettings, + mut domain_user_enabled: DomainUserEnabled, ) -> WorkspaceMountOnboardingReport where CreateMount: FnMut(CreateDesktopMountRequest) -> Result, + PrepareApprovalRetry: FnMut() -> Result<(), String>, LaunchApproval: FnMut() -> WorkspaceMountOnboardingLaunchStrategy, + LaunchSettings: FnMut() -> WorkspaceMountOnboardingLaunchStrategy, + DomainUserEnabled: FnMut() -> Result, { let action = match WorkspaceMountOnboardingAction::parse(request.action.trim()) { Ok(action) => action, @@ -1242,6 +1259,14 @@ where if matches!(action, WorkspaceMountOnboardingAction::AllowInMacos) { #[cfg(target_os = "macos")] { + if let Err(message) = prepare_approval_retry() { + return workspace_mount_onboarding_report( + MacosWorkspaceMountOnboardingState::Failed, + message, + WorkspaceMountOnboardingPrimaryAction::RetrySetup, + WorkspaceMountOnboardingLaunchStrategy::None, + ); + } let _ = launch_approval(); } #[cfg(not(target_os = "macos"))] @@ -1270,8 +1295,59 @@ where WorkspaceMountOnboardingPrimaryAction::RetrySetup, WorkspaceMountOnboardingLaunchStrategy::None, ), - Err(message) => classify_workspace_mount_onboarding_failure(&message), + Err(message) => classify_workspace_mount_onboarding_failure_with_context( + &message, + !matches!(action, WorkspaceMountOnboardingAction::Start), + &mut domain_user_enabled, + &mut launch_settings, + ), + } +} + +#[cfg(target_os = "macos")] +fn prepare_macos_file_provider_approval_retry() -> Result<(), String> { + let report = + run_macos_file_provider_helper("list", Vec::new()).map_err(|error| error.message())?; + let domain = report + .helper_report + .get("domains") + .and_then(serde_json::Value::as_array) + .and_then(|domains| { + domains.iter().find(|domain| { + domain.get("identifier").and_then(serde_json::Value::as_str) + == Some(localityd::file_provider::MACOS_FILE_PROVIDER_DOMAIN_ID) + }) + }); + let Some(domain) = domain else { + return Ok(()); + }; + if domain + .get("userEnabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + return Ok(()); } + + run_macos_file_provider_helper( + "unregister", + vec![ + "--mount-id".to_string(), + localityd::file_provider::MACOS_FILE_PROVIDER_DOMAIN_ID.to_string(), + ], + ) + .map(|_| ()) + .map_err(|error| { + format!( + "Could not reset the denied macOS File Provider approval before retrying: {}", + error.message() + ) + }) +} + +#[cfg(not(target_os = "macos"))] +fn prepare_macos_file_provider_approval_retry() -> Result<(), String> { + Ok(()) } #[tauri::command] @@ -9110,9 +9186,9 @@ fn workspace_mount_onboarding_curated_message( state: MacosWorkspaceMountOnboardingState, ) -> Option<&'static str> { match state { - MacosWorkspaceMountOnboardingState::ApprovalRequired => { - Some("Click OK in the macOS \"Start Syncing\" prompt. Locality will continue once macOS enables the CloudStorage folder.") - } + MacosWorkspaceMountOnboardingState::ApprovalRequired => Some( + "Click OK in the macOS \"Start Syncing\" prompt. Locality will continue once macOS enables the CloudStorage folder.", + ), MacosWorkspaceMountOnboardingState::WaitingForCloudStorageRoot => { Some("Locality is still waiting for the CloudStorage folder to appear.") } @@ -9120,6 +9196,10 @@ fn workspace_mount_onboarding_curated_message( } } +fn workspace_mount_onboarding_settings_message() -> &'static str { + "macOS still has Locality turned off. In System Settings, open Login Items & Extensions > File Providers, turn on Locality, then click Check again." +} + fn workspace_mount_onboarding_should_refresh_surfaces( report: &WorkspaceMountOnboardingReport, ) -> bool { @@ -9151,12 +9231,32 @@ fn macos_workspace_mount_onboarding_state( None } -fn classify_workspace_mount_onboarding_failure(message: &str) -> WorkspaceMountOnboardingReport { +fn classify_workspace_mount_onboarding_failure_with_context( + message: &str, + explicit_approval_followup: bool, + mut domain_user_enabled: DomainUserEnabled, + mut launch_settings: LaunchSettings, +) -> WorkspaceMountOnboardingReport +where + DomainUserEnabled: FnMut() -> Result, + LaunchSettings: FnMut() -> WorkspaceMountOnboardingLaunchStrategy, +{ #[cfg(target_os = "macos")] { if recoverable_macos_file_provider_activation_error(message) { - let user_enabled = macos_workspace_mount_domain_user_enabled().unwrap_or(false); + let user_enabled = domain_user_enabled().unwrap_or(false); if let Some(state) = macos_workspace_mount_onboarding_state(message, user_enabled) { + if state == MacosWorkspaceMountOnboardingState::ApprovalRequired + && explicit_approval_followup + { + let launch_strategy = launch_settings(); + return workspace_mount_onboarding_report( + state, + workspace_mount_onboarding_settings_message(), + WorkspaceMountOnboardingPrimaryAction::CheckAgain, + launch_strategy, + ); + } let primary_action = match state { MacosWorkspaceMountOnboardingState::ApprovalRequired => { WorkspaceMountOnboardingPrimaryAction::AllowInMacos @@ -9235,6 +9335,33 @@ fn launch_macos_file_provider_approval_surface() -> WorkspaceMountOnboardingLaun WorkspaceMountOnboardingLaunchStrategy::None } +#[cfg(target_os = "macos")] +fn launch_macos_file_provider_settings() -> WorkspaceMountOnboardingLaunchStrategy { + for target in [ + "x-apple.systempreferences:com.apple.LoginItems-Settings.extension", + "x-apple.systempreferences:com.apple.ExtensionsPreferences", + "/System/Library/PreferencePanes/Extensions.prefPane", + ] { + if Command::new("open").arg(target).spawn().is_ok() { + return WorkspaceMountOnboardingLaunchStrategy::OpenSystemSettings; + } + } + if Command::new("open") + .arg("-b") + .arg("com.apple.systempreferences") + .spawn() + .is_ok() + { + return WorkspaceMountOnboardingLaunchStrategy::OpenSystemSettings; + } + WorkspaceMountOnboardingLaunchStrategy::InstructionsOnly +} + +#[cfg(not(target_os = "macos"))] +fn launch_macos_file_provider_settings() -> WorkspaceMountOnboardingLaunchStrategy { + WorkspaceMountOnboardingLaunchStrategy::None +} + fn connection_metadata_key( connection: &ConnectionRecord, ) -> (&str, Option<&str>, Option<&str>, Option<&str>) { @@ -12477,7 +12604,12 @@ mod tests { message )); - let report = super::classify_workspace_mount_onboarding_failure(message); + let report = super::classify_workspace_mount_onboarding_failure_with_context( + message, + false, + || Ok(false), + || super::WorkspaceMountOnboardingLaunchStrategy::InstructionsOnly, + ); assert_eq!(report.state, "failed"); assert_eq!(report.primary_action, "retry_setup"); assert_eq!(report.launch_strategy, "none"); @@ -12486,16 +12618,24 @@ mod tests { #[test] fn workspace_mount_onboarding_allow_action_retries_mount_setup_after_native_prompt() { - let mut launch_count = 0usize; - let mut requests = Vec::new(); + let launch_count = std::cell::Cell::new(0usize); + let retry_cleanup_count = std::cell::Cell::new(0usize); + let requests = std::cell::RefCell::new(Vec::new()); + let events = std::cell::RefCell::new(Vec::new()); let report = super::run_workspace_mount_onboarding_with( super::WorkspaceMountOnboardingRequest { path: "~/Library/CloudStorage/Locality/notion-main".to_string(), action: "allow_in_macos".to_string(), }, + || { + retry_cleanup_count.set(retry_cleanup_count.get() + 1); + events.borrow_mut().push("cleanup"); + Ok(()) + }, |request| { - requests.push(( + events.borrow_mut().push("create"); + requests.borrow_mut().push(( request.connector.clone(), request.path.clone(), request.mount_id.clone(), @@ -12503,14 +12643,19 @@ mod tests { Ok("Mounted Notion at /Users/test/Library/CloudStorage/Locality/notion-main with macOS File Provider.".to_string()) }, || { - launch_count += 1; + launch_count.set(launch_count.get() + 1); + events.borrow_mut().push("launch"); super::WorkspaceMountOnboardingLaunchStrategy::InstructionsOnly }, + || super::WorkspaceMountOnboardingLaunchStrategy::OpenSystemSettings, + || Ok(true), ); - assert_eq!(launch_count, 1); + assert_eq!(retry_cleanup_count.get(), 1); + assert_eq!(launch_count.get(), 1); + assert_eq!(events.into_inner(), vec!["cleanup", "launch", "create"]); assert_eq!( - requests, + requests.into_inner(), vec![( "notion".to_string(), "~/Library/CloudStorage/Locality/notion-main".to_string(), @@ -12525,15 +12670,20 @@ mod tests { } #[test] - fn workspace_mount_onboarding_allow_action_stays_approval_required_when_domain_remains_disabled( - ) { + fn workspace_mount_onboarding_allow_action_prompts_settings_after_denial() { let mut launch_count = 0usize; + let mut settings_launch_count = 0usize; + let mut retry_cleanup_count = 0usize; let report = super::run_workspace_mount_onboarding_with( super::WorkspaceMountOnboardingRequest { path: "~/Library/CloudStorage/Locality/notion-main".to_string(), action: "allow_in_macos".to_string(), }, + || { + retry_cleanup_count += 1; + Ok(()) + }, |_request| { Err("Could not open macOS File Provider domain `loc`: The Locality File Provider is registered but not enabled. Click OK in the macOS Start Syncing prompt, then try again.".to_string()) }, @@ -12541,19 +12691,93 @@ mod tests { launch_count += 1; super::WorkspaceMountOnboardingLaunchStrategy::InstructionsOnly }, + || { + settings_launch_count += 1; + super::WorkspaceMountOnboardingLaunchStrategy::OpenSystemSettings + }, + || Ok(false), ); let expected_state = if cfg!(target_os = "macos") { + assert_eq!(retry_cleanup_count, 1); assert_eq!(launch_count, 1); + assert_eq!(settings_launch_count, 1); "approval_required" } else { + assert_eq!(retry_cleanup_count, 0); assert_eq!(launch_count, 0); + assert_eq!(settings_launch_count, 0); "failed" }; assert_eq!(report.state, expected_state); if cfg!(target_os = "macos") { + assert_eq!(report.primary_action, "check_again"); + assert_eq!(report.launch_strategy, "open_system_settings"); + assert!(report.message.contains("System Settings")); + } + } + + #[test] + fn workspace_mount_onboarding_start_keeps_native_prompt_guidance_before_user_retry() { + let mut settings_launch_count = 0usize; + + let report = super::run_workspace_mount_onboarding_with( + super::WorkspaceMountOnboardingRequest { + path: "~/Library/CloudStorage/Locality/notion-main".to_string(), + action: "start".to_string(), + }, + || Ok(()), + |_request| { + Err("Could not open macOS File Provider domain `loc`: The Locality File Provider is registered but not enabled. Click OK in the macOS Start Syncing prompt, then try again.".to_string()) + }, + || super::WorkspaceMountOnboardingLaunchStrategy::InstructionsOnly, + || { + settings_launch_count += 1; + super::WorkspaceMountOnboardingLaunchStrategy::OpenSystemSettings + }, + || Ok(false), + ); + + if cfg!(target_os = "macos") { + assert_eq!(report.state, "approval_required"); assert_eq!(report.primary_action, "allow_in_macos"); + assert_eq!(report.launch_strategy, "instructions_only"); assert!(report.message.contains("Start Syncing")); + assert_eq!(settings_launch_count, 0); + } else { + assert_eq!(report.state, "failed"); + } + } + + #[test] + fn workspace_mount_onboarding_retry_opens_settings_when_domain_remains_disabled() { + let mut settings_launch_count = 0usize; + + let report = super::run_workspace_mount_onboarding_with( + super::WorkspaceMountOnboardingRequest { + path: "~/Library/CloudStorage/Locality/notion-main".to_string(), + action: "allow_in_macos".to_string(), + }, + || Ok(()), + |_request| { + Err("Could not open macOS File Provider domain `loc`: The Locality File Provider is registered but not enabled. Click OK in the macOS Start Syncing prompt, then try again.".to_string()) + }, + || super::WorkspaceMountOnboardingLaunchStrategy::InstructionsOnly, + || { + settings_launch_count += 1; + super::WorkspaceMountOnboardingLaunchStrategy::OpenSystemSettings + }, + || Ok(false), + ); + + if cfg!(target_os = "macos") { + assert_eq!(report.state, "approval_required"); + assert_eq!(report.primary_action, "check_again"); + assert_eq!(report.launch_strategy, "open_system_settings"); + assert!(report.message.contains("System Settings")); + assert_eq!(settings_launch_count, 1); + } else { + assert_eq!(report.state, "failed"); } } @@ -12563,7 +12787,9 @@ mod tests { super::workspace_mount_onboarding_curated_message( super::MacosWorkspaceMountOnboardingState::ApprovalRequired ), - Some("Click OK in the macOS \"Start Syncing\" prompt. Locality will continue once macOS enables the CloudStorage folder.") + Some( + "Click OK in the macOS \"Start Syncing\" prompt. Locality will continue once macOS enables the CloudStorage folder." + ) ); assert_eq!( super::workspace_mount_onboarding_curated_message( diff --git a/apps/desktop/src/onboarding-mount.test.ts b/apps/desktop/src/onboarding-mount.test.ts index a7bc29bd..d16020a3 100644 --- a/apps/desktop/src/onboarding-mount.test.ts +++ b/apps/desktop/src/onboarding-mount.test.ts @@ -61,6 +61,19 @@ describe("mount onboarding helpers", () => { ); }); + it("explains the remembered macOS denial settings path", () => { + const deniedReport = report({ + primaryAction: "check_again", + launchStrategy: "open_system_settings", + }); + + expect(mountOnboardingHeadline(deniedReport)).toBe( + "Turn on Locality in macOS Settings.", + ); + expect(mountOnboardingNeedsInstructions(deniedReport)).toBe(false); + expect(mountOnboardingInstructions?.(deniedReport)).toBeNull(); + }); + it("does not show approval instructions while waiting for the CloudStorage root", () => { expect( mountOnboardingNeedsInstructions( diff --git a/apps/desktop/src/onboarding-mount.ts b/apps/desktop/src/onboarding-mount.ts index 9027f101..8999dd50 100644 --- a/apps/desktop/src/onboarding-mount.ts +++ b/apps/desktop/src/onboarding-mount.ts @@ -11,6 +11,7 @@ export type WorkspaceMountOnboardingPrimaryAction = export type WorkspaceMountOnboardingLaunchStrategy = | "open_finder" + | "open_system_settings" | "instructions_only" | "none"; @@ -46,6 +47,9 @@ export function mountOnboardingPrimaryLabel( export function mountOnboardingHeadline( report: WorkspaceMountOnboardingReport | null, ) { + if (report?.state === "approval_required" && report.launchStrategy === "open_system_settings") { + return "Turn on Locality in macOS Settings."; + } switch (report?.state) { case "approval_required": return "Approve the macOS Start Syncing prompt."; @@ -67,7 +71,13 @@ export function mountOnboardingNeedsInstructions( export function mountOnboardingInstructions( report: WorkspaceMountOnboardingReport | null, ) { - if (report?.state !== "approval_required" || report.launchStrategy !== "instructions_only") { + if (report?.state !== "approval_required") { + return null; + } + if (report.launchStrategy === "open_system_settings") { + return null; + } + if (report.launchStrategy !== "instructions_only") { return null; } return ( diff --git a/scripts/install-macos-prompt-test-app.sh b/scripts/install-macos-prompt-test-app.sh new file mode 100755 index 00000000..5d1ccb60 --- /dev/null +++ b/scripts/install-macos-prompt-test-app.sh @@ -0,0 +1,356 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +DEFAULT_APP_PATH="/Applications/Locality Prompt Test.app" +DEFAULT_SIGNING_IDENTITY="Developer ID Application: CodeFlash Inc (C484HB7Q6S)" +DEFAULT_DISPLAY_NAME="Locality Prompt Test" +DEFAULT_DMG_DIR="${ROOT}/target/release/bundle/dmg" + +APP_PATH="${LOCALITY_PROMPT_TEST_APP_PATH:-${DEFAULT_APP_PATH}}" +SOURCE_APP="${LOCALITY_PROMPT_TEST_SOURCE_APP:-}" +DMG="${LOCALITY_PROMPT_TEST_DMG:-}" +DISPLAY_NAME="${LOCALITY_PROMPT_TEST_DISPLAY_NAME:-${DEFAULT_DISPLAY_NAME}}" +SIGNING_IDENTITY="${LOCALITY_PROMPT_TEST_SIGNING_IDENTITY:-${SIGNING_IDENTITY:-${DEFAULT_SIGNING_IDENTITY}}}" +BUNDLE_ID="${LOCALITY_PROMPT_TEST_BUNDLE_ID:-}" +DRY_RUN=0 +RESET_DOMAIN="${LOCALITY_PROMPT_TEST_RESET:-1}" +LAUNCH="${LOCALITY_PROMPT_TEST_LAUNCH:-1}" +FORCE_NON_TEST_APP_PATH="${LOCALITY_PROMPT_TEST_FORCE_NON_TEST_APP_PATH:-0}" + +HOST_ENTITLEMENTS="${ROOT}/platform/macos/LocalityFileProvider/App/LocalityDeveloperId.entitlements" +FILE_PROVIDER_ENTITLEMENTS="${ROOT}/platform/macos/LocalityFileProvider/App/LocalityFileProvider.entitlements" +MOUNT_POINT="" +TMPDIR="" + +usage() { + cat <<'USAGE' +Usage: scripts/install-macos-prompt-test-app.sh [options] + +Build target helper for manual macOS File Provider onboarding tests. Installs a +freshly identified "Locality Prompt Test.app" from a built Locality.app bundle +or DMG, resets any existing test app File Provider domain, re-signs the bundle, +registers its File Provider extension, and launches it. + +Options: + --app-path PATH Target app path. Defaults to /Applications/Locality Prompt Test.app. + --source-app PATH Source Locality.app bundle. Defaults to the built Tauri bundle or DMG. + --dmg PATH Source DMG when --source-app is not passed. Defaults to newest built Locality_*.dmg. + --bundle-id ID App bundle identifier. Defaults to ai.codeflash.locality.promptfresh. + --display-name NAME App and File Provider display name. Defaults to "Locality Prompt Test". + --signing-identity NAME Developer ID signing identity. + --reset-domain Reset the existing test app File Provider domain before reinstalling. Default. + --no-reset-domain Do not reset the existing test app File Provider domain. + --launch Launch the installed test app. Default. + --no-launch Do not launch the installed test app. + --force-non-test-app-path + Allow a target app path that does not look like a prompt-test app. + --dry-run Print the install plan without changing the system. + -h, --help Show this help. + +Environment aliases: + LOCALITY_PROMPT_TEST_APP_PATH + LOCALITY_PROMPT_TEST_SOURCE_APP + LOCALITY_PROMPT_TEST_DMG + LOCALITY_PROMPT_TEST_BUNDLE_ID + LOCALITY_PROMPT_TEST_TIMESTAMP + LOCALITY_PROMPT_TEST_DISPLAY_NAME + LOCALITY_PROMPT_TEST_SIGNING_IDENTITY + LOCALITY_PROMPT_TEST_RESET=0|1 + LOCALITY_PROMPT_TEST_LAUNCH=0|1 + LOCALITY_PROMPT_TEST_FORCE_NON_TEST_APP_PATH=0|1 +USAGE +} + +log() { + printf '%s\n' "$*" +} + +fail() { + printf 'install-macos-prompt-test-app: %s\n' "$*" >&2 + exit 1 +} + +print_cmd() { + printf '+' + local arg + for arg in "$@"; do + printf ' %q' "${arg}" + done + printf '\n' +} + +run() { + print_cmd "$@" + if [[ "${DRY_RUN}" -eq 0 ]]; then + "$@" + fi +} + +run_may_fail() { + print_cmd "$@" + if [[ "${DRY_RUN}" -eq 0 ]]; then + "$@" >/dev/null 2>&1 || true + fi +} + +prompt_test_timestamp() { + if [[ -n "${LOCALITY_PROMPT_TEST_TIMESTAMP:-}" ]]; then + printf '%s\n' "${LOCALITY_PROMPT_TEST_TIMESTAMP}" + return 0 + fi + date +%Y%m%d%H%M%S +} + +default_bundle_id() { + printf 'ai.codeflash.locality.promptfresh%s\n' "$(prompt_test_timestamp)" +} + +default_dmg() { + local candidate + local latest="" + while IFS= read -r -d '' candidate; do + if [[ -z "${latest}" || "${candidate}" -nt "${latest}" ]]; then + latest="${candidate}" + fi + done < <(find "${DEFAULT_DMG_DIR}" -maxdepth 1 -type f -name 'Locality_*.dmg' -print0 2>/dev/null) + + [[ -n "${latest}" ]] || return 1 + printf '%s\n' "${latest}" +} + +expand_tilde() { + local path="$1" + if [[ "${path}" == "~" ]]; then + printf '%s\n' "${HOME}" + elif [[ "${path}" == "~/"* ]]; then + printf '%s/%s\n' "${HOME}" "${path#"~/"}" + else + printf '%s\n' "${path}" + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --app-path) + APP_PATH="${2:?--app-path requires a value}" + shift 2 + ;; + --source-app) + SOURCE_APP="${2:?--source-app requires a value}" + shift 2 + ;; + --dmg) + DMG="${2:?--dmg requires a value}" + shift 2 + ;; + --bundle-id) + BUNDLE_ID="${2:?--bundle-id requires a value}" + shift 2 + ;; + --display-name) + DISPLAY_NAME="${2:?--display-name requires a value}" + shift 2 + ;; + --signing-identity) + SIGNING_IDENTITY="${2:?--signing-identity requires a value}" + shift 2 + ;; + --reset-domain) + RESET_DOMAIN=1 + shift + ;; + --no-reset-domain) + RESET_DOMAIN=0 + shift + ;; + --launch) + LAUNCH=1 + shift + ;; + --no-launch) + LAUNCH=0 + shift + ;; + --force-non-test-app-path) + FORCE_NON_TEST_APP_PATH=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac + done +} + +cleanup() { + if [[ -n "${MOUNT_POINT}" && -d "${MOUNT_POINT}" && "${DRY_RUN}" -eq 0 ]]; then + hdiutil detach "${MOUNT_POINT}" -quiet >/dev/null 2>&1 \ + || hdiutil detach "${MOUNT_POINT}" -force -quiet >/dev/null 2>&1 \ + || true + fi + if [[ -n "${TMPDIR}" && -d "${TMPDIR}" ]]; then + rm -rf "${TMPDIR}" >/dev/null 2>&1 || true + fi +} + +require_execute_environment() { + [[ "${DRY_RUN}" -eq 1 ]] && return 0 + [[ "$(uname -s)" == "Darwin" ]] || fail "this installer only runs on macOS" + command -v hdiutil >/dev/null 2>&1 || fail "hdiutil is required" + command -v codesign >/dev/null 2>&1 || fail "codesign is required" + command -v pluginkit >/dev/null 2>&1 || fail "pluginkit is required" + [[ -f "${HOST_ENTITLEMENTS}" ]] || fail "missing host entitlements: ${HOST_ENTITLEMENTS}" + [[ -f "${FILE_PROVIDER_ENTITLEMENTS}" ]] || fail "missing File Provider entitlements: ${FILE_PROVIDER_ENTITLEMENTS}" +} + +validate_app_path() { + [[ -n "${APP_PATH}" ]] || fail "--app-path must not be empty" + [[ "${APP_PATH}" == *.app ]] || fail "--app-path must point to a .app bundle: ${APP_PATH}" + [[ "${APP_PATH}" != "/" ]] || fail "--app-path cannot be /" + + if [[ "${FORCE_NON_TEST_APP_PATH}" != "1" ]]; then + local app_name + app_name="$(basename "${APP_PATH}")" + [[ "${app_name}" != "Locality.app" ]] \ + || fail "--app-path points at the production app. Use a prompt-test app path or pass --force-non-test-app-path." + [[ "${app_name}" == *Prompt* || "${app_name}" == *prompt* || "${app_name}" == *Test* || "${app_name}" == *test* ]] \ + || fail "--app-path must look like a prompt-test app path: ${APP_PATH}. Pass --force-non-test-app-path to override." + fi +} + +validate_source_and_target_paths() { + [[ "${SOURCE_APP}" != "${APP_PATH}" ]] \ + || fail "--source-app and --app-path must be different paths" + + if [[ -e "${SOURCE_APP}" && -e "${APP_PATH}" ]]; then + local source_real target_real + source_real="$(cd "$(dirname "${SOURCE_APP}")" && pwd -P)/$(basename "${SOURCE_APP}")" + target_real="$(cd "$(dirname "${APP_PATH}")" && pwd -P)/$(basename "${APP_PATH}")" + [[ "${source_real}" != "${target_real}" ]] \ + || fail "--source-app and --app-path resolve to the same path" + fi +} + +resolve_source_app() { + SOURCE_APP="$(expand_tilde "${SOURCE_APP}")" + DMG="$(expand_tilde "${DMG}")" + APP_PATH="$(expand_tilde "${APP_PATH}")" + validate_app_path + + if [[ -n "${SOURCE_APP}" ]]; then + [[ -d "${SOURCE_APP}" ]] || fail "source app does not exist: ${SOURCE_APP}" + validate_source_and_target_paths + return 0 + fi + + local bundle_app="${ROOT}/target/release/bundle/macos/Locality.app" + if [[ -d "${bundle_app}" ]]; then + SOURCE_APP="${bundle_app}" + validate_source_and_target_paths + return 0 + fi + + if [[ -z "${DMG}" ]]; then + DMG="$(default_dmg)" || fail "missing DMG under ${DEFAULT_DMG_DIR}. Run make build-tauri first or pass --dmg PATH." + DMG="$(expand_tilde "${DMG}")" + fi + [[ -f "${DMG}" ]] || fail "missing DMG: ${DMG}. Run make build-tauri first or pass --dmg PATH." + + TMPDIR="$(mktemp -d)" + MOUNT_POINT="${TMPDIR}/mount" + mkdir -p "${MOUNT_POINT}" + run hdiutil attach "${DMG}" -readonly -noverify -noautoopen -mountpoint "${MOUNT_POINT}" -quiet + SOURCE_APP="${MOUNT_POINT}/Locality.app" + if [[ "${DRY_RUN}" -eq 1 ]]; then + validate_source_and_target_paths + return 0 + fi + [[ -d "${SOURCE_APP}" ]] || fail "DMG did not contain Locality.app: ${DMG}" + validate_source_and_target_paths +} + +reset_existing_test_app() { + local old_helper="${APP_PATH}/Contents/MacOS/locality-file-providerctl" + local old_appex="${APP_PATH}/Contents/PlugIns/LocalityFileProvider.appex" + + run_may_fail osascript -e "tell application id \"${BUNDLE_ID}\" to quit" + run_may_fail pkill -f "${APP_PATH}/Contents" + + if [[ "${RESET_DOMAIN}" == "1" && -x "${old_helper}" ]]; then + run_may_fail "${old_helper}" reset --json + fi + if [[ -d "${old_appex}" ]]; then + run_may_fail pluginkit -r "${old_appex}" + fi +} + +install_bundle() { + local appex="${APP_PATH}/Contents/PlugIns/LocalityFileProvider.appex" + local extension_bundle_id="${BUNDLE_ID}.FileProvider" + + run mkdir -p "$(dirname "${APP_PATH}")" + run rm -rf "${APP_PATH}" + run ditto "${SOURCE_APP}" "${APP_PATH}" + + run /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${BUNDLE_ID}" "${APP_PATH}/Contents/Info.plist" + run /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName ${DISPLAY_NAME}" "${APP_PATH}/Contents/Info.plist" + run /usr/libexec/PlistBuddy -c "Set :CFBundleName ${DISPLAY_NAME}" "${APP_PATH}/Contents/Info.plist" + run /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${extension_bundle_id}" "${appex}/Contents/Info.plist" + run /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName ${DISPLAY_NAME}" "${appex}/Contents/Info.plist" + run /usr/libexec/PlistBuddy -c "Set :CFBundleName LocalityPromptTestFileProvider" "${appex}/Contents/Info.plist" + + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${HOST_ENTITLEMENTS}" --identifier "${BUNDLE_ID}.locality-desktop" "${APP_PATH}/Contents/MacOS/locality-desktop" + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${HOST_ENTITLEMENTS}" --identifier "${BUNDLE_ID}.loc" "${APP_PATH}/Contents/MacOS/loc" + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${HOST_ENTITLEMENTS}" --identifier "${BUNDLE_ID}.localityd" "${APP_PATH}/Contents/MacOS/localityd" + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${HOST_ENTITLEMENTS}" --identifier "${BUNDLE_ID}.file-providerctl" "${APP_PATH}/Contents/MacOS/locality-file-providerctl" + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${FILE_PROVIDER_ENTITLEMENTS}" --identifier "${extension_bundle_id}.binary" "${appex}/Contents/MacOS/LocalityFileProvider" + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${FILE_PROVIDER_ENTITLEMENTS}" --identifier "${extension_bundle_id}" "${appex}" + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${HOST_ENTITLEMENTS}" --identifier "${BUNDLE_ID}" "${APP_PATH}" + + run_may_fail xattr -dr com.apple.quarantine "${APP_PATH}" + run pluginkit -a "${appex}" + run codesign --verify --deep --strict --verbose=2 "${APP_PATH}" + run pluginkit -m -v -i "${extension_bundle_id}" + run_may_fail "${APP_PATH}/Contents/MacOS/locality-file-providerctl" --json list + + if [[ "${LAUNCH}" == "1" ]]; then + run open -a "${APP_PATH}" + fi +} + +main() { + parse_args "$@" + if [[ -z "${BUNDLE_ID}" ]]; then + BUNDLE_ID="$(default_bundle_id)" + fi + + require_execute_environment + trap cleanup EXIT + resolve_source_app + + log "source app: ${SOURCE_APP}" + log "target app: ${APP_PATH}" + log "bundle id: ${BUNDLE_ID}" + log "extension bundle id: ${BUNDLE_ID}.FileProvider" + log "display name: ${DISPLAY_NAME}" + log "signing identity: ${SIGNING_IDENTITY}" + log "reset existing domain: ${RESET_DOMAIN}" + log "launch after install: ${LAUNCH}" + log "state isolation: prompt-fresh only; this test app still uses the normal Locality state and app-group entitlements." + + reset_existing_test_app + install_bundle +} + +main "$@" diff --git a/scripts/install-macos-prompt-test-app.test.sh b/scripts/install-macos-prompt-test-app.test.sh new file mode 100755 index 00000000..a57de2b4 --- /dev/null +++ b/scripts/install-macos-prompt-test-app.test.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${ROOT}/scripts/install-macos-prompt-test-app.sh" + +fail() { + printf 'install-macos-prompt-test-app test failed: %s\n' "$*" >&2 + exit 1 +} + +assert_contains() { + local haystack="$1" + local needle="$2" + grep -Fq -- "${needle}" <<<"${haystack}" || fail "missing: ${needle}" +} + +assert_not_contains() { + local haystack="$1" + local needle="$2" + ! grep -Fq -- "${needle}" <<<"${haystack}" || fail "unexpected: ${needle}" +} + +make_source_app() { + local app="$1" + mkdir -p \ + "${app}/Contents/MacOS" \ + "${app}/Contents/PlugIns/LocalityFileProvider.appex/Contents/MacOS" + touch \ + "${app}/Contents/MacOS/locality-desktop" \ + "${app}/Contents/MacOS/loc" \ + "${app}/Contents/MacOS/localityd" \ + "${app}/Contents/MacOS/locality-file-providerctl" \ + "${app}/Contents/PlugIns/LocalityFileProvider.appex/Contents/MacOS/LocalityFileProvider" + chmod +x \ + "${app}/Contents/MacOS/locality-desktop" \ + "${app}/Contents/MacOS/loc" \ + "${app}/Contents/MacOS/localityd" \ + "${app}/Contents/MacOS/locality-file-providerctl" \ + "${app}/Contents/PlugIns/LocalityFileProvider.appex/Contents/MacOS/LocalityFileProvider" + cat >"${app}/Contents/Info.plist" <<'PLIST' + + +CFBundleExecutablelocality-desktop +PLIST + cat >"${app}/Contents/PlugIns/LocalityFileProvider.appex/Contents/Info.plist" <<'PLIST' + + +CFBundleExecutableLocalityFileProvider +PLIST +} + +test_dry_run_plans_fresh_prompt_test_app_installation() { + local tmp source app output + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + app="${tmp}/Applications/Locality Prompt Test.app" + make_source_app "${source}" + + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130000 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${app}" \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch + )" + + assert_contains "${output}" "bundle id: ai.codeflash.locality.promptfresh20260714130000" + assert_contains "${output}" "extension bundle id: ai.codeflash.locality.promptfresh20260714130000.FileProvider" + assert_contains "${output}" "state isolation: prompt-fresh only" + assert_contains "${output}" "ditto" + assert_contains "${output}" "${source}" + assert_contains "${output}" "${app}" + assert_contains "${output}" "CFBundleIdentifier\\ ai.codeflash.locality.promptfresh20260714130000" + assert_contains "${output}" "CFBundleIdentifier\\ ai.codeflash.locality.promptfresh20260714130000.FileProvider" + assert_contains "${output}" "LocalityDeveloperId.entitlements" + assert_contains "${output}" "locality-file-providerctl" + assert_contains "${output}" "locality-desktop" + assert_contains "${output}" "LocalityFileProvider.entitlements" + assert_contains "${output}" "LocalityFileProvider.appex" + assert_contains "${output}" "pluginkit -a" + assert_not_contains "${output}" "open -a" +} + +test_dry_run_resets_existing_test_app_domain_by_default() { + local tmp source app output + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + app="${tmp}/Applications/Locality Prompt Test.app" + make_source_app "${source}" + make_source_app "${app}" + + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130001 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${app}" \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch + )" + + assert_contains "${output}" "locality-file-providerctl reset --json" + assert_contains "${output}" "pluginkit -r" +} + +test_dry_run_can_skip_existing_test_app_domain_reset() { + local tmp source app output + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + app="${tmp}/Applications/Locality Prompt Test.app" + make_source_app "${source}" + make_source_app "${app}" + + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130002 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${app}" \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-reset-domain \ + --no-launch + )" + + assert_not_contains "${output}" "locality-file-providerctl reset --json" + assert_contains "${output}" "pluginkit -r" +} + +test_dry_run_rejects_non_app_target_path() { + local tmp source app output status + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + app="${tmp}/Applications/Locality Prompt Test" + make_source_app "${source}" + + set +e + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130003 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${app}" \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch 2>&1 + )" + status=$? + set -e + + [[ "${status}" -ne 0 ]] || fail "invalid app path unexpectedly succeeded" + assert_contains "${output}" "--app-path must point to a .app bundle" +} + +test_dry_run_rejects_production_target_path_by_default() { + local tmp source app output status + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + app="${tmp}/Applications/Locality.app" + make_source_app "${source}" + + set +e + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130004 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${app}" \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch 2>&1 + )" + status=$? + set -e + + [[ "${status}" -ne 0 ]] || fail "production app path unexpectedly succeeded" + assert_contains "${output}" "--app-path points at the production app" + assert_not_contains "${output}" "rm -rf" +} + +test_dry_run_force_allows_non_test_target_path() { + local tmp source app output + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + app="${tmp}/Applications/Locality.app" + make_source_app "${source}" + + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130005 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${app}" \ + --force-non-test-app-path \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch + )" + + assert_contains "${output}" "target app: ${app}" + assert_contains "${output}" "rm -rf" +} + +test_dry_run_rejects_source_app_as_target() { + local tmp source output status + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + make_source_app "${source}" + + set +e + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130006 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${source}" \ + --force-non-test-app-path \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch 2>&1 + )" + status=$? + set -e + + [[ "${status}" -ne 0 ]] || fail "source app as target unexpectedly succeeded" + assert_contains "${output}" "--source-app and --app-path must be different paths" + assert_not_contains "${output}" "rm -rf" +} + +main() { + test_dry_run_plans_fresh_prompt_test_app_installation + test_dry_run_resets_existing_test_app_domain_by_default + test_dry_run_can_skip_existing_test_app_domain_reset + test_dry_run_rejects_non_app_target_path + test_dry_run_rejects_production_target_path_by_default + test_dry_run_force_allows_non_test_target_path + test_dry_run_rejects_source_app_as_target + printf 'install-macos-prompt-test-app helper tests passed\n' +} + +main "$@"