use std::{
sync::{Arc, mpsc},
time::Duration,
};
use block2::RcBlock;
use dispatch2::{DispatchQueue, DispatchQueueAttr};
use objc2::{AllocAnyThread, DefinedClass, define_class, rc::Retained, runtime::ProtocolObject};
use objc2_core_graphics::{
CGDirectDisplayID, CGDisplayCopyDisplayMode, CGDisplayMode, CGDisplayPixelsHigh,
CGDisplayPixelsWide, CGMainDisplayID,
};
use objc2_core_media::CMSampleBuffer;
use objc2_core_video::{
CVPixelBuffer, CVPixelBufferGetBaseAddress, CVPixelBufferGetBytesPerRow,
CVPixelBufferGetHeight, CVPixelBufferGetWidth, CVPixelBufferLockBaseAddress,
CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress, kCVPixelFormatType_32BGRA,
};
use objc2_foundation::{NSArray, NSError, NSObject, NSObjectProtocol};
use objc2_screen_capture_kit::{
SCContentFilter, SCShareableContent, SCStream, SCStreamConfiguration, SCStreamOutput,
SCStreamOutputType,
};
use super::{DesktopConfig, Format, Shared, State, unpad_rows};
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20);
const STOP_POLL: Duration = Duration::from_millis(100);
pub(crate) fn spawn(config: DesktopConfig, shared: Arc<Shared>) -> Result<(), String> {
std::thread::Builder::new()
.name("desktop-sck".into())
.spawn(move || {
let session = match start(&config, shared.clone()) {
Ok(session) => session,
Err(why) => {
tracing::warn!("bevy_iroh: screen: {why}");
shared.finish(State::Failed(why));
return;
}
};
while !shared.stopped() {
std::thread::sleep(STOP_POLL);
}
drop(session);
shared.finish(State::Ended);
})
.map_err(|e| format!("could not start the capture thread: {e}"))?;
Ok(())
}
fn backing_size(display_id: CGDirectDisplayID) -> (u32, u32) {
let points_wide = CGDisplayPixelsWide(display_id);
let points_high = CGDisplayPixelsHigh(display_id);
let mode = CGDisplayCopyDisplayMode(display_id);
let scale = mode
.as_deref()
.map(|mode| {
let mode_points = CGDisplayMode::width(Some(mode));
let mode_pixels = CGDisplayMode::pixel_width(Some(mode));
if mode_points == 0 || mode_pixels == 0 {
1
} else {
(mode_pixels / mode_points).max(1)
}
})
.unwrap_or(1);
((points_wide * scale) as u32, (points_high * scale) as u32)
}
fn shareable_displays() -> Result<Retained<SCShareableContent>, String> {
struct Owned(*mut SCShareableContent);
unsafe impl Send for Owned {}
let (sender, receiver) = mpsc::channel();
let handler = RcBlock::new(
move |content: *mut SCShareableContent, error: *mut NSError| {
let message = if content.is_null() {
let why = unsafe { error.as_ref() }
.map(|error| error.localizedDescription().to_string())
.unwrap_or_else(|| {
"ScreenCaptureKit returned neither content nor an error".into()
});
Err(why)
} else {
let retained = unsafe { Retained::retain(content) };
Ok(Owned(
retained.map_or(std::ptr::null_mut(), Retained::into_raw),
))
};
let _ = sender.send(message);
},
);
unsafe { SCShareableContent::getShareableContentWithCompletionHandler(&handler) };
match receiver.recv_timeout(HANDSHAKE_TIMEOUT) {
Ok(Ok(Owned(content))) => unsafe { Retained::from_raw(content) }
.ok_or_else(|| "ScreenCaptureKit handed back a null content list".to_string()),
Ok(Err(why)) => Err(format!(
"ScreenCaptureKit would not list what is shareable: {why}. This is what a machine \
without Screen Recording permission reports: allow the app in System Settings > \
Privacy & Security > Screen & System Audio Recording, then relaunch it"
)),
Err(_) => Err(format!(
"ScreenCaptureKit did not answer within {}s; if a permission dialog is up, it is \
waiting for it",
HANDSHAKE_TIMEOUT.as_secs()
)),
}
}
struct Sink {
shared: Arc<Shared>,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = AllocAnyThread]
#[name = "BevyIrohScreenSink"]
#[ivars = Sink]
struct StreamSink;
unsafe impl NSObjectProtocol for StreamSink {}
unsafe impl SCStreamOutput for StreamSink {
#[unsafe(method(stream:didOutputSampleBuffer:ofType:))]
fn did_output(
&self,
_stream: &SCStream,
sample: &CMSampleBuffer,
kind: SCStreamOutputType,
) {
if kind != SCStreamOutputType::Screen {
return;
}
let Some(pixels) = (unsafe { sample.image_buffer() }) else {
return;
};
self.ivars().publish(&pixels);
}
}
);
impl StreamSink {
fn new(shared: Arc<Shared>) -> Retained<Self> {
let this = Self::alloc().set_ivars(Sink { shared });
unsafe { objc2::msg_send![super(this), init] }
}
}
impl Sink {
fn publish(&self, pixels: &CVPixelBuffer) {
if unsafe { CVPixelBufferLockBaseAddress(pixels, CVPixelBufferLockFlags::ReadOnly) } != 0 {
return;
}
let width = CVPixelBufferGetWidth(pixels);
let height = CVPixelBufferGetHeight(pixels);
let pitch = CVPixelBufferGetBytesPerRow(pixels);
let base = CVPixelBufferGetBaseAddress(pixels);
if !base.is_null() && width > 0 && height > 0 && pitch >= width * 4 {
let mut out = self.shared.buffer(width * height * 4);
let src = unsafe { std::slice::from_raw_parts(base as *const u8, pitch * height) };
unpad_rows(src, width as u32, height as u32, pitch, &mut out);
self.shared
.publish(width as u32, height as u32, Format::Bgra, out);
}
unsafe { CVPixelBufferUnlockBaseAddress(pixels, CVPixelBufferLockFlags::ReadOnly) };
}
}
struct Session {
stream: Retained<SCStream>,
_sink: Retained<StreamSink>,
_queue: dispatch2::DispatchRetained<DispatchQueue>,
}
unsafe impl Send for Session {}
impl Drop for Session {
fn drop(&mut self) {
let (sender, receiver) = mpsc::channel();
let handler = RcBlock::new(move |_error: *mut NSError| {
let _ = sender.send(());
});
unsafe { self.stream.stopCaptureWithCompletionHandler(Some(&handler)) };
let _ = receiver.recv_timeout(HANDSHAKE_TIMEOUT);
}
}
fn start(config: &DesktopConfig, shared: Arc<Shared>) -> Result<Session, String> {
let display_id = CGMainDisplayID();
let (width, height) = backing_size(display_id);
if width == 0 || height == 0 {
return Err("the main display reports a zero-sized desktop".into());
}
let content = shareable_displays()?;
let displays = unsafe { content.displays() };
let display = displays
.iter()
.find(|display| unsafe { display.displayID() } == display_id)
.ok_or_else(|| "ScreenCaptureKit does not offer the main display".to_string())?;
let filter = unsafe {
SCContentFilter::initWithDisplay_excludingWindows(
SCContentFilter::alloc(),
&display,
&NSArray::new(),
)
};
let configuration = unsafe { SCStreamConfiguration::new() };
unsafe {
configuration.setWidth(width as usize);
configuration.setHeight(height as usize);
configuration.setPixelFormat(kCVPixelFormatType_32BGRA);
configuration.setShowsCursor(config.cursor);
configuration.setQueueDepth(2);
}
let sink = StreamSink::new(shared);
let queue = DispatchQueue::new("bevy_iroh.screen", DispatchQueueAttr::SERIAL);
let stream = unsafe {
SCStream::initWithFilter_configuration_delegate(
SCStream::alloc(),
&filter,
&configuration,
None,
)
};
unsafe {
stream.addStreamOutput_type_sampleHandlerQueue_error(
ProtocolObject::from_ref(&*sink),
SCStreamOutputType::Screen,
Some(&queue),
)
}
.map_err(|why| {
format!(
"could not attach to the stream: {}",
why.localizedDescription()
)
})?;
let (sender, receiver) = mpsc::channel();
let handler = RcBlock::new(move |error: *mut NSError| {
let message =
unsafe { error.as_ref() }.map(|error| error.localizedDescription().to_string());
let _ = sender.send(message);
});
unsafe { stream.startCaptureWithCompletionHandler(Some(&handler)) };
match receiver.recv_timeout(HANDSHAKE_TIMEOUT) {
Ok(None) => {}
Ok(Some(why)) => {
return Err(format!(
"ScreenCaptureKit would not start capturing: {why}. If this is a permission \
refusal, allow the app in System Settings > Privacy & Security > Screen & \
System Audio Recording and relaunch it"
));
}
Err(_) => {
return Err(format!(
"ScreenCaptureKit did not start within {}s",
HANDSHAKE_TIMEOUT.as_secs()
));
}
}
tracing::info!("bevy_iroh: screen: sharing the main display at {width}x{height}");
Ok(Session {
stream,
_sink: sink,
_queue: queue,
})
}