Skip to content

Commit 3c708d3

Browse files
committed
Onboarding: gate launch-time TCC access while FDA decision is pending
Adds a process-wide FDA gate (`crate::fda_gate`) that defers launch-time access to TCC-protected surfaces while `fda_choice == NotAskedYet` AND the OS reports FDA isn't granted. Suppresses the 5–10 native macOS popups (MediaLibrary, AppData, FileProvider, Desktop, Documents, Downloads, …) that otherwise stack on top of the in-app onboarding modal. - `fda_gate` module with `is_fda_pending` (pure predicate) + `set_fda_pending` / `is_fda_pending_runtime` (process-global atomic, cleared via `start_indexing_after_fda_decision` on Deny / re-read at startup on Allow). - `volumes::list_locations` short-circuits NSWorkspace icon fetches and skips `Path::exists()` on `~/Desktop`/`~/Documents`/`~/Downloads` while pending. `get_cloud_drives` returns empty (no `~/Library/CloudStorage` read). - `icons::get_icons` and `refresh_directory_icons` IPCs return empty maps while pending so the frontend's launch-time extension-icon prefetch stops triggering UTType/LaunchServices probes. - `mtp::start_mtp_watcher` is deferred to `start_indexing_after_fda_decision` so the USB hotplug scan doesn't trip the MacDroid FileProvider TCC popup. - `should_auto_start_indexing` delegates to `fda_gate::is_fda_pending` so the indexer and icon gates can't drift apart. - TCC popup copy in `Info.plist` rewritten value-first with a "grant FDA for fewer popups" escape hatch; new keys for MediaLibrary and PhotoLibrary. - Docs: new rule in `AGENTS.md`; `Decision/Why` in `volumes/CLAUDE.md` and `indexing/CLAUDE.md`; updated flow in `onboarding/CLAUDE.md`.
1 parent 04c2458 commit 3c708d3

12 files changed

Lines changed: 340 additions & 90 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,15 @@ resilience, and common pitfalls.
177177
- When adding a new user-facing action, add it to `command-registry.ts` and `handleCommandExecute` in
178178
`routes/(main)/command-dispatch.ts`.
179179
- If you added a new Tauri command touching the filesystem, check `docs/architecture.md` § Platform constraints.
180+
-**Don't read TCC-protected paths or call NSWorkspace icon/LaunchServices APIs at app launch without the FDA gate.**
181+
`~/Downloads`, `~/Documents`, `~/Desktop`, `~/Pictures`, `~/Movies`, `~/Music`, `~/Library/CloudStorage`, and any
182+
`NSWorkspace.iconForFile:` call (even on `/Applications` or the iCloud root) can trigger macOS TCC popups during
183+
onboarding. We had **5–10 popups stacked on top of the in-app FDA modal** before this gate landed. Use
184+
`crate::fda_gate::is_fda_pending_runtime()` for launch-time call sites, or
185+
`crate::fda_gate::is_fda_pending(fda_choice, os_fda_granted)` for pure logic and tests. After Allow + restart, or Deny
186+
in-session via `start_indexing_after_fda_decision`, the gate clears and the same call sites run normally. See
187+
[`apps/desktop/src-tauri/src/fda_gate.rs`](apps/desktop/src-tauri/src/fda_gate.rs) and
188+
[`apps/desktop/src/lib/onboarding/CLAUDE.md`](apps/desktop/src/lib/onboarding/CLAUDE.md) § "FDA gate".
180189
-**Tauri APIs fail silently without permissions.** Whenever you call a new Tauri API from a window — `setMinSize`,
181190
`setTitle`, `show`, plugin commands, anything new — add the matching permission to that window's capability file in
182191
`src-tauri/capabilities/{default,settings,viewer}.json`. Without it, the call rejects with a generic "not allowed"

apps/desktop/src-tauri/Info.plist

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,23 @@
99
<key>CFBundleIconName</key>
1010
<string>Sequoia</string>
1111

12-
<!-- Privacy usage descriptions shown in macOS TCC prompts -->
13-
<key>NSNetworkVolumesUsageDescription</key>
14-
<string>Cmdr needs access to network volumes to browse and manage files on your NAS and other network drives.</string>
15-
<key>NSRemovableVolumesUsageDescription</key>
16-
<string>Cmdr needs access to removable volumes to browse and manage files on USB drives and external disks.</string>
17-
<key>NSDesktopFolderUsageDescription</key>
18-
<string>Cmdr needs access to your Desktop folder to browse and manage files there.</string>
19-
<key>NSDocumentsFolderUsageDescription</key>
20-
<string>Cmdr needs access to your Documents folder to browse and manage files there.</string>
12+
<!-- Privacy usage descriptions shown in macOS TCC prompts. Value-first copy with the
13+
"grant FDA for fewer popups" hint where it applies. Keep each string ≤200 chars. -->
2114
<key>NSDownloadsFolderUsageDescription</key>
22-
<string>Cmdr needs access to your Downloads folder to browse and manage files there.</string>
15+
<string>Cmdr needs this access to let you browse your downloads. If you want fewer popups, grant Cmdr "Full Disk Access" in System Settings → Privacy &amp; Security.</string>
16+
<key>NSDocumentsFolderUsageDescription</key>
17+
<string>Cmdr needs this access to let you browse your docs. If you want fewer popups, grant Cmdr "Full Disk Access" in System Settings → Privacy &amp; Security.</string>
18+
<key>NSDesktopFolderUsageDescription</key>
19+
<string>Cmdr needs this access to let you browse your Desktop. If you want fewer popups, grant Cmdr "Full Disk Access" in System Settings → Privacy &amp; Security.</string>
2320
<key>NSLocalNetworkUsageDescription</key>
24-
<string>Cmdr uses your local network to discover SMB file servers like NAS devices and connect to them for browsing and transferring files.</string>
21+
<string>Cmdr needs Local Network access to let you connect to SMB servers (NAS, file shares) super fast! Cmdr's connection is 4–30x faster than macOS's built-in mount.</string>
22+
<key>NSNetworkVolumesUsageDescription</key>
23+
<string>Cmdr needs this access to let you browse your network shares/drives. If you want fewer popups, grant Cmdr "Full Disk Access" in System Settings.</string>
24+
<key>NSRemovableVolumesUsageDescription</key>
25+
<string>Cmdr needs this access to let you browse the USB drives, SD cards, and external disks you connect. If you Don't Allow, macOS won't let Cmdr access those devices.</string>
26+
<key>NSAppleMusicUsageDescription</key>
27+
<string>Cmdr reads music file metadata to render file icons matching Apple Music. Cmdr doesn't read your library. If you want fewer popups, grant Cmdr "Full Disk Access" in System Settings.</string>
28+
<key>NSPhotoLibraryUsageDescription</key>
29+
<string>Cmdr reads image file metadata to render thumbnails for your photos. We don't read your library or upload anything. If you want fewer popups, grant Cmdr "Full Disk Access" in System Settings.</string>
2530
</dict>
2631
</plist>

apps/desktop/src-tauri/src/commands/icons.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,22 @@ const ICONS_TIMEOUT: Duration = Duration::from_secs(2);
1515
/// When `use_app_icons_for_documents` is true and on macOS, extension-based icons
1616
/// are fetched from app bundles (showing the app's icon as fallback). When false,
1717
/// the system's default document icons are used (Finder-style with app badge).
18+
///
19+
/// Returns an empty map while `crate::fda_gate::is_fda_pending_runtime()` is true.
20+
/// `fetch_fresh_extension_icon` walks UTType / LaunchServices, which on macOS
21+
/// touches MediaLibrary, AppData, FileProvider, and Apple Events TCC services
22+
/// for media/app/cloud-storage/scriptable extensions — exactly the popups we
23+
/// must not stack on top of the in-app FDA modal. Frontend re-requests after
24+
/// the gate clears.
1825
#[tauri::command]
1926
#[specta::specta]
2027
pub async fn get_icons(icon_ids: Vec<String>, use_app_icons_for_documents: bool) -> TimedOut<HashMap<String, String>> {
28+
if crate::fda_gate::is_fda_pending_runtime() {
29+
return TimedOut {
30+
data: HashMap::new(),
31+
timed_out: false,
32+
};
33+
}
2134
blocking_with_timeout_flag(ICONS_TIMEOUT, HashMap::new(), move || {
2235
icons::get_icons(icon_ids, use_app_icons_for_documents)
2336
})
@@ -30,13 +43,22 @@ pub async fn get_icons(icon_ids: Vec<String>, use_app_icons_for_documents: bool)
3043
///
3144
/// When `use_app_icons_for_documents` is true, falls back to app icons for files without
3245
/// document-specific icons. When false, uses Finder-style document icons.
46+
///
47+
/// Returns an empty map while the FDA gate is pending — same reason as
48+
/// `get_icons`. See `crate::fda_gate`.
3349
#[tauri::command]
3450
#[specta::specta]
3551
pub async fn refresh_directory_icons(
3652
directory_paths: Vec<String>,
3753
extensions: Vec<String>,
3854
use_app_icons_for_documents: bool,
3955
) -> TimedOut<HashMap<String, String>> {
56+
if crate::fda_gate::is_fda_pending_runtime() {
57+
return TimedOut {
58+
data: HashMap::new(),
59+
timed_out: false,
60+
};
61+
}
4062
blocking_with_timeout_flag(ICONS_TIMEOUT, HashMap::new(), move || {
4163
icons::refresh_icons_for_directory(directory_paths, extensions, use_app_icons_for_documents)
4264
})

apps/desktop/src-tauri/src/commands/indexing.rs

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,18 +68,45 @@ pub async fn set_indexing_enabled(app: AppHandle, enabled: bool) -> Result<(), S
6868
Ok(())
6969
}
7070

71-
/// Start the indexer once the user has decided about Full Disk Access.
71+
/// Apply the user's FDA decision: clear the gate, start the MTP watcher
72+
/// (deferred at launch to avoid the MacDroid File Provider prompt during
73+
/// onboarding), and start the indexer.
7274
///
73-
/// At app launch, indexing is skipped when the FDA choice is `NotAskedYet` AND
74-
/// the OS reports FDA as not granted (see `should_auto_start_indexing`). The
75-
/// frontend calls this command after the user clicks "Deny" so the indexer
76-
/// starts within the same session. The "Allow" path needs no call: the user
77-
/// restarts the app, and the launch-time gate passes via the OS check.
75+
/// Three things happen at the gate boundary:
76+
/// 1. Clear the FDA-pending atomic (`crate::fda_gate::set_fda_pending(false)`)
77+
/// so subsequent code paths can run normally. The deny path runs in the
78+
/// same process; the allow path restarts the app, which re-enters
79+
/// `setup()` and sets the atomic via the OS probe.
80+
/// 2. Start the MTP hotplug watcher. MTP is opt-in per device — the
81+
/// watcher itself doesn't trigger TCC.
82+
/// 3. Start the drive indexer. On the Deny path this is what surfaces the
83+
/// "individual Allow/Deny prompts" the user signed up for by denying
84+
/// FDA: the scan walks protected folders, macOS fires one TCC popup per
85+
/// folder, the user grants or denies each. Folders that get denied stay
86+
/// unindexed (size shows as `<dir>`); the rest get indexed normally.
87+
///
88+
/// **No proactive `volumes-changed` re-emission.** Emitting here would
89+
/// refire every per-folder TCC prompt at once via NSWorkspace icon
90+
/// resolution, on TOP of the per-folder prompts the indexer is already
91+
/// generating. The sidebar keeps the icon-less favorites it got during
92+
/// onboarding; the next listing-driven flow refreshes them naturally.
93+
///
94+
/// At app launch, indexing is skipped when the FDA choice is `NotAskedYet`
95+
/// AND the OS reports FDA as not granted (see `should_auto_start_indexing`).
96+
/// The frontend calls this command after the user clicks "Deny" so the
97+
/// indexer starts within the same session. The "Allow" path needs no call:
98+
/// the user restarts the app, and the launch-time gate passes via the OS
99+
/// check.
78100
///
79101
/// Idempotent: a no-op when indexing is already running or initializing.
80102
#[tauri::command]
81103
#[specta::specta]
82104
pub async fn start_indexing_after_fda_decision(app: AppHandle) -> Result<(), String> {
105+
crate::fda_gate::set_fda_pending(false);
106+
107+
#[cfg(any(target_os = "macos", target_os = "linux"))]
108+
crate::mtp::start_mtp_watcher(&app);
109+
83110
if indexing::is_active() {
84111
return Ok(());
85112
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//! Full Disk Access gate.
2+
//!
3+
//! At first launch on macOS, Cmdr shows an in-app modal that walks the user
4+
//! through granting Full Disk Access (FDA) before the indexer scans `/`.
5+
//! The same gate applies more broadly: any launch-time code that touches
6+
//! TCC-protected paths (Downloads, Documents, Desktop, ...) or NSWorkspace
7+
//! icon/LaunchServices APIs on those paths must skip work while the FDA
8+
//! decision is pending. Otherwise macOS stacks several native permission
9+
//! popups (MediaLibrary, AppData, Desktop, Documents, Downloads, ...) on
10+
//! top of our in-app modal — exactly the onboarding-flood UX we want to
11+
//! avoid.
12+
//!
13+
//! The gate has two pieces:
14+
//!
15+
//! 1. `is_fda_pending(fda_choice, os_fda_granted)` — pure decision used at
16+
//! startup and by tests. Pending iff the user hasn't decided AND the OS
17+
//! reports FDA isn't granted.
18+
//! 2. A process-global `AtomicBool` set once at startup (and cleared when
19+
//! the user denies FDA in-session). Read by code that runs after startup
20+
//! via `is_fda_pending_runtime()`.
21+
//!
22+
//! On non-macOS platforms FDA doesn't exist; the runtime gate is always
23+
//! `false` (open) so cross-platform callers get the right behaviour without
24+
//! cfg-guards at every site.
25+
26+
use std::sync::OnceLock;
27+
use std::sync::atomic::{AtomicBool, Ordering};
28+
29+
use crate::settings::FullDiskAccessChoice;
30+
31+
static FDA_PENDING: OnceLock<AtomicBool> = OnceLock::new();
32+
33+
/// Pure decision: is the FDA decision still pending at this moment?
34+
///
35+
/// Returns `true` only when the user hasn't decided AND the OS confirms FDA
36+
/// isn't currently granted. If the OS check returns `true` we know the
37+
/// per-folder TCC services are subsumed by FDA, so it's safe to access
38+
/// protected paths even if no in-app choice has been recorded yet.
39+
pub fn is_fda_pending(fda_choice: FullDiskAccessChoice, os_fda_granted: bool) -> bool {
40+
fda_choice == FullDiskAccessChoice::NotAskedYet && !os_fda_granted
41+
}
42+
43+
/// Set the runtime gate. Call once at startup with the result of
44+
/// `is_fda_pending(...)`, and again with `false` after the user makes a
45+
/// choice in-session (deny path — the allow path requires a restart and
46+
/// re-enters startup).
47+
pub fn set_fda_pending(pending: bool) {
48+
FDA_PENDING
49+
.get_or_init(|| AtomicBool::new(pending))
50+
.store(pending, Ordering::Release);
51+
}
52+
53+
/// Read the runtime gate. Returns `false` until `set_fda_pending` has been
54+
/// called — safe default for tests and any non-macOS build that never sets
55+
/// it.
56+
pub fn is_fda_pending_runtime() -> bool {
57+
FDA_PENDING.get().is_some_and(|f| f.load(Ordering::Acquire))
58+
}
59+
60+
#[cfg(test)]
61+
mod tests {
62+
use super::*;
63+
64+
#[test]
65+
fn pending_only_when_not_asked_and_os_denies() {
66+
assert!(is_fda_pending(FullDiskAccessChoice::NotAskedYet, false));
67+
assert!(!is_fda_pending(FullDiskAccessChoice::NotAskedYet, true));
68+
assert!(!is_fda_pending(FullDiskAccessChoice::Allow, false));
69+
assert!(!is_fda_pending(FullDiskAccessChoice::Allow, true));
70+
assert!(!is_fda_pending(FullDiskAccessChoice::Deny, false));
71+
assert!(!is_fda_pending(FullDiskAccessChoice::Deny, true));
72+
}
73+
}

apps/desktop/src-tauri/src/indexing/CLAUDE.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,18 @@ Key test files are alongside each module (test functions within `#[cfg(test)]` b
129129
scanning from `/` opens iCloud Drive, Photos, and other TCC-protected directories, which makes macOS show native
130130
permission popups stacked on top of the in-app FDA modal. The result is a confusing pile of dialogs before the user has
131131
seen our prompt. `should_auto_start_indexing(indexing_enabled, fda_choice, os_fda_granted)` (in `mod.rs`) gates the
132-
launch-time start: it skips when `fda_choice == NotAskedYet` AND `os_fda_granted == false`. Once the user picks Allow
133-
(restart) or Deny (same session, via `start_indexing_after_fda_decision`), the indexer starts. `os_fda_granted == true`
134-
overrides `NotAskedYet` so users who granted FDA before our prompt persisted a choice still get auto-start. Pure
135-
function so the gate logic is unit-tested without touching `setup()`.
132+
launch-time start using `crate::fda_gate::is_fda_pending(fda_choice, os_fda_granted)`: skip when
133+
`fda_choice == NotAskedYet` AND `os_fda_granted == false`. Once the user picks Allow (restart) or Deny (same session,
134+
via `start_indexing_after_fda_decision`), the indexer starts. `os_fda_granted == true` overrides `NotAskedYet` so users
135+
who granted FDA externally before our prompt persisted a choice still get auto-start.
136+
137+
After Deny the indexer runs in degraded mode: `read_dir` on protected paths still fires a TCC popup per folder, the
138+
user grants or denies each, and the scan walks past denied folders (they stay unindexed; their size shows as `<dir>`).
139+
That's the "individual Allow/Deny prompts" contract the user opted into by denying FDA — it's user-mediated, one prompt
140+
per protected folder, not a system-flood.
141+
142+
Launch-time NSWorkspace icon fetches in `volumes::list_locations` use the same `is_fda_pending` predicate. The pure
143+
function is shared so the two gate sites can't drift apart.
136144

137145
**`getattrlistbulk` (via jwalk) for scanning, not `enumeratorAtURL` or `searchfs`**: Benchmarked on ~5M files (macOS, Apple Silicon, APFS). `getattrlistbulk` recursive walk: 1m49s with sizes. `enumeratorAtURL` with prefetched keys: 2m05s (+11%), found ~500K fewer entries. `searchfs`: fast for name lookup but can't return sizes. `mdfind`: undercounts (Spotlight excludes `.git/`, `node_modules/`, caches). `getattrlistbulk` is what jwalk uses under the hood on macOS, and adding size collection costs only ~4% overhead (packed in the same bulk buffer, no extra syscalls).
138146

0 commit comments

Comments
 (0)