diff --git a/core_engine/Cargo.toml b/core_engine/Cargo.toml index 3a8489c..275fa19 100644 --- a/core_engine/Cargo.toml +++ b/core_engine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "core_engine" -version = "0.1.1" +version = "0.3.0" edition = "2021" description = "Pure Rust audio processing engine for macloop" license = "MIT" @@ -13,7 +13,10 @@ audioadapter-buffers = "2.0.0" hound = "3.5.1" [target.'cfg(target_os = "macos")'.dependencies] +block2 = "0.6.2" bytemuck = "1.23.1" coreaudio-rs = "0.14.0" +objc2 = "0.6.4" objc2-audio-toolbox = "0.3.2" +objc2-av-foundation = { version = "0.3.2", features = ["AVCaptureDevice", "AVMediaFormat"] } screencapturekit = "1.5.4" diff --git a/core_engine/src/lib.rs b/core_engine/src/lib.rs index 322de07..f1bc6e9 100644 --- a/core_engine/src/lib.rs +++ b/core_engine/src/lib.rs @@ -4,6 +4,7 @@ pub mod engine; pub mod format; pub mod metrics; pub mod outputs; +pub mod permissions; pub mod processor; pub mod sources; @@ -26,6 +27,7 @@ pub use outputs::asr_sink::{ AsrSinkConfig, AsrSinkError, AsrSinkInput, AsrSinkMetricsSnapshot, }; pub use outputs::wav_file::{WavFileOutput, WavOutputError, WavSinkMetricsSnapshot}; +pub use permissions::{microphone_access, screen_capture_access}; pub use processor::{AudioProcessor, NodeId, OutputId, StreamId}; pub use sources::app_audio::{ AppAudioError, AppAudioSource, AppAudioSourceConfig, ApplicationInfo, diff --git a/core_engine/src/permissions.rs b/core_engine/src/permissions.rs new file mode 100644 index 0000000..73925fd --- /dev/null +++ b/core_engine/src/permissions.rs @@ -0,0 +1,82 @@ +//! TCC permission helpers for screen-recording and microphone access. +//! +//! These wrap the macOS privacy APIs so callers can check (and optionally +//! request) the permissions required before starting a capture session. + +/// Check (and optionally request) Screen Recording permission. +/// +/// When `prompt` is `false`, this performs a non-intrusive preflight check via +/// `CGPreflightScreenCaptureAccess`. When `prompt` is `true`, it calls +/// `CGRequestScreenCaptureAccess`, which triggers the system permission dialog +/// (or adds the app to the Screen Recording list) and returns whether access is +/// granted. +/// +/// Returns `true` if screen-capture access is currently granted. +#[cfg(target_os = "macos")] +pub fn screen_capture_access(prompt: bool) -> bool { + // These symbols are available on macOS 10.15+; this crate is macOS-only. + #[link(name = "CoreGraphics", kind = "framework")] + extern "C" { + fn CGPreflightScreenCaptureAccess() -> bool; + fn CGRequestScreenCaptureAccess() -> bool; + } + + unsafe { + if prompt { + CGRequestScreenCaptureAccess() + } else { + CGPreflightScreenCaptureAccess() + } + } +} + +/// Non-macOS fallback: there is no screen-capture TCC concept. +#[cfg(not(target_os = "macos"))] +pub fn screen_capture_access(_prompt: bool) -> bool { + false +} + +/// Check (and optionally request) microphone permission. +/// +/// Returns one of `"authorized"`, `"denied"`, `"restricted"`, +/// `"not_determined"`, or `"unknown"`. +/// +/// When `prompt` is `true` and the current status is `"not_determined"`, this +/// kicks off a non-blocking `requestAccessForMediaType` request (which shows the +/// system dialog). It does not wait for the user's response; the returned string +/// reflects the status at the time of the call. +#[cfg(target_os = "macos")] +pub fn microphone_access(prompt: bool) -> &'static str { + use block2::RcBlock; + use objc2::runtime::Bool; + use objc2_av_foundation::{AVAuthorizationStatus, AVCaptureDevice, AVMediaTypeAudio}; + + unsafe { + let media_type = match AVMediaTypeAudio { + Some(mt) => mt, + None => return "unknown", + }; + + let status = AVCaptureDevice::authorizationStatusForMediaType(media_type); + + if prompt && status == AVAuthorizationStatus::NotDetermined { + // Fire off the request without blocking on the async completion. + let handler = RcBlock::new(|_granted: Bool| {}); + AVCaptureDevice::requestAccessForMediaType_completionHandler(media_type, &handler); + } + + match status { + AVAuthorizationStatus::Authorized => "authorized", + AVAuthorizationStatus::Denied => "denied", + AVAuthorizationStatus::Restricted => "restricted", + AVAuthorizationStatus::NotDetermined => "not_determined", + _ => "unknown", + } + } +} + +/// Non-macOS fallback: there is no microphone TCC concept. +#[cfg(not(target_os = "macos"))] +pub fn microphone_access(_prompt: bool) -> &'static str { + "unknown" +} diff --git a/macloop/__init__.py b/macloop/__init__.py index c3b2142..e1b36a2 100644 --- a/macloop/__init__.py +++ b/macloop/__init__.py @@ -28,6 +28,7 @@ from ._macloop import list_applications as _list_applications from ._macloop import list_displays as _list_displays from ._macloop import list_microphones as _list_microphones +from ._macloop import microphone_access, screen_capture_access AudioSamples = Union[npt.NDArray[np.int16], npt.NDArray[np.float32]] @@ -764,4 +765,6 @@ def _resolve_wav_fd(file: Any) -> Tuple[int, bool]: "SyntheticSource", "WavSink", "WavSinkStats", + "microphone_access", + "screen_capture_access", ] diff --git a/macloop/__init__.pyi b/macloop/__init__.pyi index 672989c..36c01c2 100644 --- a/macloop/__init__.pyi +++ b/macloop/__init__.pyi @@ -167,3 +167,7 @@ class WavSink: def close(self) -> None: ... def __enter__(self) -> WavSink: ... def __exit__(self, exc_type: object, exc: object, tb: object) -> None: ... + + +def screen_capture_access(prompt: bool = False) -> bool: ... +def microphone_access(prompt: bool = False) -> str: ... diff --git a/macloop/_macloop.pyi b/macloop/_macloop.pyi index 09cbd02..28a000d 100644 --- a/macloop/_macloop.pyi +++ b/macloop/_macloop.pyi @@ -87,6 +87,8 @@ class _AudioEngineBackend: def list_microphones() -> list[dict[str, Any]]: ... def list_displays() -> list[dict[str, Any]]: ... def list_applications() -> list[dict[str, Any]]: ... +def screen_capture_access(prompt: bool = False) -> bool: ... +def microphone_access(prompt: bool = False) -> str: ... def _create_asr_sink( engine: _AudioEngineBackend, sink_id: str, diff --git a/python_ffi/Cargo.toml b/python_ffi/Cargo.toml index 40171fd..c03cea8 100644 --- a/python_ffi/Cargo.toml +++ b/python_ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "python_ffi" -version = "0.1.1" +version = "0.3.0" edition = "2021" description = "Python bindings and control plane for macloop" license = "MIT" diff --git a/python_ffi/src/lib.rs b/python_ffi/src/lib.rs index da9299e..177ce4f 100644 --- a/python_ffi/src/lib.rs +++ b/python_ffi/src/lib.rs @@ -1,6 +1,7 @@ mod stats; use core_engine::{ + microphone_access as core_microphone_access, screen_capture_access as core_screen_capture_access, AppAudioSource, AppAudioSourceConfig, ApplicationInfo, AsrChunkView, AsrSampleSlice, AsrSink, AsrSinkCallback, AsrSinkConfig, AsrSinkInput, AsrSinkMetricsSnapshot, AudioEngineController, AudioProcessor, DisplayInfo, EngineError, MicInfo, MicrophoneSource, MicrophoneSourceConfig, @@ -1737,6 +1738,18 @@ fn list_applications(py: Python<'_>) -> PyResult> { Ok(list) } +#[pyfunction] +#[pyo3(signature = (prompt = false))] +fn screen_capture_access(prompt: bool) -> bool { + core_screen_capture_access(prompt) +} + +#[pyfunction] +#[pyo3(signature = (prompt = false))] +fn microphone_access(prompt: bool) -> String { + core_microphone_access(prompt).to_string() +} + #[pyfunction(name = "_create_asr_sink")] fn create_asr_sink( py: Python<'_>, @@ -1912,6 +1925,8 @@ fn _macloop(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(list_microphones, m)?)?; m.add_function(wrap_pyfunction!(list_displays, m)?)?; m.add_function(wrap_pyfunction!(list_applications, m)?)?; + m.add_function(wrap_pyfunction!(screen_capture_access, m)?)?; + m.add_function(wrap_pyfunction!(microphone_access, m)?)?; m.add_function(wrap_pyfunction!(create_asr_sink, m)?)?; m.add_function(wrap_pyfunction!(create_wav_sink, m)?)?; Ok(()) diff --git a/tests/conftest.py b/tests/conftest.py index 4479ed1..1aeca22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -307,6 +307,8 @@ def macloop_module(monkeypatch): {"id": 101, "name": "Display 101", "width": 2560, "height": 1440, "is_default": True}, {"id": 202, "name": "Display 202", "width": 1920, "height": 1080, "is_default": False}, ] + fake_ext.screen_capture_access = lambda prompt=False: True + fake_ext.microphone_access = lambda prompt=False: "authorized" monkeypatch.setitem(sys.modules, "macloop._macloop", fake_ext) sys.modules.pop("macloop", None)