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
16 changes: 16 additions & 0 deletions apps/desktop/src-tauri/src/audio_meter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ impl VolumeMeter {
self.maxes.push(time, value);
self.times.push_back(time);

// The duration-window eviction below stalls permanently when a
// device's capture clock is frozen or non-monotonic (duration_since
// yields Zero forever or None), and this meter lives for the whole
// process; without a hard cap it grows one entry per audio callback
// until quit.
const MAX_WINDOW_ENTRIES: usize = 512;
while self.times.len() > MAX_WINDOW_ENTRIES {
// A frozen clock produces duplicate keys that share one `maxes`
// entry; only drop that entry once no duplicate remains queued.
if let Some(front) = self.times.pop_front()
&& !self.times.contains(&front)
{
self.maxes.remove(&front);
}
}

while let Some(time) = self
.times
.back()
Expand Down
113 changes: 87 additions & 26 deletions apps/desktop/src-tauri/src/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ static MACOS_DOCK_VISIBILITY_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0);
static MACOS_PENDING_PANEL_WINDOWS: AtomicU32 = AtomicU32::new(0);
#[cfg(target_os = "macos")]
static MACOS_SCK_PERMISSION_MISMATCH_LOGGED: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "macos")]
static MACOS_SCK_DISPLAYS_VALIDATED: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "macos")]
static MACOS_SCK_LAST_VALIDATION_ATTEMPT: std::sync::Mutex<Option<std::time::Instant>> =
std::sync::Mutex::new(None);
#[cfg(target_os = "macos")]
const MACOS_SCK_VALIDATION_RETRY_INTERVAL: Duration = Duration::from_secs(5);

#[cfg(target_os = "macos")]
pub(crate) struct MacosPanelWindowActivationGuard {
Expand Down Expand Up @@ -283,42 +290,84 @@ fn macos_permission_status(permission: &OSPermission, initial_check: bool) -> OS
}
}

// The SCShareableContent snapshot behind this validation materialises every
// window/app/display on the system (~1MB+ of ObjC objects per call). Polling
// callers (devices snapshot emitter every 5s, permission UIs down to 250ms)
// used to re-run it for the whole process lifetime, leaking the graph on
// pool-less tokio threads at ~15MB/min until macOS exhausted swap (issue
// #2023, the 82GB incident). A successful validation is cached for the rest
// of the process: runtime revocation is caught by the cheap CGPreflight gate,
// and macOS relaunches the app on screen-recording permission changes anyway.
// Failed validations retry at most once per MACOS_SCK_VALIDATION_RETRY_INTERVAL.
#[cfg(target_os = "macos")]
fn macos_screen_recording_available() -> bool {
if !scap_screencapturekit::has_permission() {
return false;
}

let future = async {
match sc::ShareableContent::current().await {
Ok(content) => {
let display_count = content.displays().len();
if display_count == 0
&& !MACOS_SCK_PERMISSION_MISMATCH_LOGGED.swap(true, Ordering::AcqRel)
{
if MACOS_SCK_DISPLAYS_VALIDATED.load(Ordering::Acquire) {
return true;
}

// block_in_place covers the mutex acquisition too: waiters serialised
// behind an in-flight validation would otherwise park a tokio worker
// without telling the runtime.
if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::block_in_place(macos_validate_sck_displays)
} else {
macos_validate_sck_displays()
}
}

#[cfg(target_os = "macos")]
fn macos_validate_sck_displays() -> bool {
// Serialise validators: concurrent callers during the first startup check
// must wait for the in-flight validation rather than tripping the backoff
// and transiently reporting a granted permission as denied.
let mut last_attempt = MACOS_SCK_LAST_VALIDATION_ATTEMPT
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if MACOS_SCK_DISPLAYS_VALIDATED.load(Ordering::Acquire) {
return true;
}
if let Some(attempted_at) = *last_attempt
&& attempted_at.elapsed() < MACOS_SCK_VALIDATION_RETRY_INTERVAL
{
return false;
}
*last_attempt = Some(std::time::Instant::now());

let validated = objc2::rc::autoreleasepool(|_| {
tauri::async_runtime::block_on(async {
match sc::ShareableContent::current().await {
Ok(content) => {
let display_count = content.displays().len();
if display_count == 0
&& !MACOS_SCK_PERMISSION_MISMATCH_LOGGED.swap(true, Ordering::AcqRel)
{
tracing::debug!(
window_count = content.windows().len(),
application_count = content.apps().len(),
"ScreenCaptureKit returned no displays despite CoreGraphics screen-recording permission"
);
}
display_count > 0
}
Err(error) => {
tracing::debug!(
window_count = content.windows().len(),
application_count = content.apps().len(),
"ScreenCaptureKit returned no displays despite CoreGraphics screen-recording permission"
error = %error,
"ScreenCaptureKit shareable content unavailable during permission check"
);
false
}
display_count > 0
}
Err(error) => {
tracing::debug!(
error = %error,
"ScreenCaptureKit shareable content unavailable during permission check"
);
false
}
}
};
})
});

if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::block_in_place(|| tauri::async_runtime::block_on(future))
} else {
tauri::async_runtime::block_on(future)
if validated {
MACOS_SCK_DISPLAYS_VALIDATED.store(true, Ordering::Release);
}
validated
}

#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -374,6 +423,15 @@ where

#[cfg(target_os = "macos")]
async fn macos_wait_for_permission_update(permission: &OSPermission) -> bool {
// The user just interacted with the permission prompt; drop the SCK
// validation backoff so this poll loop sees fresh answers instead of a
// stale negative from up to 5s ago.
if matches!(permission, OSPermission::ScreenRecording) {
*MACOS_SCK_LAST_VALIDATION_ATTEMPT
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}

macos_wait_for_permission_update_with(
|| macos_permission_status(permission, false).permitted(),
|| tokio::time::sleep(Duration::from_millis(200)),
Expand Down Expand Up @@ -497,17 +555,20 @@ impl OSPermissionsCheck {
#[tauri::command(async)]
#[specta::specta]
pub fn do_permissions_check(_initial_check: bool) -> OSPermissionsCheck {
// Pool-wrapped because this runs on tokio/tauri worker threads (which have
// no ambient NSAutoreleasePool) from polling callers; without it every
// autoreleased AVFoundation/AppKit temporary leaks for the process lifetime.
#[cfg(target_os = "macos")]
{
OSPermissionsCheck {
objc2::rc::autoreleasepool(|_| OSPermissionsCheck {
screen_recording: macos_permission_status(
&OSPermission::ScreenRecording,
_initial_check,
),
microphone: macos_permission_status(&OSPermission::Microphone, _initial_check),
camera: macos_permission_status(&OSPermission::Camera, _initial_check),
accessibility: macos_permission_status(&OSPermission::Accessibility, _initial_check),
}
})
}

#[cfg(not(target_os = "macos"))]
Expand Down
49 changes: 27 additions & 22 deletions crates/camera-avfoundation/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,41 @@ use std::{
};
use tracing::warn;

// Pool-wrapped: device polling calls this every few seconds from tokio threads
// that have no ambient NSAutoreleasePool, so the discovery session's
// autoreleased temporaries would otherwise leak for the process lifetime.
pub fn list_video_devices() -> arc::R<ns::Array<av::CaptureDevice>> {
let mut device_types = vec![av::CaptureDeviceType::built_in_wide_angle_camera()];
objc::ar_pool(|| {
let mut device_types = vec![av::CaptureDeviceType::built_in_wide_angle_camera()];

if api::macos_available("13.0")
&& let Some(typ) = unsafe { av::CaptureDeviceType::desk_view_camera() }
{
device_types.push(typ);
}

if api::macos_available("14.0") {
if let Some(typ) = unsafe { av::CaptureDeviceType::external() } {
if api::macos_available("13.0")
&& let Some(typ) = unsafe { av::CaptureDeviceType::desk_view_camera() }
{
device_types.push(typ);
}
if let Some(typ) = unsafe { av::CaptureDeviceType::continuity_camera() } {
device_types.push(typ);

if api::macos_available("14.0") {
if let Some(typ) = unsafe { av::CaptureDeviceType::external() } {
device_types.push(typ);
}
if let Some(typ) = unsafe { av::CaptureDeviceType::continuity_camera() } {
device_types.push(typ);
}
} else {
device_types.push(av::CaptureDeviceType::external_unknown());
}
} else {
device_types.push(av::CaptureDeviceType::external_unknown());
}

let device_types = ns::Array::from_slice(&device_types);
let device_types = ns::Array::from_slice(&device_types);

let video_discovery_session =
av::CaptureDeviceDiscoverySession::with_device_types_media_and_pos(
&device_types,
Some(av::MediaType::video()),
av::CaptureDevicePos::Unspecified,
);
let video_discovery_session =
av::CaptureDeviceDiscoverySession::with_device_types_media_and_pos(
&device_types,
Some(av::MediaType::video()),
av::CaptureDevicePos::Unspecified,
);

video_discovery_session.devices()
video_discovery_session.devices()
})
}

#[derive(Clone, Copy)]
Expand Down
25 changes: 15 additions & 10 deletions crates/camera/src/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@ use cidre::*;
use objc2_av_foundation::*;

pub(super) fn list_cameras_impl() -> impl Iterator<Item = CameraInfo> {
let devices = cap_camera_avfoundation::list_video_devices();
devices
.iter()
.map(|d| CameraInfo {
device_id: d.unique_id().to_string(),
model_id: ModelID::from_avfoundation(d),
display_name: d.localized_name().to_string(),
})
.collect::<Vec<_>>()
.into_iter()
// ar_pool: called from pool-less tokio threads on a polling cadence; the
// unique_id/localized_name accessors autorelease NSStrings that would
// otherwise accumulate for the process lifetime.
objc::ar_pool(|| {
let devices = cap_camera_avfoundation::list_video_devices();
devices
.iter()
.map(|d| CameraInfo {
device_id: d.unique_id().to_string(),
model_id: ModelID::from_avfoundation(d),
display_name: d.localized_name().to_string(),
})
.collect::<Vec<_>>()
})
.into_iter()
}

impl CameraInfo {
Expand Down
56 changes: 53 additions & 3 deletions crates/recording/src/feeds/microphone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ fn list_input_device_names() -> Vec<String> {
let default_id = macos_helpers::get_default_device_id(true);

if let Some(name) = default_id
.and_then(|id| macos_helpers::get_device_name(id).ok())
.and_then(macos_device_name_released)
.filter(|name| !name.is_empty())
{
names.insert(name, ());
Expand All @@ -521,7 +521,7 @@ fn list_input_device_names() -> Vec<String> {
for device_id in device_ids {
if macos_helpers::get_audio_device_supports_scope(device_id, Scope::Input)
.unwrap_or(false)
&& let Ok(name) = macos_helpers::get_device_name(device_id)
&& let Some(name) = macos_device_name_released(device_id)
&& !name.is_empty()
{
names.entry(name).or_insert(());
Expand All @@ -536,6 +536,44 @@ fn list_input_device_names() -> Vec<String> {
names.into_keys().collect()
}

// coreaudio-rs's get_device_name never releases the CFString it copies out of
// AudioObjectGetPropertyData (twice on the CFStringGetCStringPtr fallback
// path), leaking one or two strings per device per call; the devices snapshot
// emitter calls this every 5s for the process lifetime. This variant hands the
// +1 ref to core-foundation, which releases it on drop.
#[cfg(target_os = "macos")]
fn macos_device_name_released(device_id: coreaudio::sys::AudioDeviceID) -> Option<String> {
use core_foundation::{
base::TCFType,
string::{CFString, CFStringRef},
};
use coreaudio::sys;

let property_address = sys::AudioObjectPropertyAddress {
mSelector: sys::kAudioDevicePropertyDeviceNameCFString,
mScope: sys::kAudioDevicePropertyScopeOutput,
mElement: sys::kAudioObjectPropertyElementMaster,
};

let mut device_name: CFStringRef = std::ptr::null();
let mut data_size = std::mem::size_of::<CFStringRef>() as u32;
let status = unsafe {
sys::AudioObjectGetPropertyData(
device_id,
&property_address,
0,
std::ptr::null(),
&mut data_size,
(&mut device_name) as *mut CFStringRef as *mut _,
)
};
if status != sys::kAudioHardwareNoError as i32 || device_name.is_null() {
return None;
}

Some(unsafe { CFString::wrap_under_create_rule(device_name) }.to_string())
}

#[cfg(not(target_os = "macos"))]
fn list_input_device_names() -> Vec<String> {
let host = cpal::default_host();
Expand Down Expand Up @@ -1333,7 +1371,19 @@ impl Message<RemoveInput> for MicrophoneFeed {
async fn handle(&mut self, _: RemoveInput, _: &mut Context<Self, Self::Reply>) -> Self::Reply {
trace!("MicrophoneFeed.RemoveInput");

let state = self.state.try_as_open()?;
// Callers routinely discard this reply; a locked feed silently keeps
// the cpal stream (and its per-callback allocations) alive, so make
// that path visible in logs. debug-level because deselecting the mic
// during a studio recording hits this legitimately.
let state = match self.state.try_as_open() {
Ok(state) => state,
Err(err) => {
debug!(
"Microphone feed RemoveInput deferred: feed is locked by an active consumer"
);
return Err(err);
}
};

state.connecting = None;

Expand Down
11 changes: 11 additions & 0 deletions crates/recording/src/sources/microphone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ pub struct Microphone {
cancel: CancellationToken,
}

// The detached bridge tasks hold clones of the feed lock and only exit via
// this token. If the pipeline is torn down without stop() (wedged muxer,
// error paths), a live-but-orphaned lock would keep the feed in State::Locked
// forever, making RemoveInput a silent no-op and pinning the cpal stream for
// the rest of the process.
impl Drop for Microphone {
fn drop(&mut self) {
self.cancel.cancel();
}
}

#[derive(Debug, Error)]
pub enum MicrophoneSourceError {
#[error("microphone actor not running")]
Expand Down
Loading
Loading