Skip to content

Commit b262f4e

Browse files
committed
feat(coreaudio): add macOS system audio permission helpers
1 parent 2da4ee1 commit b262f4e

File tree

6 files changed

+96
-1
lines changed

6 files changed

+96
-1
lines changed

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ objc2 = { version = "0.6" }
139139

140140
[target.'cfg(target_os = "macos")'.dependencies]
141141
jack = { version = "0.13", optional = true }
142+
libloading = "0.8"
143+
block2 = "0.6"
144+
objc2-core-foundation = "0.3"
142145

143146
[target.'cfg(target_os = "ios")'.dependencies]
144147
objc2-avf-audio = { version = "0.3", default-features = false, features = [

src/host/coreaudio/macos/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use property_listener::AudioObjectPropertyListener;
2020
mod device;
2121
pub mod enumerate;
2222
mod loopback;
23+
pub mod permissions;
2324
mod property_listener;
2425
pub use device::Device;
2526

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//! macOS system audio recording permission helpers.
2+
//!
3+
//! These functions check and request the "System Audio Recording" permission
4+
//! (`kTCCServiceAudioCapture`) via the private TCC framework — required for
5+
//! loopback recording via [`default_output_device`](super::enumerate::default_output_device).
6+
7+
use block2::StackBlock;
8+
use libloading::{Library, Symbol};
9+
use objc2_core_foundation::{CFRetained, CFString};
10+
use std::ffi::c_void;
11+
12+
const TCC_FRAMEWORK: &str = "/System/Library/PrivateFrameworks/TCC.framework/Versions/A/TCC";
13+
const TCC_SERVICE: &str = "kTCCServiceAudioCapture";
14+
15+
fn load_tcc() -> Option<Library> {
16+
unsafe { Library::new(TCC_FRAMEWORK) }.ok()
17+
}
18+
19+
fn tcc_service() -> CFRetained<CFString> {
20+
CFString::from_str(TCC_SERVICE)
21+
}
22+
23+
/// Silently check whether system audio recording permission is granted.
24+
/// Returns `true` if already granted, `false` otherwise. No UI is shown.
25+
pub fn check_system_audio_permission() -> bool {
26+
let Some(lib) = load_tcc() else { return false };
27+
unsafe {
28+
let Ok(preflight): Result<
29+
Symbol<unsafe extern "C" fn(*const c_void, *const c_void) -> u32>,
30+
_,
31+
> = lib.get(b"TCCAccessPreflight\0") else {
32+
return false;
33+
};
34+
let service = tcc_service();
35+
preflight(&*service as *const _ as *const c_void, std::ptr::null()) == 0
36+
}
37+
}
38+
39+
/// Request system audio recording permission, showing the system prompt if needed.
40+
///
41+
/// **Blocking** — does not return until the user responds.
42+
/// Returns `false` immediately (without showing UI) if previously denied —
43+
/// call [`open_system_audio_settings`] in that case.
44+
pub fn request_system_audio_permission() -> bool {
45+
let Some(lib) = load_tcc() else { return false };
46+
unsafe {
47+
let Ok(request_fn): Result<
48+
Symbol<unsafe extern "C" fn(*const c_void, *const c_void, *const c_void)>,
49+
_,
50+
> = lib.get(b"TCCAccessRequest\0") else {
51+
return false;
52+
};
53+
54+
let (tx, rx) = std::sync::mpsc::sync_channel::<bool>(1);
55+
// Store as usize (Copy) so TCC's internal block memcpy doesn't double-drop the sender.
56+
let tx_ptr = Box::into_raw(Box::new(tx)) as usize;
57+
58+
let completion = StackBlock::new(move |granted: u8| {
59+
let tx = Box::from_raw(tx_ptr as *mut std::sync::mpsc::SyncSender<bool>);
60+
tx.send(granted != 0).ok();
61+
});
62+
63+
let service = tcc_service();
64+
request_fn(
65+
&*service as *const _ as *const c_void,
66+
std::ptr::null(),
67+
&completion as *const _ as *const c_void,
68+
);
69+
70+
rx.recv().unwrap_or(false)
71+
}
72+
}
73+
74+
/// Open Privacy & Security > System Audio Recording in System Settings.
75+
///
76+
/// Call this when [`request_system_audio_permission`] returns `false` (previously denied).
77+
pub fn open_system_audio_settings() {
78+
std::process::Command::new("open")
79+
.arg("x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AudioCapture")
80+
.spawn()
81+
.ok();
82+
}

src/host/coreaudio/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use crate::{BackendSpecificError, SampleFormat, StreamConfig};
1515
#[cfg(target_os = "ios")]
1616
mod ios;
1717
#[cfg(target_os = "macos")]
18-
mod macos;
18+
pub mod macos;
1919

2020
#[cfg(target_os = "ios")]
2121
#[allow(unused_imports)]

src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ pub use device_description::{
183183
DeviceDescription, DeviceDescriptionBuilder, DeviceDirection, DeviceType, InterfaceType,
184184
};
185185
pub use error::*;
186+
#[cfg(target_os = "macos")]
187+
pub use host::coreaudio::macos::permissions::{
188+
check_system_audio_permission, open_system_audio_settings, request_system_audio_permission,
189+
};
186190
pub use platform::{
187191
available_hosts, default_host, host_from_id, Device, Devices, Host, HostId, Stream,
188192
SupportedInputConfigs, SupportedOutputConfigs, ALL_HOSTS,

src/platform/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,11 @@ mod platform_impl {
748748

749749
#[cfg(any(target_os = "macos", target_os = "ios"))]
750750
mod platform_impl {
751+
#[cfg(target_os = "macos")]
752+
#[cfg_attr(docsrs, doc(cfg(target_os = "macos")))]
753+
pub use crate::host::coreaudio::macos::permissions::{
754+
check_system_audio_permission, open_system_audio_settings, request_system_audio_permission,
755+
};
751756
#[cfg_attr(docsrs, doc(cfg(any(target_os = "macos", target_os = "ios"))))]
752757
pub use crate::host::coreaudio::Host as CoreAudioHost;
753758
#[cfg(all(feature = "jack", target_os = "macos"))]

0 commit comments

Comments
 (0)