|
1 | 1 | //! macOS permission checking and system settings helpers. |
2 | 2 |
|
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. |
5 | 113 | #[tauri::command] |
6 | 114 | #[specta::specta] |
7 | 115 | 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 | +} |
9 | 163 |
|
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) |
12 | 186 | } |
13 | 187 |
|
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`. |
15 | 193 | #[tauri::command] |
16 | 194 | #[specta::specta] |
17 | 195 | 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 | + }; |
18 | 201 | std::process::Command::new("open") |
19 | | - .arg("x-apple.systempreferences:com.apple.preference.security?Privacy") |
| 202 | + .arg(url) |
20 | 203 | .spawn() |
21 | 204 | .map_err(|e| format!("Failed to open System Settings: {}", e))?; |
22 | 205 | Ok(()) |
|
0 commit comments