1use 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
29pub struct PermissionFlowController {
31 #[cfg(target_os = "macos")]
32 pointer: NonNull<c_void>,
33 not_send_or_sync: PhantomData<Rc<()>>,
34}
35
36impl PermissionFlowController {
37 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 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
125pub struct Permission(u8);
126
127impl Permission {
128 pub const ACCESSIBILITY: Permission = Permission(1);
130 pub const INPUT_MONITORING: Permission = Permission(2);
132 pub const SCREEN_RECORDING: Permission = Permission(3);
134 pub const APP_MANAGEMENT: Permission = Permission(4);
136 pub const BLUETOOTH: Permission = Permission(5);
138 pub const DEVELOPER_TOOLS: Permission = Permission(6);
140 pub const FULL_DISK_ACCESS: Permission = Permission(7);
142 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 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#[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 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#[derive(Clone)]
232pub struct StartFlowOptions {
233 permission: Permission,
234 app_path: AppPath,
235 use_click_source_frame: bool,
236}
237
238impl StartFlowOptions {
239 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 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 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#[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
296impl Drop for PermissionFlowController {
299 fn drop(&mut self) {
300 #[cfg(target_os = "macos")]
301 {
302 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(¤t_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}