Skip to main content

permission_flow/
lib.rs

1//! Rust bindings for the macOS permission guidance flow shipped in this
2//! workspace's Swift/AppKit implementation.
3//!
4//! On non-macOS targets, this crate intentionally compiles as a no-op shim so
5//! cross-platform workspaces can still build. In that mode, controller methods
6//! quietly succeed and permission status resolves to `Unknown`.
7
8use std::ffi::{CStr, CString, NulError};
9use std::fmt;
10use std::marker::PhantomData;
11use std::path::Path;
12use std::rc::Rc;
13
14#[cfg(target_os = "macos")]
15use std::ffi::OsString;
16#[cfg(target_os = "macos")]
17use std::ffi::c_void;
18#[cfg(target_os = "macos")]
19use std::mem::{MaybeUninit, size_of};
20#[cfg(target_os = "macos")]
21use std::os::raw::{c_char, c_int};
22#[cfg(target_os = "macos")]
23use std::os::unix::ffi::{OsStrExt, OsStringExt};
24#[cfg(target_os = "macos")]
25use std::path::PathBuf;
26#[cfg(target_os = "macos")]
27use std::ptr::{NonNull, null_mut};
28
29/// Main controller for driving the Swift permission flow from Rust.
30pub struct PermissionFlowController {
31    #[cfg(target_os = "macos")]
32    pointer: NonNull<c_void>,
33    not_send_or_sync: PhantomData<Rc<()>>,
34}
35
36impl PermissionFlowController {
37    /// Creates a controller for starting permission flows.
38    ///
39    /// This must be called on the macOS main thread.
40    pub fn new() -> Result<Self, NewControllerError> {
41        #[cfg(target_os = "macos")]
42        {
43            let mut controller = null_mut();
44            let status = unsafe { permission_flow_controller_new(&mut controller) };
45            if status != OK_STATUS {
46                assert_eq!(
47                    status, NOT_MAIN_THREAD_ERROR_STATUS,
48                    "The shim should only report a non-main-thread error in this version"
49                );
50                return Err(NewControllerError(()));
51            }
52
53            // If this ever happens, the Rust and Swift sides have drifted out of contract.
54            let pointer =
55                NonNull::new(controller).expect("Shim returned ok for an invalid pointer");
56
57            Ok(Self {
58                pointer,
59                not_send_or_sync: PhantomData,
60            })
61        }
62
63        #[cfg(not(target_os = "macos"))]
64        {
65            Ok(Self {
66                not_send_or_sync: PhantomData,
67            })
68        }
69    }
70
71    /// Starts a permission flow.
72    ///
73    /// The underlying library only keeps one panel open at a time, so starting
74    /// a new flow closes the previous one.
75    pub fn start_flow(&self, options: StartFlowOptions) -> Result<(), StartPermissionFlowError> {
76        #[cfg(target_os = "macos")]
77        {
78            let status = unsafe {
79                permission_flow_controller_start_flow(
80                    self.pointer.as_ptr(),
81                    options.permission.as_ffi(),
82                    options.app_path.path.as_ptr(),
83                    if options.use_click_source_frame { 1 } else { 0 },
84                )
85            };
86
87            if status != OK_STATUS {
88                Err(StartPermissionFlowError(status))
89            } else {
90                Ok(())
91            }
92        }
93
94        #[cfg(not(target_os = "macos"))]
95        {
96            let _ = options;
97            Ok(())
98        }
99    }
100
101    /// Stops the current permission flow, if one is active.
102    pub fn stop_current_flow(&self) -> Result<(), StopPermissionFlowError> {
103        #[cfg(target_os = "macos")]
104        {
105            let status = unsafe { permission_flow_controller_close_panel(self.pointer.as_ptr()) };
106
107            if status != OK_STATUS {
108                Err(StopPermissionFlowError(status))
109            } else {
110                Ok(())
111            }
112        }
113
114        #[cfg(not(target_os = "macos"))]
115        {
116            Ok(())
117        }
118    }
119}
120
121// ================================================================ MODELS ================================================================ //
122
123/// A macOS privacy permission that can be opened through the Swift shim.
124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
125pub struct Permission(u8);
126
127impl Permission {
128    /// Privacy & Security > Accessibility.
129    pub const ACCESSIBILITY: Permission = Permission(1);
130    /// Privacy & Security > Input Monitoring.
131    pub const INPUT_MONITORING: Permission = Permission(2);
132    /// Privacy & Security > Screen & System Audio Recording.
133    pub const SCREEN_RECORDING: Permission = Permission(3);
134    /// Privacy & Security > App Management.
135    pub const APP_MANAGEMENT: Permission = Permission(4);
136    /// Privacy & Security > Bluetooth.
137    pub const BLUETOOTH: Permission = Permission(5);
138    /// Privacy & Security > Developer Tools.
139    pub const DEVELOPER_TOOLS: Permission = Permission(6);
140    /// Privacy & Security > Full Disk Access.
141    pub const FULL_DISK_ACCESS: Permission = Permission(7);
142    /// Privacy & Security > Media & Apple Music.
143    pub const MEDIA_APPLE_MUSIC: Permission = Permission(8);
144
145    #[cfg(target_os = "macos")]
146    fn as_ffi(self) -> i8 {
147        self.0 as i8
148    }
149
150    fn display_name(self) -> &'static str {
151        match self {
152            Self::ACCESSIBILITY => "Accessibility",
153            Self::INPUT_MONITORING => "Input Monitoring",
154            Self::SCREEN_RECORDING => "Screen Recording",
155            Self::APP_MANAGEMENT => "App Management",
156            Self::BLUETOOTH => "Bluetooth",
157            Self::DEVELOPER_TOOLS => "Developer Tools",
158            Self::FULL_DISK_ACCESS => "Full Disk Access",
159            Self::MEDIA_APPLE_MUSIC => "Media & Apple Music",
160            _ => "Unknown Permission",
161        }
162    }
163
164    /// Returns the host application's current authorization state for this permission.
165    ///
166    /// This does not report whether an arbitrary target app already has the
167    /// permission. It only reports what the current host process or host app
168    /// can determine about its own authorization state.
169    pub fn authorization_state(
170        self,
171    ) -> Result<PermissionAuthorizationState, PermissionStatusError> {
172        #[cfg(target_os = "macos")]
173        {
174            let mut state = 0;
175            let status = unsafe { permission_flow_authorization_state(self.as_ffi(), &mut state) };
176
177            if status != OK_STATUS {
178                return Err(PermissionStatusError(status));
179            }
180
181            let state = match state {
182                AUTHORIZATION_GRANTED_STATE => PermissionAuthorizationState::Granted,
183                AUTHORIZATION_NOT_GRANTED_STATE => PermissionAuthorizationState::NotGranted,
184                AUTHORIZATION_UNKNOWN_STATE => PermissionAuthorizationState::Unknown,
185                AUTHORIZATION_CHECKING_STATE => PermissionAuthorizationState::Checking,
186                _ => panic!("Shim returned an invalid authorization state"),
187            };
188
189            Ok(state)
190        }
191
192        #[cfg(not(target_os = "macos"))]
193        {
194            Ok(PermissionAuthorizationState::Unknown)
195        }
196    }
197}
198
199/// A validated C-compatible app path passed through the FFI boundary.
200#[derive(Clone)]
201pub struct AppPath {
202    path: CString,
203}
204
205impl AppPath {
206    pub fn as_c_str(&self) -> &CStr {
207        &self.path
208    }
209
210    /// Returns a best-effort guess for the app bundle users are most likely
211    /// trying to grant permission to in the current launch context.
212    ///
213    /// If the current executable lives inside an `.app` bundle, that bundle is
214    /// returned. Otherwise, this walks up the parent process chain and returns
215    /// the first enclosing `.app` bundle it finds, which covers common cases
216    /// like Terminal, iTerm, or an IDE-integrated terminal.
217    pub fn suggested_host_app() -> Option<Self> {
218        #[cfg(target_os = "macos")]
219        {
220            suggested_host_app_path().and_then(|path| Self::try_from(path.as_path()).ok())
221        }
222
223        #[cfg(not(target_os = "macos"))]
224        {
225            None
226        }
227    }
228}
229
230/// Options for starting a permission flow.
231#[derive(Clone)]
232pub struct StartFlowOptions {
233    permission: Permission,
234    app_path: AppPath,
235    use_click_source_frame: bool,
236}
237
238impl StartFlowOptions {
239    /// Creates a new set of start-flow options.
240    pub fn new<P: Into<AppPath>>(permission: Permission, app_path: P) -> Self {
241        Self {
242            permission,
243            app_path: app_path.into(),
244            use_click_source_frame: true,
245        }
246    }
247
248    /// Sets whether the current mouse location should be used as the source
249    /// frame for the launch animation.
250    pub fn use_click_source_frame(mut self, use_click_source_frame: bool) -> Self {
251        self.use_click_source_frame = use_click_source_frame;
252        self
253    }
254
255    /// Disables the click-source-frame launch animation.
256    pub fn without_click_source_frame(mut self) -> Self {
257        self.use_click_source_frame = false;
258        self
259    }
260
261    pub fn permission(&self) -> Permission {
262        self.permission
263    }
264
265    pub fn app_path(&self) -> &AppPath {
266        &self.app_path
267    }
268
269    pub fn uses_click_source_frame(&self) -> bool {
270        self.use_click_source_frame
271    }
272}
273
274#[derive(Clone, Copy, Debug, Eq, PartialEq)]
275pub enum PermissionAuthorizationState {
276    Granted,
277    NotGranted,
278    Unknown,
279    Checking,
280}
281
282// ================================================================ ERRORS ================================================================ //
283
284#[derive(PartialEq, Debug)]
285pub struct NewControllerError(());
286
287#[derive(PartialEq, Debug)]
288pub struct StartPermissionFlowError(i8);
289
290#[derive(PartialEq, Debug)]
291pub struct StopPermissionFlowError(i8);
292
293#[derive(PartialEq, Debug)]
294pub struct PermissionStatusError(i8);
295
296// ================================================================ TRAIT_IMPLS ================================================================ //
297
298impl Drop for PermissionFlowController {
299    fn drop(&mut self) {
300        #[cfg(target_os = "macos")]
301        {
302            // SAFETY: PermissionFlowController is created on the main thread. Since
303            // it is neither Send nor Sync, it cannot be soundly moved away from the
304            // main thread. That means Drop, and practically every other method, also
305            // runs on the main thread.
306            let status = unsafe { permission_flow_controller_free(self.pointer.as_ptr()) };
307
308            debug_assert_eq!(status, OK_STATUS);
309        }
310    }
311}
312
313impl fmt::Display for Permission {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        f.write_str(self.display_name())
316    }
317}
318
319impl From<CString> for AppPath {
320    fn from(value: CString) -> Self {
321        Self { path: value }
322    }
323}
324
325impl From<&CStr> for AppPath {
326    fn from(value: &CStr) -> Self {
327        Self {
328            path: value.to_owned(),
329        }
330    }
331}
332
333impl TryFrom<&Path> for AppPath {
334    type Error = NulError;
335
336    fn try_from(path: &Path) -> Result<Self, NulError> {
337        #[cfg(unix)]
338        let bytes = path.as_os_str().as_bytes();
339        #[cfg(not(unix))]
340        let owned = path.to_string_lossy().into_owned();
341        #[cfg(not(unix))]
342        let bytes = owned.as_bytes();
343
344        Ok(Self {
345            path: CString::new(bytes)?,
346        })
347    }
348}
349
350impl TryFrom<&str> for AppPath {
351    type Error = NulError;
352
353    fn try_from(path: &str) -> Result<Self, NulError> {
354        Ok(Self {
355            path: CString::new(path.as_bytes())?,
356        })
357    }
358}
359
360impl fmt::Display for NewControllerError {
361    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362        f.write_str("PermissionFlowController::new must be called on the main thread")
363    }
364}
365
366impl fmt::Display for StartPermissionFlowError {
367    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368        write!(
369            f,
370            "PermissionFlowController::start_flow: {}",
371            format_error(self.0)
372        )
373    }
374}
375
376impl fmt::Display for StopPermissionFlowError {
377    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378        write!(
379            f,
380            "PermissionFlowController::stop_current_flow: {}",
381            format_error(self.0)
382        )
383    }
384}
385
386impl fmt::Display for PermissionStatusError {
387    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388        write!(
389            f,
390            "Permission::authorization_state: {}",
391            format_error(self.0)
392        )
393    }
394}
395
396impl std::error::Error for NewControllerError {}
397impl std::error::Error for StartPermissionFlowError {}
398impl std::error::Error for StopPermissionFlowError {}
399impl std::error::Error for PermissionStatusError {}
400
401#[cfg(target_os = "macos")]
402const OK_STATUS: i8 = 0;
403const INVALID_PERMISSION_ERROR_STATUS: i8 = 1;
404const NULL_CONTROLLER_ERROR_STATUS: i8 = 2;
405const NOT_MAIN_THREAD_ERROR_STATUS: i8 = 3;
406#[cfg(target_os = "macos")]
407const AUTHORIZATION_GRANTED_STATE: i8 = 0;
408#[cfg(target_os = "macos")]
409const AUTHORIZATION_NOT_GRANTED_STATE: i8 = 1;
410#[cfg(target_os = "macos")]
411const AUTHORIZATION_UNKNOWN_STATE: i8 = 2;
412#[cfg(target_os = "macos")]
413const AUTHORIZATION_CHECKING_STATE: i8 = 3;
414#[cfg(target_os = "macos")]
415const PROC_PIDTBSDINFO: c_int = 3;
416#[cfg(target_os = "macos")]
417const PROC_PIDPATHINFO_MAXSIZE: usize = 4096;
418#[cfg(target_os = "macos")]
419const MAXCOMLEN: usize = 16;
420
421fn format_error(err: i8) -> &'static str {
422    match err {
423        INVALID_PERMISSION_ERROR_STATUS => "invalid permission pane",
424        NULL_CONTROLLER_ERROR_STATUS => "controller pointer was null",
425        NOT_MAIN_THREAD_ERROR_STATUS => "permission-flow UI APIs must run on the main thread",
426        _ => "unknown error",
427    }
428}
429
430#[cfg(target_os = "macos")]
431fn suggested_host_app_path() -> Option<PathBuf> {
432    let current_executable = std::env::current_exe().ok()?;
433    if let Some(app_bundle) = enclosing_app_bundle(&current_executable) {
434        return Some(app_bundle.to_path_buf());
435    }
436
437    let mut pid = std::process::id() as c_int;
438    for _ in 0..32 {
439        let parent = parent_pid(pid)?;
440        if parent <= 1 || parent == pid {
441            break;
442        }
443
444        pid = parent;
445        let path = process_path(pid)?;
446        if let Some(app_bundle) = enclosing_app_bundle(&path) {
447            return Some(app_bundle.to_path_buf());
448        }
449    }
450
451    None
452}
453
454#[cfg(any(target_os = "macos", test))]
455fn enclosing_app_bundle(path: &Path) -> Option<&Path> {
456    path.ancestors().find(|ancestor| {
457        ancestor
458            .extension()
459            .and_then(|extension| extension.to_str())
460            .is_some_and(|extension| extension.eq_ignore_ascii_case("app"))
461    })
462}
463
464#[cfg(target_os = "macos")]
465fn parent_pid(pid: c_int) -> Option<c_int> {
466    let mut info = MaybeUninit::<ProcBsdInfo>::zeroed();
467    let size = size_of::<ProcBsdInfo>() as c_int;
468    let result = unsafe { proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, info.as_mut_ptr().cast(), size) };
469    if result != size {
470        return None;
471    }
472
473    Some(unsafe { info.assume_init() }.pbi_ppid as c_int)
474}
475
476#[cfg(target_os = "macos")]
477fn process_path(pid: c_int) -> Option<PathBuf> {
478    let mut buffer = [0_u8; PROC_PIDPATHINFO_MAXSIZE];
479    let result = unsafe { proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
480    if result <= 0 {
481        return None;
482    }
483
484    let bytes = unsafe { CStr::from_ptr(buffer.as_ptr().cast()) }.to_bytes();
485    if bytes.is_empty() {
486        return None;
487    }
488
489    Some(PathBuf::from(OsString::from_vec(bytes.to_vec())))
490}
491
492#[cfg(target_os = "macos")]
493unsafe extern "C" {
494    fn permission_flow_controller_new(controller_out: *mut *mut c_void) -> i8;
495    fn permission_flow_controller_free(controller: *mut c_void) -> i8;
496    fn permission_flow_controller_start_flow(
497        controller: *mut c_void,
498        permission: i8,
499        app_path: *const c_char,
500        use_click_source_frame: i8,
501    ) -> i8;
502    fn permission_flow_controller_close_panel(controller: *mut c_void) -> i8;
503    fn permission_flow_authorization_state(permission: i8, state_out: *mut i8) -> i8;
504    fn proc_pidinfo(
505        pid: c_int,
506        flavor: c_int,
507        arg: u64,
508        buffer: *mut c_void,
509        buffersize: c_int,
510    ) -> c_int;
511    fn proc_pidpath(pid: c_int, buffer: *mut c_void, buffersize: u32) -> c_int;
512}
513
514#[cfg(target_os = "macos")]
515#[repr(C)]
516struct ProcBsdInfo {
517    pbi_flags: u32,
518    pbi_status: u32,
519    pbi_xstatus: u32,
520    pbi_pid: u32,
521    pbi_ppid: u32,
522    pbi_uid: u32,
523    pbi_gid: u32,
524    pbi_ruid: u32,
525    pbi_rgid: u32,
526    pbi_svuid: u32,
527    pbi_svgid: u32,
528    rfu_1: u32,
529    pbi_comm: [u8; MAXCOMLEN],
530    pbi_name: [u8; 2 * MAXCOMLEN],
531    pbi_nfiles: u32,
532    pbi_pgid: u32,
533    pbi_pjobc: u32,
534    e_tdev: u32,
535    e_tpgid: u32,
536    pbi_nice: i32,
537    pbi_start_tvsec: u64,
538    pbi_start_tvusec: u64,
539}
540
541#[cfg(test)]
542mod tests {
543    use super::{
544        INVALID_PERMISSION_ERROR_STATUS, Permission, PermissionFlowController, StartFlowOptions,
545        StartPermissionFlowError, enclosing_app_bundle,
546    };
547    use std::path::Path;
548
549    #[test]
550    #[cfg(target_os = "macos")]
551    fn new_controller_returns_not_main_thread_on_worker_thread() {
552        let handle = std::thread::spawn(|| PermissionFlowController::new().err());
553        let result = handle.join().expect("worker thread panicked");
554        assert!(result.is_some());
555    }
556
557    #[test]
558    fn permission_authorization_state_is_available_on_worker_thread() {
559        let handle = std::thread::spawn(|| Permission::ACCESSIBILITY.authorization_state());
560        let result = handle.join().expect("worker thread panicked");
561
562        assert!(result.is_ok());
563    }
564
565    #[test]
566    fn enclosing_app_bundle_finds_nested_bundle() {
567        let path = Path::new("/Applications/RustRover.app/Contents/MacOS/rustrover");
568        let bundle = enclosing_app_bundle(path);
569
570        assert_eq!(bundle, Some(Path::new("/Applications/RustRover.app")));
571    }
572
573    #[test]
574    fn enclosing_app_bundle_returns_none_for_non_bundle_paths() {
575        let path = Path::new("/Users/example/project/target/debug/app");
576        assert_eq!(enclosing_app_bundle(path), None);
577    }
578
579    #[test]
580    fn start_flow_options_use_click_source_frame_by_default() {
581        let options = StartFlowOptions::new(Permission::ACCESSIBILITY, c"/Applications/Test.app");
582
583        assert!(options.uses_click_source_frame());
584    }
585
586    #[test]
587    fn start_flow_options_can_disable_click_source_frame() {
588        let options = StartFlowOptions::new(Permission::ACCESSIBILITY, c"/Applications/Test.app")
589            .without_click_source_frame();
590
591        assert!(!options.uses_click_source_frame());
592    }
593
594    #[test]
595    fn permission_display_names_are_human_readable() {
596        assert_eq!(
597            Permission::MEDIA_APPLE_MUSIC.to_string(),
598            "Media & Apple Music"
599        );
600    }
601
602    #[test]
603    #[cfg(target_os = "macos")]
604    #[ignore = "requires the macOS main thread, which the Rust test harness does not guarantee"]
605    fn start_controller_does_not_panic_on_invalid_permission() {
606        let controller = PermissionFlowController::new().unwrap();
607        let err = controller.start_flow(
608            StartFlowOptions::new(Permission(15), c"This App").without_click_source_frame(),
609        );
610
611        assert_eq!(
612            err,
613            Err(StartPermissionFlowError(INVALID_PERMISSION_ERROR_STATUS))
614        );
615    }
616}