Skip to content

Commit 59aca71

Browse files
committed
Onboarding: Gate indexer auto-start behind FDA decision
Indexer was running at every launch from `setup()`, recursively scanning `/`. On first launch this triggered macOS native permission popups (iCloud, Photos, etc.) before the in-app FDA modal mounted, stacking three prompts over our explanation modal. - Add pure `should_auto_start_indexing(indexing_enabled, fda_choice, os_fda_granted)` in `indexing/mod.rs`. Skips auto-start when `fda_choice == NotAskedYet` AND the OS check reports FDA as not granted. 5 unit tests cover the truth table. - Wire `setup()` in `lib.rs` to call the OS-level FDA check (`permissions::check_full_disk_access` per platform) and pass through to the gate. - New `start_indexing_after_fda_decision` Tauri command (idempotent via `is_active()`). Frontend `FullDiskAccessPrompt` Deny handler calls it so the user doesn't need to restart for indexing to start in-session. Allow path needs no wiring — next-launch OS check passes the gate. - Drop `#[allow(dead_code)]` on `Settings::full_disk_access_choice` (now consulted) and re-export `FullDiskAccessChoice` from `settings::mod`. - Bump `mod.rs` and `lib.rs` entries in `file-length-allowlist.json`. - Document the gate + `Decision/Why` in `indexing/CLAUDE.md`, `onboarding/CLAUDE.md`, `settings/CLAUDE.md`.
1 parent ffeb7d9 commit 59aca71

12 files changed

Lines changed: 221 additions & 11 deletions

File tree

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,20 @@ pub async fn set_indexing_enabled(app: AppHandle, enabled: bool) -> Result<(), S
5959
}
6060
Ok(())
6161
}
62+
63+
/// Start the indexer once the user has decided about Full Disk Access.
64+
///
65+
/// At app launch, indexing is skipped when the FDA choice is `NotAskedYet` AND
66+
/// the OS reports FDA as not granted (see `should_auto_start_indexing`). The
67+
/// frontend calls this command after the user clicks "Deny" so the indexer
68+
/// starts within the same session. The "Allow" path needs no call: the user
69+
/// restarts the app, and the launch-time gate passes via the OS check.
70+
///
71+
/// Idempotent: a no-op when indexing is already running or initializing.
72+
#[tauri::command]
73+
pub async fn start_indexing_after_fda_decision(app: AppHandle) -> Result<(), String> {
74+
if indexing::is_active() {
75+
return Ok(());
76+
}
77+
indexing::start_indexing(&app)
78+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,15 @@ Key test files are alongside each module (test functions within `#[cfg(test)]` b
122122

123123
## Key decisions
124124

125+
**Defer indexer auto-start until the user decides about Full Disk Access**: At first launch on macOS, recursively
126+
scanning from `/` opens iCloud Drive, Photos, and other TCC-protected directories, which makes macOS show native
127+
permission popups stacked on top of the in-app FDA modal. The result is a confusing pile of dialogs before the user has
128+
seen our prompt. `should_auto_start_indexing(indexing_enabled, fda_choice, os_fda_granted)` (in `mod.rs`) gates the
129+
launch-time start: it skips when `fda_choice == NotAskedYet` AND `os_fda_granted == false`. Once the user picks Allow
130+
(restart) or Deny (same session, via `start_indexing_after_fda_decision`), the indexer starts. `os_fda_granted == true`
131+
overrides `NotAskedYet` so users who granted FDA before our prompt persisted a choice still get auto-start. Pure
132+
function so the gate logic is unit-tested without touching `setup()`.
133+
125134
**`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).
126135

127136
**Physical sizes may overcount ~10-20% due to APFS clones**: Per-file `st_blocks * 512` sums to ~905 GB vs ~746 GB true volume usage (`statfs()`). APFS clones (Xcode, simulators, Time Machine, `cp` since Ventura) share underlying blocks but each clone reports full allocation. Volume usage bar always uses `statfs()` for true totals.

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

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ use store::{DirStats, IndexStore};
4444
use tauri::AppHandle;
4545
use writer::WriteMessage;
4646

47+
use crate::settings::FullDiskAccessChoice;
48+
4749
// ── Indexing state machine ────────────────────────────────────────────
4850

4951
/// Lifecycle phases of the indexing system. Single source of truth for
@@ -86,6 +88,37 @@ pub fn should_auto_start(indexing_enabled: Option<bool>) -> bool {
8688
true
8789
}
8890

91+
/// Pure decision: should the indexer auto-start at app launch?
92+
///
93+
/// Combines the user's indexing-enabled setting with the FDA gate. The FDA gate
94+
/// blocks the indexer from scanning `/` before the user has decided about Full
95+
/// Disk Access — otherwise macOS native permission popups (iCloud, Photos, etc.)
96+
/// stack on top of the in-app FDA modal at first launch.
97+
///
98+
/// Auto-start when ALL of the following hold:
99+
/// - The user has not disabled indexing (`indexing_enabled != Some(false)`).
100+
/// - Either the user has already made an FDA choice (Allow or Deny), OR the OS
101+
/// reports FDA is currently granted. When the choice is `NotAskedYet` AND the
102+
/// OS check returns `false`, we skip auto-start until the user decides.
103+
pub fn should_auto_start_indexing(
104+
indexing_enabled: Option<bool>,
105+
fda_choice: FullDiskAccessChoice,
106+
os_fda_granted: bool,
107+
) -> bool {
108+
if !should_auto_start(indexing_enabled) {
109+
return false;
110+
}
111+
112+
// FDA gate: only block when the user hasn't decided AND the OS confirms FDA
113+
// is not currently granted. If the OS check returns true, we know we won't
114+
// trigger native permission popups, so it's safe to start.
115+
if fda_choice == FullDiskAccessChoice::NotAskedYet && !os_fda_granted {
116+
return false;
117+
}
118+
119+
true
120+
}
121+
89122
/// Trigger background verification of a directory against the index DB.
90123
/// Called after enrichment on each navigation. No-op if indexing is not running.
91124
/// Fully fire-and-forget: the INDEXING lock is acquired on a spawned task,
@@ -1056,6 +1089,87 @@ mod tests {
10561089
}
10571090
}
10581091

1092+
// ── should_auto_start_indexing (FDA gate) ────────────────────────
1093+
1094+
/// First launch with no FDA grant: indexer must NOT auto-start.
1095+
/// Otherwise the recursive scan from `/` triggers macOS native permission
1096+
/// popups (iCloud, Photos, etc.) before the in-app FDA modal mounts.
1097+
#[test]
1098+
fn should_auto_start_indexing_blocked_when_not_asked_and_os_fda_false() {
1099+
assert!(!should_auto_start_indexing(
1100+
None,
1101+
FullDiskAccessChoice::NotAskedYet,
1102+
false
1103+
));
1104+
assert!(!should_auto_start_indexing(
1105+
Some(true),
1106+
FullDiskAccessChoice::NotAskedYet,
1107+
false
1108+
));
1109+
}
1110+
1111+
/// `NotAskedYet` but OS already grants FDA (user enabled it externally,
1112+
/// or stale persisted state): safe to auto-start.
1113+
#[test]
1114+
fn should_auto_start_indexing_allowed_when_os_fda_true_overrides_not_asked() {
1115+
assert!(should_auto_start_indexing(
1116+
None,
1117+
FullDiskAccessChoice::NotAskedYet,
1118+
true
1119+
));
1120+
assert!(should_auto_start_indexing(
1121+
Some(true),
1122+
FullDiskAccessChoice::NotAskedYet,
1123+
true
1124+
));
1125+
}
1126+
1127+
/// User picked Allow (typically restarts the app after granting in System
1128+
/// Settings): auto-start regardless of OS check (it should also be true,
1129+
/// but we trust the persisted choice).
1130+
#[test]
1131+
fn should_auto_start_indexing_allowed_when_user_choice_is_allow() {
1132+
assert!(should_auto_start_indexing(None, FullDiskAccessChoice::Allow, true));
1133+
// Even if the OS check returns false (FDA was revoked), the Allow
1134+
// branch still passes the gate — the +page.svelte revoked-prompt path
1135+
// handles re-asking the user. The indexer just won't be able to read
1136+
// protected dirs, which is fine; it skips them.
1137+
assert!(should_auto_start_indexing(None, FullDiskAccessChoice::Allow, false));
1138+
}
1139+
1140+
/// User picked Deny: indexer auto-starts (we respect the choice not to ask
1141+
/// again, and indexing without FDA still works for accessible paths).
1142+
#[test]
1143+
fn should_auto_start_indexing_allowed_when_user_choice_is_deny() {
1144+
assert!(should_auto_start_indexing(None, FullDiskAccessChoice::Deny, false));
1145+
assert!(should_auto_start_indexing(
1146+
Some(true),
1147+
FullDiskAccessChoice::Deny,
1148+
false
1149+
));
1150+
}
1151+
1152+
/// Indexing disabled in settings always wins: never auto-start regardless
1153+
/// of FDA state.
1154+
#[test]
1155+
fn should_auto_start_indexing_blocked_when_indexing_disabled() {
1156+
assert!(!should_auto_start_indexing(
1157+
Some(false),
1158+
FullDiskAccessChoice::Allow,
1159+
true
1160+
));
1161+
assert!(!should_auto_start_indexing(
1162+
Some(false),
1163+
FullDiskAccessChoice::Deny,
1164+
false
1165+
));
1166+
assert!(!should_auto_start_indexing(
1167+
Some(false),
1168+
FullDiskAccessChoice::NotAskedYet,
1169+
true
1170+
));
1171+
}
1172+
10591173
/// After clearing READ_POOL, `enrich_entries_with_index` returns early
10601174
/// without panic and leaves entries unenriched.
10611175
#[test]

apps/desktop/src-tauri/src/lib.rs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -474,8 +474,22 @@ pub fn run() {
474474
// Initialize indexing state (does not start scanning until explicitly started)
475475
indexing::init();
476476

477-
// Auto-start indexing unless user disabled it in settings
478-
if indexing::should_auto_start(saved_settings.indexing_enabled) {
477+
// Auto-start indexing unless user disabled it in settings, or unless
478+
// the FDA gate blocks (first launch with no Full Disk Access decision
479+
// yet — starting the recursive scan from `/` would trigger macOS native
480+
// permission popups before the in-app FDA modal mounts).
481+
#[cfg(target_os = "macos")]
482+
let os_fda_granted = permissions::check_full_disk_access();
483+
#[cfg(target_os = "linux")]
484+
let os_fda_granted = permissions_linux::check_full_disk_access();
485+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
486+
let os_fda_granted = stubs::permissions::check_full_disk_access();
487+
488+
if indexing::should_auto_start_indexing(
489+
saved_settings.indexing_enabled,
490+
saved_settings.full_disk_access_choice,
491+
os_fda_granted,
492+
) {
479493
let app_handle = app.handle().clone();
480494
// Use tauri's runtime spawn instead of tokio::spawn since setup()
481495
// runs synchronously before the Tokio runtime is fully available
@@ -484,8 +498,14 @@ pub fn run() {
484498
log::warn!("Failed to auto-start indexing: {e}");
485499
}
486500
});
487-
} else {
501+
} else if saved_settings.indexing_enabled == Some(false) {
488502
log::info!("Drive indexing auto-start skipped (disabled in settings)");
503+
} else {
504+
log::info!(
505+
"Drive indexing auto-start deferred until Full Disk Access decision (FDA choice: {:?}, OS-granted: {})",
506+
saved_settings.full_disk_access_choice,
507+
os_fda_granted,
508+
);
489509
}
490510

491511
Ok(())
@@ -1048,6 +1068,7 @@ pub fn run() {
10481068
commands::indexing::get_dir_stats_batch,
10491069
commands::indexing::clear_drive_index,
10501070
commands::indexing::set_indexing_enabled,
1071+
commands::indexing::start_indexing_after_fda_decision,
10511072
commands::indexing::get_index_debug_status,
10521073
// Drive search commands
10531074
commands::search::prepare_search_index,

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Thin read-only settings loader used during Rust startup. The frontend owns all s
1818
```rust
1919
Settings {
2020
show_hidden_files: bool, // default true
21-
full_disk_access_choice: ..., // persisted by frontend only, #[allow(dead_code)]
21+
full_disk_access_choice: ..., // consulted at launch by indexer FDA gate
2222
developer_mcp_enabled: Option<bool>,
2323
developer_mcp_port: Option<u16>,
2424
indexing_enabled: Option<bool>,
@@ -69,7 +69,9 @@ These are top-level keys — the dot is part of the key name, not a nesting sepa
6969

7070
- **One-way read only.** This module never writes. All writes go through the frontend's settings store.
7171
- Direct file reading is the correct design — multiple backend systems (MCP, indexing, crash reporter) need settings before the frontend loads.
72-
- `full_disk_access_choice` is marked `#[allow(dead_code)]` — it is persisted by the frontend but the backend takes no action on it.
72+
- `full_disk_access_choice` is consulted at app launch by the indexer FDA gate (`indexing::should_auto_start_indexing`)
73+
to defer the recursive scan from `/` until the user has decided. See `indexing/CLAUDE.md` § "Defer indexer auto-start
74+
until the user decides about Full Disk Access".
7375
- Falls back gracefully: missing file → use `Default`.
7476

7577
## Dependencies

apps/desktop/src-tauri/src/settings/loader.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ pub struct Settings {
2929
#[serde(alias = "showHiddenFiles", default = "default_show_hidden")]
3030
pub show_hidden_files: bool,
3131
#[serde(alias = "fullDiskAccessChoice", default)]
32-
#[allow(dead_code, reason = "Only used by frontend, backend just persists it")]
3332
pub full_disk_access_choice: FullDiskAccessChoice,
3433
#[serde(alias = "developer.mcpEnabled", default)]
3534
pub developer_mcp_enabled: Option<bool>,

apps/desktop/src-tauri/src/settings/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
33
pub mod loader;
44

5-
pub use loader::{early_load_max_log_storage_mb, early_load_verbose_logging, load_settings};
5+
pub use loader::{FullDiskAccessChoice, early_load_max_log_storage_mb, early_load_verbose_logging, load_settings};

apps/desktop/src/lib/onboarding/CLAUDE.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,25 @@ Access in macOS System Settings.
1616
Two actions are available:
1717

1818
- **Open System Settings** — calls `openPrivacySettings()` via IPC, then shows a follow-up hint to restart the app.
19-
- **Deny** — saves `fullDiskAccessChoice: 'deny'` to settings and calls `onComplete()` to dismiss.
19+
- **Deny** — saves `fullDiskAccessChoice: 'deny'` to settings, calls `startIndexingAfterFdaDecision()` so the indexer
20+
starts within this session, then calls `onComplete()` to dismiss.
21+
22+
## Indexer FDA gate
23+
24+
At app launch, the backend defers starting the drive indexer until the user has decided about Full Disk Access. The
25+
recursive scan from `/` would otherwise trigger macOS native permission popups (iCloud, Photos, etc.) that stack on top
26+
of this in-app FDA modal.
27+
28+
The gate fires when `fullDiskAccessChoice === 'notAskedYet'` AND the OS-level FDA check returns false. After the user
29+
decides:
30+
31+
- **Deny** path: `FullDiskAccessPrompt.svelte` calls `startIndexingAfterFdaDecision()` so the indexer starts in the
32+
current session.
33+
- **Allow** path: the user grants FDA in System Settings, then restarts the app. On next launch the OS check returns
34+
true, the gate passes, and the indexer auto-starts.
35+
36+
The Tauri command is idempotent — calling it when indexing is already running is a no-op. See
37+
`src-tauri/src/indexing/CLAUDE.md` for the backend side.
2038

2139
The `wasRevoked` prop switches the copy from "first ask" to "revoked" framing.
2240

apps/desktop/src/lib/onboarding/FullDiskAccessPrompt.svelte

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
<script lang="ts">
2-
import { openPrivacySettings } from '$lib/tauri-commands'
2+
import { openPrivacySettings, startIndexingAfterFdaDecision } from '$lib/tauri-commands'
33
import { saveSettings } from '$lib/settings-store'
44
import ModalDialog from '$lib/ui/ModalDialog.svelte'
55
import Button from '$lib/ui/Button.svelte'
6+
import { getAppLogger } from '$lib/logging/logger'
7+
8+
const log = getAppLogger('onboarding')
69
710
interface Props {
811
onComplete: () => void
@@ -20,6 +23,14 @@
2023
2124
async function handleDeny() {
2225
await saveSettings({ fullDiskAccessChoice: 'deny' })
26+
// Indexing was deferred at app launch (FDA gate). Now that the user has
27+
// decided, start it within this session so they don't need to restart
28+
// for the index to start populating.
29+
try {
30+
await startIndexingAfterFdaDecision()
31+
} catch (error) {
32+
log.warn('Failed to start indexing after FDA deny: {error}', { error })
33+
}
2334
onComplete()
2435
}
2536
</script>

apps/desktop/src/lib/tauri-commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ export {
295295
setErrorReportsEnabled,
296296
setShowVirtualGitPortal,
297297
setIndexingEnabled,
298+
startIndexingAfterFdaDecision,
298299
getDirStatsBatch,
299300
getE2eStartPath,
300301
getAiStatus,

0 commit comments

Comments
 (0)