Skip to content

Commit 19e992d

Browse files
committed
Onboarding: get Cmdr into the Full Disk Access list on macOS 13+ by probing a protected directory
On macOS 13+ (Tahoe especially) a notarized app no longer appears in System Settings → Privacy & Security → Full Disk Access from our probe, so users had to add Cmdr by hand with "+". Traced the cause against Path Finder: its whole FDA probe is a raw `open()` on the `~/Library/Mail` *directory*, and it lands in the list the instant it does. A denied file `read()` (what Cmdr did) stopped registering notarized bundles on Tahoe; a denied directory `open()` still does. - `permissions.rs`: on a denied probe, `check_full_disk_access` now also fires a raw `open()` on each existing protected directory (`fda_probe_dirs`: `~/Library/Mail`, `~/Library/Safari`, `~/Library/Messages`, and the always-present TCC dir) via the new `try_open_path`. Detection logic is unchanged: the file reads still decide granted/denied, so there's no behavior risk there. We keep the file/`mmap`/`NSData`/`read_dir` triggers for macOS 12 and earlier, where the file `read()` is what registered. - Corrected the now-falsified docs: the old "Tahoe short-circuits `read()` denials" framing in `onboarding/DETAILS.md` and `docs/architecture.md` was wrong; it's specifically that file reads don't register but directory opens do. - The quiet 500 ms poller stays side-effect-free (file reads only); registration rides on the heavy probe, which fires at boot, on onboarding mount, and right before opening System Settings. Verification is deferred to the next release: this only manifests on a real notarized build, so reset the TCC state (`tccutil reset SystemPolicyAllFiles com.veszelovszki.cmdr`) and confirm Cmdr appears in the list after launch. The "+" step-tip stays as a backstop.
1 parent 3bcbc28 commit 19e992d

4 files changed

Lines changed: 79 additions & 29 deletions

File tree

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

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,20 @@ use std::io::{ErrorKind, Read};
66
use std::os::unix::ffi::OsStrExt;
77
use std::path::{Path, PathBuf};
88

9-
/// Specific TCC-protected files we probe to determine FDA status.
9+
/// Specific TCC-protected files we probe to determine FDA status, and (on
10+
/// older macOS) to register the bundle in the Full Disk Access list.
1011
///
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`.
12+
/// We `open()` + `read()` actual *files*: a denied `read()` is what added the
13+
/// bundle to System Settings → Privacy & Security → Full Disk Access on macOS
14+
/// 12 and earlier. On macOS 13+ (Tahoe especially) that stopped working for
15+
/// notarized apps: the denied file `read()` is refused without listing the
16+
/// app, and the access that DOES register is now a raw `open()` on a protected
17+
/// *directory* (see `fda_probe_dirs`). We keep both: file reads for old macOS,
18+
/// directory opens for current macOS.
1819
///
1920
/// 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).
21+
/// because `NotFound` doesn't reach TCC. Once we hit one, the read either
22+
/// succeeds (FDA granted) or returns `PermissionDenied` (not granted).
2323
fn fda_probe_files() -> Vec<PathBuf> {
2424
let Some(home) = dirs::home_dir() else {
2525
return Vec::new();
@@ -34,6 +34,27 @@ fn fda_probe_files() -> Vec<PathBuf> {
3434
]
3535
}
3636

37+
/// Protected TCC *directories* we `open()` (read-only, no read) to register the
38+
/// bundle in the Full Disk Access list on macOS 13+ (Ventura/Sonoma/Tahoe).
39+
///
40+
/// On Tahoe a denied file `read()` no longer lists a notarized bundle, but a
41+
/// raw `open()` on a TCC-protected directory still does (verified against Path
42+
/// Finder, which polls `open(~/Library/Mail)` and lands in the list the instant
43+
/// it does). Use a raw `open()`, NOT `opendir`/`read_dir`: the latter doesn't
44+
/// register. The TCC dir always exists; the rest cover the common case where
45+
/// the user has Mail/Safari/Messages set up.
46+
fn fda_probe_dirs() -> Vec<PathBuf> {
47+
let Some(home) = dirs::home_dir() else {
48+
return Vec::new();
49+
};
50+
vec![
51+
home.join("Library/Mail"), // present for Mail users
52+
home.join("Library/Safari"), // present after Safari use
53+
home.join("Library/Messages"), // present for Messages users
54+
home.join("Library/Application Support/com.apple.TCC"), // always exists, TCC-protected
55+
]
56+
}
57+
3758
/// Tries to open `path` and read at least one byte from it. The read is what
3859
/// trips TCC; `open()` alone has been observed not to register the bundle.
3960
fn try_read_byte(path: &Path) -> std::io::Result<()> {
@@ -46,6 +67,15 @@ fn try_read_byte(path: &Path) -> std::io::Result<()> {
4667
Ok(())
4768
}
4869

70+
/// Tries to `open()` `path` read-only without reading from it. This is the
71+
/// access that lists the bundle in Full Disk Access on macOS 13+ when `path` is
72+
/// a protected directory (mirrors Path Finder's `open(~/Library/Mail)`). Works
73+
/// on dirs and files; we deliberately don't `read()` (a directory read returns
74+
/// `EISDIR`). The `Err(PermissionDenied)` case is the FDA denial that registers.
75+
fn try_open_path(path: &Path) -> std::io::Result<()> {
76+
File::open(path).map(|_| ())
77+
}
78+
4979
/// Tries to mmap the first byte of `path`. Different syscall path than
5080
/// `read()` (mmap goes through the VM subsystem); on some macOS versions
5181
/// this is observed to trigger tccd registration where plain `read()`
@@ -181,11 +211,12 @@ pub fn check_full_disk_access_quiet() -> bool {
181211
///
182212
/// Probing is also how the bundle gets registered with TCC, which is what
183213
/// makes Cmdr show up in the Full Disk Access list in System Settings. On
184-
/// macOS 26 (Tahoe), the kernel/sandbox can short-circuit `read()` denials
185-
/// without consulting tccd, leaving the bundle out of the FDA list. To
186-
/// maximize the chance one of the access paths threads the needle, on a
187-
/// denial we fire all three: raw `read`, `mmap`, `NSData`, plus a
188-
/// `read_dir` of the parent directory.
214+
/// macOS 13+ (Tahoe especially) a denied file `read()` no longer lists a
215+
/// notarized bundle: the access that registers is a raw `open()` on a
216+
/// protected *directory* (what Path Finder uses). So on a denial we fire every
217+
/// trigger we know: the legacy file `mmap` / `NSData` / parent `read_dir` (old
218+
/// macOS), plus a directory `open()` on each protected dir (macOS 13+; see
219+
/// `fda_probe_dirs`).
189220
///
190221
/// For repeated, side-effect-free polling (e.g. the onboarding grant
191222
/// detector), use `check_full_disk_access_quiet` instead, which skips these
@@ -230,6 +261,20 @@ pub fn check_full_disk_access() -> bool {
230261
log::debug!(target: "fda_probe", "FDA probe extra: read_dir(parent of {:?}) → {} ({:?})", path, e, e.kind())
231262
}
232263
}
264+
// macOS 13+/Tahoe: a raw open() on a protected DIRECTORY is the
265+
// access that actually lists the bundle in Full Disk Access (the
266+
// file read above no longer registers notarized apps there). Fire
267+
// it on every existing protected dir. See `fda_probe_dirs`.
268+
for dir in fda_probe_dirs() {
269+
match try_open_path(&dir) {
270+
Ok(()) => {
271+
log::debug!(target: "fda_probe", "FDA probe extra: open dir {:?} OK (FDA actually granted? unexpected)", dir)
272+
}
273+
Err(e) => {
274+
log::debug!(target: "fda_probe", "FDA probe extra: open dir {:?} → {} ({:?})", dir, e, e.kind())
275+
}
276+
}
277+
}
233278
return false;
234279
}
235280
Err(e) => {

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2214,11 +2214,12 @@ export const commands = {
22142214
*
22152215
* Probing is also how the bundle gets registered with TCC, which is what
22162216
* makes Cmdr show up in the Full Disk Access list in System Settings. On
2217-
* macOS 26 (Tahoe), the kernel/sandbox can short-circuit `read()` denials
2218-
* without consulting tccd, leaving the bundle out of the FDA list. To
2219-
* maximize the chance one of the access paths threads the needle, on a
2220-
* denial we fire all three: raw `read`, `mmap`, `NSData`, plus a
2221-
* `read_dir` of the parent directory.
2217+
* macOS 13+ (Tahoe especially) a denied file `read()` no longer lists a
2218+
* notarized bundle: the access that registers is a raw `open()` on a
2219+
* protected *directory* (what Path Finder uses). So on a denial we fire every
2220+
* trigger we know: the legacy file `mmap` / `NSData` / parent `read_dir` (old
2221+
* macOS), plus a directory `open()` on each protected dir (macOS 13+; see
2222+
* `fda_probe_dirs`).
22222223
*
22232224
* For repeated, side-effect-free polling (e.g. the onboarding grant
22242225
* detector), use `check_full_disk_access_quiet` instead, which skips these

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -333,11 +333,14 @@ wizard's footer remains consistent for the other steps (Back + Next / Finish / R
333333
- **Deep-link host changed in Ventura.** macOS 13+ uses `com.apple.settings.PrivacySecurity.extension`; older macOS uses
334334
`com.apple.preference.security`. `openPrivacySettings()` picks via `get_macos_major_version`. The same version informs
335335
the modal copy: macOS 12 and older append new FDA entries at the end of the list (instead of alphabetical).
336-
- **macOS 26 (Tahoe) FDA auto-add is broken.** Even with a notarized Developer ID build at `/Applications/Cmdr.app`, the
337-
kernel / sandbox can short-circuit `read()` denials on TCC-protected paths without consulting `tccd`, meaning Cmdr may
338-
not enter the FDA list automatically. The "+" button fallback (documented in step 1's `step-tip`) is the user-side
339-
workaround. References: [Apple Developer Forums #809549](https://developer.apple.com/forums/thread/809549),
340-
[Backrest issue #986](https://github.com/garethgeorge/backrest/issues/986),
336+
- **macOS 13+ (Tahoe) lists the app on a directory `open()`, not a file `read()`.** On macOS 12 a denied file `read()`
337+
of a TCC-protected path registered a notarized bundle in the FDA list. On macOS 13+ (Tahoe especially) that read is
338+
refused without listing the app; the access that still registers is a raw `open()` on a protected _directory_ (traced
339+
from Path Finder, which polls `open(~/Library/Mail)` and lands in the list the instant it does). So
340+
`check_full_disk_access` in `permissions.rs` fires directory opens (`fda_probe_dirs`: `~/Library/Mail`, the TCC dir,
341+
etc.) alongside the legacy file `mmap` / `NSData` / `read_dir` triggers. The "+" button fallback (step 1's `step-tip`)
342+
stays as a backstop for any machine that still doesn't list Cmdr. References:
343+
[Apple Developer Forums #809549](https://developer.apple.com/forums/thread/809549),
341344
[Apple Developer Forums #757768](https://developer.apple.com/forums/thread/757768).
342345
- **The wizard renders the app behind it.** First launch lands on `~`, so what peeks through the backdrop is friendly.
343346
No "white screen until wizard done" code path.

docs/architecture.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,10 @@ Rules that cut across many modules. All existing commands follow these; apply th
202202

203203
- **Full Disk Access**: checked by trying to read 1 byte from a list of TCC-protected files
204204
(`~/Library/Safari/History.db`, `~/Library/Mail/V10/MailData/Envelope Index`, etc.) until one returns either `Ok` (FDA
205-
granted) or `PermissionDenied` (denied; bundle gets registered with TCC). On denial, also fires `mmap` +
206-
`NSData dataWithContentsOfFile:` + `read_dir` of the parent (multi-trigger fallback because macOS 26 (Tahoe) can
207-
short-circuit `read()` denials without consulting tccd). Prompt on first launch. See
205+
granted) or `PermissionDenied` (denied). Listing the app in System Settings is a separate concern: on macOS 12 the
206+
denied file `read()` registered it, but on macOS 13+ (Tahoe) only a raw `open()` on a protected _directory_
207+
(`~/Library/Mail`, the TCC dir, etc.) registers a notarized app, so on denial we fire that plus the legacy `mmap` /
208+
`NSData` / `read_dir` triggers. Prompt on first launch. See `permissions.rs` and
208209
`apps/desktop/src/lib/onboarding/CLAUDE.md`.
209210
- **Keychain**: stores network credentials and trial state. Uses `security-framework` crate.
210211
- **copyfile(3)**: preserves xattrs, ACLs, resource forks. `COPYFILE_CLONE` for instant APFS clones.

0 commit comments

Comments
 (0)