use std::sync::Arc;
use std::time::Duration;
use block2::RcBlock;
use dispatch2::{DispatchQueue, DispatchRetained};
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2::{AnyThread, DefinedClass, define_class, msg_send};
use objc2_core_media::{CMSampleBuffer, CMTime};
use objc2_core_video::kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange;
use objc2_foundation::{NSArray, NSError, NSObject, NSObjectProtocol};
use objc2_screen_capture_kit::{
SCContentFilter, SCDisplay, SCRunningApplication, SCShareableContent, SCStream, SCStreamConfiguration,
SCStreamDelegate, SCStreamOutput, SCStreamOutputType, SCWindow,
};
use super::surface::surface_frame;
use super::{App, Config, Display, FrameChannel, FrameStream, Window};
use crate::Error;
const DEFAULT_FRAMERATE: i32 = 30;
const FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(5);
const ASYNC_TIMEOUT: Duration = Duration::from_secs(5);
const NORMAL_WINDOW_LAYER: isize = 0;
pub(super) async fn open_display(config: &Config, device: Option<&str>) -> Result<FrameStream, Error> {
init_core_graphics();
let content = shareable_content().await?;
let display = find_display(&content, device)?;
let excluded = NSArray::<SCWindow>::new();
let filter =
unsafe { SCContentFilter::initWithDisplay_excludingWindows(SCContentFilter::alloc(), &display, &excluded) };
let (width, height) = display_size(&display);
let size = capture_size(width, height);
open(config, &filter, size).await
}
pub(super) async fn open_window(config: &Config, id: &str) -> Result<FrameStream, Error> {
init_core_graphics();
let content = shareable_content().await?;
let window = find_window(&content, id)?;
let filter = unsafe { SCContentFilter::initWithDesktopIndependentWindow(SCContentFilter::alloc(), &window) };
let frame = unsafe { window.frame() };
let size = capture_size(frame.size.width, frame.size.height);
open(config, &filter, size).await
}
pub(super) async fn open_app(config: &Config, id: &str) -> Result<FrameStream, Error> {
init_core_graphics();
let content = shareable_content().await?;
let app = find_app(&content, id)?;
let display = find_display(&content, None)?;
let apps = NSArray::from_retained_slice(&[app]);
let excepting = NSArray::<SCWindow>::new();
let filter = unsafe {
SCContentFilter::initWithDisplay_includingApplications_exceptingWindows(
SCContentFilter::alloc(),
&display,
&apps,
&excepting,
)
};
let (width, height) = display_size(&display);
let size = capture_size(width, height);
open(config, &filter, size).await
}
fn display_size(display: &SCDisplay) -> (f64, f64) {
(unsafe { display.width() } as f64, unsafe { display.height() } as f64)
}
fn init_core_graphics() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
objc2_core_graphics::CGMainDisplayID();
});
}
async fn open(config: &Config, filter: &SCContentFilter, size: (u32, u32)) -> Result<FrameStream, Error> {
let fps = config.framerate.map(|f| f as i32).unwrap_or(DEFAULT_FRAMERATE).max(1);
let configuration = unsafe { SCStreamConfiguration::new() };
let width = config.width.map(|w| even(w as f64)).unwrap_or(size.0);
let height = config.height.map(|h| even(h as f64)).unwrap_or(size.1);
unsafe {
configuration.setWidth(width as usize);
configuration.setHeight(height as usize);
configuration.setPixelFormat(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange);
configuration.setMinimumFrameInterval(CMTime::new(1, fps));
configuration.setShowsCursor(config.cursor);
}
let chan = FrameChannel::new();
let delegate = Delegate::new(chan.clone());
let dispatch = DispatchQueue::new("dev.moq.video.screen", None);
let stream = unsafe {
let proto = ProtocolObject::from_ref(&*delegate);
SCStream::initWithFilter_configuration_delegate(SCStream::alloc(), filter, &configuration, Some(proto))
};
unsafe {
let proto = ProtocolObject::from_ref(&*delegate);
stream
.addStreamOutput_type_sampleHandlerQueue_error(proto, SCStreamOutputType::Screen, Some(&dispatch))
.map_err(|e| Error::Codec(anyhow::anyhow!("add screen output: {e:?}")))?;
}
start_capture(&stream).await?;
let guard = StreamGuard {
stream,
chan: chan.clone(),
_delegate: delegate,
_dispatch: dispatch,
};
let label = config.source.label();
let first = match tokio::time::timeout(FIRST_FRAME_TIMEOUT, chan.recv()).await {
Ok(Some(frame)) => frame,
Ok(None) | Err(_) => {
return Err(Error::Codec(anyhow::anyhow!(
"no frames from {label} within {FIRST_FRAME_TIMEOUT:?} (screen recording permission?)"
)));
}
};
let (width, height) = (first.width(), first.height());
tracing::info!(source = %label, width, height, "opened screen capture (ScreenCaptureKit)");
Ok(FrameStream::new(
chan,
width,
height,
None,
label,
Some(first),
Box::new(guard),
))
}
pub(super) async fn displays() -> Result<Vec<Display>, Error> {
let content = shareable_content().await?;
let displays = unsafe { content.displays() };
Ok((0..displays.count())
.map(|index| {
let display = displays.objectAtIndex(index);
Display {
id: index.to_string(),
name: format!("Display {}", unsafe { display.displayID() }),
width: unsafe { display.width() } as u32,
height: unsafe { display.height() } as u32,
}
})
.collect())
}
pub(super) async fn windows() -> Result<Vec<Window>, Error> {
let content = shareable_content().await?;
let windows = unsafe { content.windows() };
Ok((0..windows.count())
.map(|index| windows.objectAtIndex(index))
.filter(|window| unsafe { window.isOnScreen() } && unsafe { window.windowLayer() } == NORMAL_WINDOW_LAYER)
.map(|window| {
let frame = unsafe { window.frame() };
Window {
id: unsafe { window.windowID() }.to_string(),
title: unsafe { window.title() }.map(|t| t.to_string()).unwrap_or_default(),
app: unsafe { window.owningApplication() }
.map(|app| unsafe { app.applicationName() }.to_string())
.unwrap_or_default(),
width: frame.size.width as u32,
height: frame.size.height as u32,
}
})
.collect())
}
pub(super) async fn apps() -> Result<Vec<App>, Error> {
let content = shareable_content().await?;
let windows = unsafe { content.windows() };
let mut apps = Vec::new();
let mut seen = std::collections::HashSet::new();
for index in 0..windows.count() {
let window = windows.objectAtIndex(index);
if !unsafe { window.isOnScreen() } || unsafe { window.windowLayer() } != NORMAL_WINDOW_LAYER {
continue;
}
let Some(app) = (unsafe { window.owningApplication() }) else {
continue;
};
let id = unsafe { app.bundleIdentifier() }.to_string();
if seen.insert(id.clone()) {
apps.push(App {
id,
name: unsafe { app.applicationName() }.to_string(),
});
}
}
Ok(apps)
}
fn find_display(content: &SCShareableContent, device: Option<&str>) -> Result<Retained<SCDisplay>, Error> {
let displays = unsafe { content.displays() };
let index = match device {
None => 0,
Some(spec) => spec
.strip_prefix("display:")
.unwrap_or(spec)
.parse::<usize>()
.map_err(|_| Error::Codec(anyhow::anyhow!("invalid display selector {spec:?}")))?,
};
if index >= displays.count() {
return Err(Error::Codec(anyhow::anyhow!("no display at index {index}")));
}
Ok(displays.objectAtIndex(index))
}
fn find_window(content: &SCShareableContent, id: &str) -> Result<Retained<SCWindow>, Error> {
let wanted: u32 = id
.parse()
.map_err(|_| Error::Codec(anyhow::anyhow!("invalid window id {id:?}")))?;
let windows = unsafe { content.windows() };
(0..windows.count())
.map(|index| windows.objectAtIndex(index))
.find(|window| unsafe { window.windowID() } == wanted)
.ok_or_else(|| Error::Codec(anyhow::anyhow!("no window with id {id} (did it close?)")))
}
fn find_app(content: &SCShareableContent, id: &str) -> Result<Retained<SCRunningApplication>, Error> {
let apps = unsafe { content.applications() };
(0..apps.count())
.map(|index| apps.objectAtIndex(index))
.find(|app| unsafe { app.bundleIdentifier() }.to_string() == id)
.ok_or_else(|| Error::Codec(anyhow::anyhow!("no running application with bundle id {id:?}")))
}
fn capture_size(width: f64, height: f64) -> (u32, u32) {
(even(width), even(height))
}
fn even(value: f64) -> u32 {
((value as u32) & !1).max(2)
}
struct StreamGuard {
stream: Retained<SCStream>,
chan: Arc<FrameChannel>,
_delegate: Retained<Delegate>,
_dispatch: DispatchRetained<DispatchQueue>,
}
impl Drop for StreamGuard {
fn drop(&mut self) {
unsafe { self.stream.stopCaptureWithCompletionHandler(None) };
self.chan.close();
}
}
async fn shareable_content() -> Result<Retained<SCShareableContent>, Error> {
let (tx, rx) = tokio::sync::oneshot::channel::<Result<SendObj<SCShareableContent>, String>>();
let tx = std::sync::Mutex::new(Some(tx));
let handler = RcBlock::new(move |content: *mut SCShareableContent, error: *mut NSError| {
let result = match unsafe { Retained::retain(content) } {
Some(content) => Ok(SendObj(content)),
None => Err(error_message(error)),
};
if let Some(tx) = tx.lock().unwrap().take() {
let _ = tx.send(result);
}
});
unsafe { SCShareableContent::getShareableContentWithCompletionHandler(&handler) };
match tokio::time::timeout(ASYNC_TIMEOUT, rx).await {
Ok(Ok(Ok(content))) => Ok(content.0),
Ok(Ok(Err(msg))) => Err(Error::Codec(anyhow::anyhow!("shareable content: {msg}"))),
Ok(Err(_)) => Err(Error::Codec(anyhow::anyhow!("shareable content handler dropped"))),
Err(_) => Err(Error::Codec(anyhow::anyhow!(
"timed out listing shareable content (screen recording permission?)"
))),
}
}
async fn start_capture(stream: &SCStream) -> Result<(), Error> {
let (tx, rx) = tokio::sync::oneshot::channel::<Option<String>>();
let tx = std::sync::Mutex::new(Some(tx));
let handler = RcBlock::new(move |error: *mut NSError| {
let result = (!error.is_null()).then(|| error_message(error));
if let Some(tx) = tx.lock().unwrap().take() {
let _ = tx.send(result);
}
});
unsafe { stream.startCaptureWithCompletionHandler(Some(&handler)) };
match tokio::time::timeout(ASYNC_TIMEOUT, rx).await {
Ok(Ok(None)) => Ok(()),
Ok(Ok(Some(msg))) => Err(Error::Codec(anyhow::anyhow!("start capture: {msg}"))),
Ok(Err(_)) => Err(Error::Codec(anyhow::anyhow!("start-capture handler dropped"))),
Err(_) => Err(Error::Codec(anyhow::anyhow!("timed out starting screen capture"))),
}
}
fn error_message(error: *mut NSError) -> String {
match unsafe { error.as_ref() } {
Some(error) => error.localizedDescription().to_string(),
None => "unknown error".to_string(),
}
}
struct SendObj<T>(Retained<T>);
unsafe impl<T> Send for SendObj<T> {}
struct DelegateIvars {
chan: Arc<FrameChannel>,
}
define_class!(
#[unsafe(super(NSObject))]
#[name = "MoqVideoScreenDelegate"]
#[ivars = DelegateIvars]
struct Delegate;
unsafe impl NSObjectProtocol for Delegate {}
unsafe impl SCStreamDelegate for Delegate {
#[unsafe(method(stream:didStopWithError:))]
unsafe fn did_stop(&self, _stream: &SCStream, error: &NSError) {
tracing::warn!(error = %error.localizedDescription(), "screen capture stopped");
self.ivars().chan.close();
}
}
unsafe impl SCStreamOutput for Delegate {
#[unsafe(method(stream:didOutputSampleBuffer:ofType:))]
unsafe fn did_output(&self, _stream: &SCStream, sample_buffer: &CMSampleBuffer, kind: SCStreamOutputType) {
if kind.0 == SCStreamOutputType::Screen.0 {
if let Some(frame) = surface_frame(sample_buffer) {
self.ivars().chan.push(frame);
}
}
}
}
);
impl Delegate {
fn new(chan: Arc<FrameChannel>) -> Retained<Self> {
let this = Self::alloc().set_ivars(DelegateIvars { chan });
unsafe { msg_send![super(this), init] }
}
}