Skip to content

Commit 1691821

Browse files
committed
Onboarding: improve FDA prompt UX and probe
- Deep-link Open System Settings straight to the Full Disk Access pane (Ventura+ uses `PrivacySecurity.extension`, older macOS keeps `preference.security`). - Modal copy is now version-aware: Ventura+ says "in the list", macOS 12 and older say "at the end of the list" (matches the legacy non-alphabetical order). - Add a "Tip" substep covering the macOS 26 (Tahoe) case where Cmdr may not auto-appear in the FDA list — guides the user to the `+` button. - Re-probe `check_full_disk_access` right before opening Settings so the bundle is freshly registered with TCC. - Switch the probe from `read_dir` on `~/Library/Mail` (often `NotFound`, doesn't trigger TCC) to `File::open` + 1-byte `read` on a list of TCC-protected files (`Safari/History.db`, `Safari/Bookmarks.plist`, `Mail/V10/MailData/Envelope Index`, `Messages/chat.db`, `com.apple.TCC/TCC.db`, `AddressBook-v22.abcddb`). On denial, also fire `mmap`, `NSData dataWithContentsOfFile:`, and a `read_dir` of the parent — multi-trigger fallback for Tahoe where the kernel can short-circuit `read()` denials without consulting tccd. - Add `get_macos_major_version` IPC so the frontend can branch copy/host without parsing `sw_vers` itself.
1 parent 49a119b commit 1691821

11 files changed

Lines changed: 316 additions & 17 deletions

File tree

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ urlencoding = "2.1.3"
176176
objc2 = { version = "0.6", features = ["std", "exception"] }
177177
objc2-foundation = { version = "0.3", features = [
178178
"NSURL", "NSString", "NSDictionary", "NSDate", "NSArray", "NSValue", "NSError",
179-
"NSFileManager", "NSNotification", "NSBundle", "NSDistributedNotificationCenter", "NSUserDefaults",
179+
"NSData", "NSFileManager", "NSNotification", "NSBundle", "NSDistributedNotificationCenter", "NSUserDefaults",
180180
] }
181181
objc2-app-kit = { version = "0.3", features = [
182182
"NSDragging", "NSDraggingItem", "NSImage", "NSColor", "NSColorSpace",

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ fn collect_permission_types(types: &mut Types) -> Vec<Function> {
424424
use specta::function::collect_functions;
425425
collect_functions![
426426
crate::permissions::check_full_disk_access,
427+
crate::permissions::get_macos_major_version,
427428
crate::permissions::open_privacy_settings,
428429
crate::permissions::open_appearance_settings,
429430
crate::permissions::open_system_settings_url,
@@ -434,6 +435,7 @@ fn collect_permission_types(types: &mut Types) -> Vec<Function> {
434435
use specta::function::collect_functions;
435436
collect_functions![
436437
crate::permissions_linux::check_full_disk_access,
438+
crate::permissions_linux::get_macos_major_version,
437439
crate::permissions_linux::open_privacy_settings,
438440
crate::permissions_linux::open_appearance_settings,
439441
crate::permissions_linux::open_system_settings_url,
@@ -444,6 +446,7 @@ fn collect_permission_types(types: &mut Types) -> Vec<Function> {
444446
use specta::function::collect_functions;
445447
collect_functions![
446448
crate::stubs::permissions::check_full_disk_access,
449+
crate::stubs::permissions::get_macos_major_version,
447450
crate::stubs::permissions::open_privacy_settings,
448451
crate::stubs::permissions::open_appearance_settings,
449452
crate::stubs::permissions::open_system_settings_url,
@@ -805,6 +808,8 @@ pub fn builder() -> Builder<tauri::Wry> {
805808
#[cfg(target_os = "macos")]
806809
crate::permissions::check_full_disk_access,
807810
#[cfg(target_os = "macos")]
811+
crate::permissions::get_macos_major_version,
812+
#[cfg(target_os = "macos")]
808813
crate::permissions::open_privacy_settings,
809814
#[cfg(target_os = "macos")]
810815
crate::permissions::open_appearance_settings,
@@ -813,6 +818,8 @@ pub fn builder() -> Builder<tauri::Wry> {
813818
#[cfg(target_os = "linux")]
814819
crate::permissions_linux::check_full_disk_access,
815820
#[cfg(target_os = "linux")]
821+
crate::permissions_linux::get_macos_major_version,
822+
#[cfg(target_os = "linux")]
816823
crate::permissions_linux::open_privacy_settings,
817824
#[cfg(target_os = "linux")]
818825
crate::permissions_linux::open_appearance_settings,
@@ -821,6 +828,8 @@ pub fn builder() -> Builder<tauri::Wry> {
821828
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
822829
crate::stubs::permissions::check_full_disk_access,
823830
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
831+
crate::stubs::permissions::get_macos_major_version,
832+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
824833
crate::stubs::permissions::open_privacy_settings,
825834
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
826835
crate::stubs::permissions::open_appearance_settings,

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

Lines changed: 190 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,205 @@
11
//! macOS permission checking and system settings helpers.
22
3-
/// Checks if the app has full disk access by probing ~/Library/Mail.
4-
/// This is a standard technique used by macOS apps - Mail is always protected.
3+
use std::ffi::CString;
4+
use std::fs::File;
5+
use std::io::{ErrorKind, Read};
6+
use std::os::unix::ffi::OsStrExt;
7+
use std::path::{Path, PathBuf};
8+
9+
/// Specific TCC-protected files we probe to determine FDA status.
10+
///
11+
/// We `open()` + `read()` actual *files*, not directory listings: TCC's
12+
/// registration hook fires on read syscalls into protected paths, not on
13+
/// `opendir()`. A `read_dir` attempt against a protected directory may be
14+
/// silently denied without ever adding the bundle to System Settings → Privacy
15+
/// & Security → Full Disk Access. Even `open()` alone has been observed not
16+
/// to register the bundle on some macOS versions — the actual `read()` is
17+
/// what reliably triggers `tccd`.
18+
///
19+
/// Order matters: we walk until we hit a file that exists on this account,
20+
/// because `NotFound` doesn't trigger TCC. Once we hit one, the read attempt
21+
/// either succeeds (FDA granted) or returns `PermissionDenied` (FDA not
22+
/// granted, bundle now registered with TCC).
23+
fn fda_probe_files() -> Vec<PathBuf> {
24+
let Some(home) = dirs::home_dir() else {
25+
return Vec::new();
26+
};
27+
vec![
28+
home.join("Library/Safari/History.db"), // TCC-protected, present after Safari use
29+
home.join("Library/Safari/Bookmarks.plist"), // TCC-protected, present after first Safari launch
30+
home.join("Library/Mail/V10/MailData/Envelope Index"), // Mail user
31+
home.join("Library/Messages/chat.db"), // Messages user
32+
home.join("Library/Application Support/com.apple.TCC/TCC.db"), // always exists, TCC-protected
33+
home.join("Library/Application Support/AddressBook/AddressBook-v22.abcddb"), // Contacts user
34+
]
35+
}
36+
37+
/// Tries to open `path` and read at least one byte from it. The read is what
38+
/// trips TCC; `open()` alone has been observed not to register the bundle.
39+
fn try_read_byte(path: &Path) -> std::io::Result<()> {
40+
let mut f = File::open(path)?;
41+
let mut buf = [0u8; 1];
42+
// We don't care if we got 0 bytes (empty file) or 1 — both mean the read
43+
// syscall reached the kernel and was allowed. The variant we care about
44+
// is the `Err` case, which is what TCC denial returns.
45+
let _ = f.read(&mut buf)?;
46+
Ok(())
47+
}
48+
49+
/// Tries to mmap the first byte of `path`. Different syscall path than
50+
/// `read()` (mmap goes through the VM subsystem); on some macOS versions
51+
/// this is observed to trigger tccd registration where plain `read()`
52+
/// doesn't.
53+
fn try_mmap_byte(path: &Path) -> std::io::Result<()> {
54+
let cpath =
55+
CString::new(path.as_os_str().as_bytes()).map_err(|e| std::io::Error::new(ErrorKind::InvalidInput, e))?;
56+
// Safety: passing valid pointers, validating return values, calling
57+
// the matching `munmap`/`close` on every path out.
58+
unsafe {
59+
let fd = libc::open(cpath.as_ptr(), libc::O_RDONLY);
60+
if fd < 0 {
61+
return Err(std::io::Error::last_os_error());
62+
}
63+
let len: libc::size_t = 1;
64+
let ptr = libc::mmap(std::ptr::null_mut(), len, libc::PROT_READ, libc::MAP_PRIVATE, fd, 0);
65+
if ptr == libc::MAP_FAILED {
66+
let err = std::io::Error::last_os_error();
67+
libc::close(fd);
68+
return Err(err);
69+
}
70+
// Force the fault by reading the byte through the mapping.
71+
let _ = std::ptr::read_volatile(ptr as *const u8);
72+
libc::munmap(ptr, len);
73+
libc::close(fd);
74+
}
75+
Ok(())
76+
}
77+
78+
/// Tries to read `path` via `NSData dataWithContentsOfFile:`. Goes through
79+
/// Foundation's higher-level file API rather than raw POSIX, in case Tahoe
80+
/// only triggers tccd registration for Foundation-routed reads.
81+
fn try_nsdata_read(path: &Path) -> std::io::Result<()> {
82+
use objc2_foundation::{NSData, NSString};
83+
let path_str = NSString::from_str(&path.to_string_lossy());
84+
// `dataWithContentsOfFile:` returns nil on failure (no error detail).
85+
// We don't care about the data; we only want the syscall to land at
86+
// tccd. Any nil result is treated as "denied" for our purposes.
87+
let data = NSData::dataWithContentsOfFile(&path_str);
88+
if data.is_some() {
89+
Ok(())
90+
} else {
91+
Err(std::io::Error::from(ErrorKind::PermissionDenied))
92+
}
93+
}
94+
95+
/// Tries to list the parent directory of `path` via `read_dir`. The
96+
/// pre-Tahoe Cmdr probe used `read_dir(~/Library/Mail)` directly, which
97+
/// some users reported as the trigger that put Cmdr in the FDA list. Kept
98+
/// as one of the multi-trigger fallbacks.
99+
fn try_read_dir_parent(path: &Path) -> std::io::Result<()> {
100+
let parent = path.parent().ok_or(std::io::Error::from(ErrorKind::InvalidInput))?;
101+
std::fs::read_dir(parent).map(|_| ())
102+
}
103+
104+
/// Checks if the app has full disk access by probing TCC-protected files.
105+
///
106+
/// Probing is also how the bundle gets registered with TCC, which is what
107+
/// makes Cmdr show up in the Full Disk Access list in System Settings. On
108+
/// macOS 26 (Tahoe), the kernel/sandbox can short-circuit `read()` denials
109+
/// without consulting tccd, leaving the bundle out of the FDA list. To
110+
/// maximize the chance one of the access paths threads the needle, on a
111+
/// denial we fire all three: raw `read`, `mmap`, `NSData`, plus a
112+
/// `read_dir` of the parent directory.
5113
#[tauri::command]
6114
#[specta::specta]
7115
pub fn check_full_disk_access() -> bool {
8-
let mail_path = dirs::home_dir().map(|h| h.join("Library/Mail")).unwrap_or_default();
116+
for path in fda_probe_files() {
117+
match try_read_byte(&path) {
118+
Ok(()) => {
119+
log::debug!(target: "fda_probe", "FDA probe: read OK on {:?} → FDA granted", path);
120+
return true;
121+
}
122+
Err(e) if e.kind() == ErrorKind::PermissionDenied => {
123+
log::debug!(target: "fda_probe", "FDA probe: PermissionDenied on {:?} via read() → FDA NOT granted; firing extra triggers", path);
124+
// Best-effort extra triggers — we don't care about results,
125+
// only that tccd hears about us through different syscall
126+
// paths. Each one is independently logged so we can see in
127+
// the TCC log which one (if any) finally registers the bundle.
128+
match try_mmap_byte(&path) {
129+
Ok(()) => {
130+
log::debug!(target: "fda_probe", "FDA probe extra: mmap OK on {:?} (FDA actually granted? unexpected)", path)
131+
}
132+
Err(e) => {
133+
log::debug!(target: "fda_probe", "FDA probe extra: mmap on {:?} → {} ({:?})", path, e, e.kind())
134+
}
135+
}
136+
match try_nsdata_read(&path) {
137+
Ok(()) => {
138+
log::debug!(target: "fda_probe", "FDA probe extra: NSData OK on {:?} (FDA actually granted? unexpected)", path)
139+
}
140+
Err(e) => {
141+
log::debug!(target: "fda_probe", "FDA probe extra: NSData on {:?} → {} ({:?})", path, e, e.kind())
142+
}
143+
}
144+
match try_read_dir_parent(&path) {
145+
Ok(()) => log::debug!(target: "fda_probe", "FDA probe extra: read_dir(parent of {:?}) OK", path),
146+
Err(e) => {
147+
log::debug!(target: "fda_probe", "FDA probe extra: read_dir(parent of {:?}) → {} ({:?})", path, e, e.kind())
148+
}
149+
}
150+
return false;
151+
}
152+
Err(e) => {
153+
log::debug!(target: "fda_probe", "FDA probe: skipping {:?}: {} ({:?})", path, e, e.kind());
154+
continue;
155+
}
156+
}
157+
}
158+
log::warn!(target: "fda_probe", "FDA probe: no candidate path produced a definitive signal — treating as no FDA");
159+
// No probed file existed. Treat as "no FDA" — better to show the prompt
160+
// than skip it.
161+
false
162+
}
9163

10-
// Try to read the directory - if we can, we have FDA
11-
std::fs::read_dir(&mail_path).is_ok()
164+
/// Returns the macOS major version (e.g. `13` for Ventura, `14` for Sonoma).
165+
///
166+
/// Used by the onboarding modal to tailor copy + the deep-link host:
167+
/// Ventura+ has the new System Settings app with the
168+
/// `PrivacySecurity.extension` URL host and an alphabetical FDA list; older
169+
/// macOS uses the legacy System Preferences `preference.security` host with
170+
/// new entries appended at the end.
171+
#[tauri::command]
172+
#[specta::specta]
173+
pub fn get_macos_major_version() -> u32 {
174+
let Ok(output) = std::process::Command::new("sw_vers").arg("-productVersion").output() else {
175+
return 13; // assume modern if sw_vers is unavailable
176+
};
177+
let Ok(version) = String::from_utf8(output.stdout) else {
178+
return 13;
179+
};
180+
version
181+
.trim()
182+
.split('.')
183+
.next()
184+
.and_then(|s| s.parse().ok())
185+
.unwrap_or(13)
12186
}
13187

14-
/// Opens System Settings > Privacy & Security > Privacy.
188+
/// Opens System Settings directly on the Full Disk Access pane.
189+
///
190+
/// Picks the deep-link host based on macOS version: Ventura+ uses the new
191+
/// `PrivacySecurity.extension`, older macOS uses the legacy
192+
/// `preference.security` host. Both anchor on `Privacy_AllFiles`.
15193
#[tauri::command]
16194
#[specta::specta]
17195
pub fn open_privacy_settings() -> Result<(), String> {
196+
let url = if get_macos_major_version() >= 13 {
197+
"x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles"
198+
} else {
199+
"x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
200+
};
18201
std::process::Command::new("open")
19-
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy")
202+
.arg(url)
20203
.spawn()
21204
.map_err(|e| format!("Failed to open System Settings: {}", e))?;
22205
Ok(())

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ pub fn check_full_disk_access() -> bool {
1212
true
1313
}
1414

15+
/// Stub: macOS version is meaningless on Linux. Returns `0`.
16+
#[tauri::command]
17+
#[specta::specta]
18+
pub fn get_macos_major_version() -> u32 {
19+
0
20+
}
21+
1522
/// Opens the system privacy/security settings if a desktop environment is available.
1623
#[tauri::command]
1724
#[specta::specta]

apps/desktop/src-tauri/src/stubs/permissions.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ pub fn check_full_disk_access() -> bool {
1313
true
1414
}
1515

16+
/// Stub: macOS version is meaningless on this platform. Returns `0`.
17+
#[tauri::command]
18+
#[specta::specta]
19+
pub fn get_macos_major_version() -> u32 {
20+
0
21+
}
22+
1623
/// Opens privacy settings (stub: no-op, returns error).
1724
///
1825
/// There's no equivalent to macOS System Settings > Privacy on Linux.

apps/desktop/src/lib/ipc/bindings.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1556,11 +1556,29 @@ export const commands = {
15561556
// Tauri command: returns the current system text-size multiplier.
15571557
getSystemTextSizeMultiplier: () => __TAURI_INVOKE<number>('get_system_text_size_multiplier'),
15581558
/**
1559-
* Checks if the app has full disk access by probing ~/Library/Mail.
1560-
* This is a standard technique used by macOS apps - Mail is always protected.
1559+
* Checks if the app has full disk access by probing TCC-protected files.
1560+
*
1561+
* Probing is also how the bundle gets registered with TCC, which is what
1562+
* makes Cmdr show up in the Full Disk Access list in System Settings.
15611563
*/
15621564
checkFullDiskAccess: () => __TAURI_INVOKE<boolean>('check_full_disk_access'),
1563-
// Opens System Settings > Privacy & Security > Privacy.
1565+
/**
1566+
* Returns the macOS major version (e.g. `13` for Ventura, `14` for Sonoma).
1567+
*
1568+
* Used by the onboarding modal to tailor copy + the deep-link host:
1569+
* Ventura+ has the new System Settings app with the
1570+
* `PrivacySecurity.extension` URL host and an alphabetical FDA list; older
1571+
* macOS uses the legacy System Preferences `preference.security` host with
1572+
* new entries appended at the end.
1573+
*/
1574+
getMacosMajorVersion: () => __TAURI_INVOKE<number>('get_macos_major_version'),
1575+
/**
1576+
* Opens System Settings directly on the Full Disk Access pane.
1577+
*
1578+
* Picks the deep-link host based on macOS version: Ventura+ uses the new
1579+
* `PrivacySecurity.extension`, older macOS uses the legacy
1580+
* `preference.security` host. Both anchor on `Privacy_AllFiles`.
1581+
*/
15641582
openPrivacySettings: () => typedError<null, string>(__TAURI_INVOKE('open_privacy_settings')),
15651583
// Opens System Settings > Appearance.
15661584
openAppearanceSettings: () => typedError<null, string>(__TAURI_INVOKE('open_appearance_settings')),

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ Access in macOS System Settings.
1515

1616
Two actions are available:
1717

18-
- **Open System Settings** — calls `openPrivacySettings()` via IPC, then shows a follow-up hint to restart the app.
18+
- **Open System Settings** — re-runs `checkFullDiskAccess()` (so TCC has a fresh registration of the bundle and the Cmdr
19+
row appears in the FDA list), then calls `openPrivacySettings()` via IPC, then shows a follow-up hint to restart the
20+
app. The IPC deep-links straight to the Full Disk Access pane (not the Privacy category list).
1921
- **Deny** — saves `fullDiskAccessChoice: 'deny'` to settings, calls `startIndexingAfterFdaDecision()` so the indexer
2022
starts within this session, then calls `onComplete()` to dismiss.
2123

@@ -86,9 +88,20 @@ apps (VS Code, iTerm2) do.
8688
- Tauri provides no callback for when the user finishes in System Preferences. The app cannot detect the grant
8789
automatically. The post-click hint tells the user to restart manually.
8890
- Uses `dialogId="full-disk-access"` on `ModalDialog`, so MCP dialog tracking is automatic.
91+
- **TCC's registration hook fires on `open()`, not `opendir()`.** A `read_dir` against a protected directory may be
92+
silently denied without ever adding the bundle to the Full Disk Access list — leaving the user with no row to toggle
93+
on. The probe in `permissions.rs` opens specific protected _files_ (`~/Library/Safari/Bookmarks.plist`,
94+
`~/Library/Mail/V10/MailData/Envelope Index`, `~/Library/Messages/chat.db`, etc.) and walks them in order until one
95+
returns either `Ok` or `PermissionDenied`. `NotFound` doesn't trigger TCC, so we keep walking. The component re-runs
96+
`checkFullDiskAccess()` right before `openPrivacySettings()` so the registration is fresh when the Settings pane
97+
loads.
98+
- **Deep-link host changed in Ventura.** macOS 13+ uses `com.apple.settings.PrivacySecurity.extension`; older macOS uses
99+
`com.apple.preference.security`. Both anchor on `Privacy_AllFiles`. `open_privacy_settings` picks the right one via
100+
`get_macos_major_version`. The same version informs the modal copy: macOS 12 and older append new FDA entries at the
101+
end of the list (instead of alphabetical), so the "find Cmdr" instruction adjusts.
89102

90103
## Dependencies
91104

92-
- `$lib/tauri-commands``openPrivacySettings`
105+
- `$lib/tauri-commands``checkFullDiskAccess`, `getMacosMajorVersion`, `openPrivacySettings`
93106
- `$lib/settings-store``saveSettings`
94107
- `$lib/ui``ModalDialog`, `Button`

apps/desktop/src/lib/onboarding/FullDiskAccessPrompt.a11y.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { expectNoA11yViolations } from '$lib/test-a11y'
1212
vi.mock('$lib/tauri-commands', () => ({
1313
notifyDialogOpened: vi.fn(() => Promise.resolve()),
1414
notifyDialogClosed: vi.fn(() => Promise.resolve()),
15+
checkFullDiskAccess: vi.fn(() => Promise.resolve(false)),
16+
getMacosMajorVersion: vi.fn(() => Promise.resolve(14)),
1517
openPrivacySettings: vi.fn(() => Promise.resolve()),
1618
}))
1719

0 commit comments

Comments
 (0)