use std::sync::{Arc, Mutex};
use std::time::Duration;
use block2::RcBlock;
use dispatch2::{DispatchQueue, DispatchRetained};
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, Bool, ProtocolObject};
use objc2::{AnyThread, DefinedClass, define_class, msg_send, sel};
use objc2_av_foundation::{
AVAuthorizationStatus, AVCaptureConnection, AVCaptureDevice, AVCaptureDeviceInput,
AVCaptureDeviceWasDisconnectedNotification, AVCaptureOutput, AVCaptureSession, AVCaptureSessionErrorKey,
AVCaptureSessionInterruptionEndedNotification, AVCaptureSessionRuntimeErrorNotification,
AVCaptureSessionWasInterruptedNotification, AVCaptureVideoDataOutput, AVCaptureVideoDataOutputSampleBufferDelegate,
AVError, AVFoundationErrorDomain, AVMediaType, AVMediaTypeVideo,
};
use objc2_core_media::CMSampleBuffer;
use objc2_foundation::{NSError, NSNotification, NSNotificationCenter, NSObject, NSObjectProtocol, NSString};
use super::surface::surface_frame;
use super::{Camera, Config, FrameChannel, Stream};
use crate::Error;
const FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(5);
const ACCESS_TIMEOUT: Duration = Duration::from_secs(60);
pub(super) fn cameras() -> Result<Vec<Camera>, Error> {
let media = unsafe { AVMediaTypeVideo }.ok_or_else(|| Error::Codec(anyhow::anyhow!("AVMediaTypeVideo")))?;
#[allow(deprecated)]
let devices = unsafe { AVCaptureDevice::devicesWithMediaType(media) };
Ok((0..devices.count())
.map(|index| devices.objectAtIndex(index))
.map(|device| Camera {
id: unsafe { device.uniqueID() }.to_string(),
name: unsafe { device.localizedName() }.to_string(),
})
.collect())
}
pub(super) async fn open(config: &Config, device: Option<&str>) -> Result<Stream, Error> {
let media = unsafe { AVMediaTypeVideo }.ok_or_else(|| Error::Codec(anyhow::anyhow!("AVMediaTypeVideo")))?;
ensure_camera_access(media).await?;
let device = match device {
Some(id) => {
let id = NSString::from_str(id);
unsafe { AVCaptureDevice::deviceWithUniqueID(&id) }
.ok_or_else(|| Error::SourceUnavailable(format!("no camera with id {id}")))?
}
None => unsafe { AVCaptureDevice::defaultDeviceWithMediaType(media) }
.ok_or_else(|| Error::SourceUnavailable("no default camera".to_string()))?,
};
if config.width.is_some() || config.height.is_some() || config.framerate.is_some() {
tracing::warn!("width/height/framerate are ignored for camera capture on macOS; using the device default");
}
let device_id = unsafe { device.uniqueID() }.to_string();
let input = unsafe { AVCaptureDeviceInput::deviceInputWithDevice_error(&device) }
.map_err(|e| Error::Codec(anyhow::anyhow!("camera input: {e:?}")))?;
let chan = FrameChannel::new();
let delegate = Delegate::new(chan.clone(), device_id.clone());
let dispatch = DispatchQueue::new("dev.moq.video.capture", None);
let output = unsafe { AVCaptureVideoDataOutput::new() };
unsafe {
output.setAlwaysDiscardsLateVideoFrames(true);
let proto = ProtocolObject::from_ref(&*delegate);
output.setSampleBufferDelegate_queue(Some(proto), Some(&dispatch));
}
let session = unsafe { AVCaptureSession::new() };
let guard = SessionGuard::new(session.clone(), delegate, dispatch);
let configuration = SessionConfiguration::new(&session);
unsafe {
if !session.canAddInput(&input) {
return Err(Error::Codec(anyhow::anyhow!("cannot add camera input")));
}
session.addInput(&input);
if !session.canAddOutput(&output) {
return Err(Error::Codec(anyhow::anyhow!("cannot add video output")));
}
session.addOutput(&output);
}
drop(configuration);
unsafe { session.startRunning() };
let first = match tokio::time::timeout(FIRST_FRAME_TIMEOUT, chan.recv()).await {
Ok(Ok(Some(frame))) => frame,
Ok(Err(error)) => return Err(error),
Ok(Ok(None)) | Err(_) => {
return Err(Error::Codec(anyhow::anyhow!(
"no frames from camera {device_id} within {FIRST_FRAME_TIMEOUT:?} (permission denied?)"
)));
}
};
let (width, height) = (first.width(), first.height());
tracing::info!(device = %device_id, width, height, "opened camera (AVFoundation)");
Ok(Stream::new(
chan,
width,
height,
None,
device_id,
Some(first),
Box::new(guard),
))
}
async fn ensure_camera_access(media: &AVMediaType) -> Result<(), Error> {
let status = unsafe { AVCaptureDevice::authorizationStatusForMediaType(media) };
if status == AVAuthorizationStatus::Authorized {
return Ok(());
}
if status == AVAuthorizationStatus::NotDetermined {
let (tx, rx) = tokio::sync::oneshot::channel();
let tx = Mutex::new(Some(tx));
let handler = RcBlock::new(move |granted: Bool| {
if let Some(tx) = tx.lock().unwrap().take() {
let _ = tx.send(granted.as_bool());
}
});
unsafe { AVCaptureDevice::requestAccessForMediaType_completionHandler(media, &handler) };
return match tokio::time::timeout(ACCESS_TIMEOUT, rx).await {
Ok(Ok(true)) => Ok(()),
Ok(Ok(false)) => Err(Error::PermissionDenied(
"camera access; enable it in System Settings > Privacy & Security > Camera".to_string(),
)),
Ok(Err(_)) => Err(Error::PermissionDenied(
"camera-permission prompt dismissed without a decision".to_string(),
)),
Err(_) => Err(Error::PermissionDenied(format!(
"timed out after {ACCESS_TIMEOUT:?} waiting for the camera-permission prompt"
))),
};
}
Err(Error::PermissionDenied(
"camera access is denied or restricted; enable it in System Settings > Privacy & Security > Camera".to_string(),
))
}
fn observe(delegate: &Delegate, session: &AVCaptureSession) {
let center = NSNotificationCenter::defaultCenter();
let observer: &AnyObject = delegate;
let session: &AnyObject = session;
unsafe {
center.addObserver_selector_name_object(
observer,
sel!(moqSessionRuntimeError:),
Some(AVCaptureSessionRuntimeErrorNotification),
Some(session),
);
center.addObserver_selector_name_object(
observer,
sel!(moqSessionWasInterrupted:),
Some(AVCaptureSessionWasInterruptedNotification),
Some(session),
);
center.addObserver_selector_name_object(
observer,
sel!(moqSessionInterruptionEnded:),
Some(AVCaptureSessionInterruptionEndedNotification),
Some(session),
);
center.addObserver_selector_name_object(
observer,
sel!(moqDeviceWasDisconnected:),
Some(AVCaptureDeviceWasDisconnectedNotification),
None,
);
}
}
fn notification_error(notification: &NSNotification) -> Option<Retained<NSError>> {
let info = notification.userInfo()?;
let key: &AnyObject = unsafe { AVCaptureSessionErrorKey };
info.objectForKey(key)?.downcast::<NSError>().ok()
}
fn runtime_error(error: Option<&NSError>) -> Error {
let Some(error) = error else {
return Error::SourceUnavailable("camera session stopped without a reason".to_string());
};
let reason = error.localizedDescription().to_string();
let domain = error.domain();
let av = unsafe { AVFoundationErrorDomain }.is_some_and(|expected| *domain == *expected);
let code = AVError(error.code());
if av && (code == AVError::ApplicationIsNotAuthorizedToUseDevice || code == AVError::ApplicationIsNotAuthorized) {
return Error::PermissionDenied(reason);
}
Error::SourceUnavailable(reason)
}
struct SessionConfiguration<'a> {
session: &'a AVCaptureSession,
}
impl<'a> SessionConfiguration<'a> {
fn new(session: &'a AVCaptureSession) -> Self {
unsafe { session.beginConfiguration() };
Self { session }
}
}
impl Drop for SessionConfiguration<'_> {
fn drop(&mut self) {
unsafe { self.session.commitConfiguration() };
}
}
struct SessionGuard {
session: Retained<AVCaptureSession>,
chan: Arc<FrameChannel>,
delegate: Retained<Delegate>,
_dispatch: DispatchRetained<DispatchQueue>,
}
impl SessionGuard {
fn new(
session: Retained<AVCaptureSession>,
delegate: Retained<Delegate>,
dispatch: DispatchRetained<DispatchQueue>,
) -> Self {
observe(&delegate, &session);
let chan = delegate.ivars().chan.clone();
Self {
session,
chan,
delegate,
_dispatch: dispatch,
}
}
}
impl Drop for SessionGuard {
fn drop(&mut self) {
unsafe { NSNotificationCenter::defaultCenter().removeObserver(&self.delegate) };
unsafe { self.session.stopRunning() };
self.chan.close();
}
}
struct DelegateIvars {
chan: Arc<FrameChannel>,
device_id: String,
}
define_class!(
#[unsafe(super(NSObject))]
#[name = "MoqVideoCameraDelegate"]
#[ivars = DelegateIvars]
struct Delegate;
unsafe impl NSObjectProtocol for Delegate {}
unsafe impl AVCaptureVideoDataOutputSampleBufferDelegate for Delegate {
#[unsafe(method(captureOutput:didOutputSampleBuffer:fromConnection:))]
unsafe fn did_output(
&self,
_output: &AVCaptureOutput,
sample_buffer: &CMSampleBuffer,
_connection: &AVCaptureConnection,
) {
if let Some(frame) = surface_frame(sample_buffer) {
self.ivars().chan.push(frame);
}
}
}
impl Delegate {
#[unsafe(method(moqSessionRuntimeError:))]
fn session_runtime_error(&self, notification: &NSNotification) {
let error = runtime_error(notification_error(notification).as_deref());
tracing::warn!(device = %self.ivars().device_id, %error, "camera session failed");
self.ivars().chan.fail(error);
}
#[unsafe(method(moqDeviceWasDisconnected:))]
fn device_was_disconnected(&self, notification: &NSNotification) {
let Some(object) = notification.object() else { return };
let device: Retained<AVCaptureDevice> = unsafe { Retained::cast_unchecked(object) };
let id = unsafe { device.uniqueID() }.to_string();
if id != self.ivars().device_id {
return; }
tracing::warn!(device = %id, "camera was disconnected");
self.ivars().chan.fail(Error::SourceUnavailable(format!("camera {id} was disconnected")));
}
#[unsafe(method(moqSessionWasInterrupted:))]
fn session_was_interrupted(&self, _notification: &NSNotification) {
tracing::warn!(device = %self.ivars().device_id, "camera session interrupted; waiting for it to resume");
}
#[unsafe(method(moqSessionInterruptionEnded:))]
fn session_interruption_ended(&self, _notification: &NSNotification) {
tracing::info!(device = %self.ivars().device_id, "camera session resumed");
}
}
);
impl Delegate {
fn new(chan: Arc<FrameChannel>, device_id: String) -> Retained<Self> {
let this = Self::alloc().set_ivars(DelegateIvars { chan, device_id });
unsafe { msg_send![super(this), init] }
}
}
#[cfg(test)]
mod tests {
use objc2_foundation::{NSDictionary, NSInteger};
use super::*;
fn wire(device_id: &str) -> (Arc<FrameChannel>, Retained<AVCaptureSession>, SessionGuard) {
let chan = FrameChannel::new();
let session = unsafe { AVCaptureSession::new() };
let delegate = Delegate::new(chan.clone(), device_id.to_string());
let dispatch = DispatchQueue::new("dev.moq.video.capture.test", None);
let guard = SessionGuard::new(session.clone(), delegate, dispatch);
(chan, session, guard)
}
#[test]
fn an_early_configuration_error_commits_before_teardown() {
let session = unsafe { AVCaptureSession::new() };
let configuration = SessionConfiguration::new(&session);
drop(configuration);
unsafe { session.stopRunning() };
}
fn post_runtime_error(session: &AVCaptureSession, code: NSInteger) {
let domain = unsafe { AVFoundationErrorDomain }.expect("AVFoundationErrorDomain");
let error = unsafe { NSError::errorWithDomain_code_userInfo(domain, code, None) };
let key: &NSString = unsafe { AVCaptureSessionErrorKey };
let info = NSDictionary::<NSString, NSError>::from_slices(&[key], &[&*error]);
let info: Retained<NSDictionary> = unsafe { Retained::cast_unchecked(info) };
let object: &AnyObject = session;
unsafe {
NSNotificationCenter::defaultCenter().postNotificationName_object_userInfo(
AVCaptureSessionRuntimeErrorNotification,
Some(object),
Some(&info),
);
}
}
fn post(name: &NSString, session: &AVCaptureSession) {
let object: &AnyObject = session;
unsafe { NSNotificationCenter::defaultCenter().postNotificationName_object(name, Some(object)) };
}
#[tokio::test]
async fn a_runtime_error_ends_the_stream() {
let (chan, session, _guard) = wire("runtime-error");
post_runtime_error(&session, AVError::DeviceWasDisconnected.0);
assert!(matches!(chan.recv().await, Err(Error::SourceUnavailable(_))));
}
#[tokio::test]
async fn a_revoked_grant_ends_the_stream_as_a_permission_error() {
let (chan, session, _guard) = wire("revoked-grant");
post_runtime_error(&session, AVError::ApplicationIsNotAuthorizedToUseDevice.0);
assert!(matches!(chan.recv().await, Err(Error::PermissionDenied(_))));
}
#[test]
fn a_runtime_error_without_a_cause_is_still_terminal() {
assert!(matches!(runtime_error(None), Error::SourceUnavailable(_)));
}
#[tokio::test]
async fn an_interruption_does_not_end_the_stream() {
let (chan, session, _guard) = wire("interrupted");
post(unsafe { AVCaptureSessionWasInterruptedNotification }, &session);
post(unsafe { AVCaptureSessionInterruptionEndedNotification }, &session);
chan.push(crate::frame::Surface::I420(crate::frame::I420 {
width: 16,
height: 16,
data: Vec::new(),
color: None,
}));
assert_eq!(chan.recv().await.unwrap().unwrap().width(), 16);
}
#[tokio::test]
async fn disconnecting_the_captured_camera_ends_the_stream() {
let Some(camera) = cameras().expect("list cameras").into_iter().next() else {
return; };
let id = NSString::from_str(&camera.id);
let device = unsafe { AVCaptureDevice::deviceWithUniqueID(&id) }.expect("camera by id");
let (chan, _session, _guard) = wire(&camera.id);
let object: &AnyObject = &device;
unsafe {
NSNotificationCenter::defaultCenter()
.postNotificationName_object(AVCaptureDeviceWasDisconnectedNotification, Some(object));
}
assert!(matches!(chan.recv().await, Err(Error::SourceUnavailable(_))));
}
#[tokio::test]
async fn disconnecting_another_camera_leaves_the_stream_alone() {
let Some(camera) = cameras().expect("list cameras").into_iter().next() else {
return; };
let id = NSString::from_str(&camera.id);
let device = unsafe { AVCaptureDevice::deviceWithUniqueID(&id) }.expect("camera by id");
let (chan, _session, _guard) = wire("some-other-camera");
let object: &AnyObject = &device;
unsafe {
NSNotificationCenter::defaultCenter()
.postNotificationName_object(AVCaptureDeviceWasDisconnectedNotification, Some(object));
}
chan.push(crate::frame::Surface::I420(crate::frame::I420 {
width: 32,
height: 32,
data: Vec::new(),
color: None,
}));
assert_eq!(chan.recv().await.unwrap().unwrap().width(), 32);
}
}