Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion core_engine/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
2 changes: 2 additions & 0 deletions core_engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand Down
82 changes: 82 additions & 0 deletions core_engine/src/permissions.rs
Original file line number Diff line number Diff line change
@@ -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"
}
3 changes: 3 additions & 0 deletions macloop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down Expand Up @@ -764,4 +765,6 @@ def _resolve_wav_fd(file: Any) -> Tuple[int, bool]:
"SyntheticSource",
"WavSink",
"WavSinkStats",
"microphone_access",
"screen_capture_access",
]
4 changes: 4 additions & 0 deletions macloop/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
2 changes: 2 additions & 0 deletions macloop/_macloop.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion python_ffi/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
15 changes: 15 additions & 0 deletions python_ffi/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -1737,6 +1738,18 @@ fn list_applications(py: Python<'_>) -> PyResult<Bound<'_, PyList>> {
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<'_>,
Expand Down Expand Up @@ -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(())
Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading