Safe, idiomatic Rust bindings for Apple ScreenCaptureKit β high-performance screen, window, and audio capture on macOS.
See the codeSafe, idiomatic Rust bindings for Apple's ScreenCaptureKit framework.
Capture screens, windows, and applications on macOS 13.0+ with high performance and low overhead.
πΌ Looking for a hosted desktop recording API? Check out Recall.ai β an API for recording video-conferencing services, in-person meetings, and more.
https://github.com/user-attachments/assets/8a272c48-7ec3-4132-9111-4602b4fa991d
IOSurface / Metalapple-cf / apple-metal binding crates (plus trait-only futures-core when the async feature is on; no heavy third-party runtime deps)[dependencies]
screencapturekit = "11"
Opt-in features (additive):
| Feature | Enables |
|---|---|
async | Runtime-agnostic async API (Tokio / async-std / smol / β¦) |
macos_13_0 | Synchronization clock (audio capture is part of the 13.0 baseline) |
macos_14_0 | Screenshots, content picker/info, aspect ratio, stream names |
macos_14_2 | Menu bar capture, child windows, presenter overlay |
macos_14_4 | Current-process shareable content |
macos_15_0 | Recording output, HDR capture, microphone |
macos_15_2 | Screenshot in rect, stream active/inactive delegates |
macos_26_0 | Advanced screenshot config, HDR screenshot output |
macos_* features are cumulative β enabling macos_15_0 automatically enables every earlier version. Pick the highest version your minimum-supported macOS will satisfy:
screencapturekit = { version = "11", features = ["async", "macos_15_0"] }
Upgrading a major version? See
docs/MIGRATION.mdfor a per-version guide. Releases 3.0β6.0 consolidated the Core Graphics / Core Media foundation types onto the sharedapple-cfcrate and 7.0 hardens the FFI boundary; the only likely source change across that line is 5.0's nestedCGRectlayout (rect.origin.x/rect.size.width). 9.0 tightens stream and picker lifecycle handling; 10.0 finalizes the audio, Metal, picker, and shared Core Media/Core Video safety contracts described below; 11.0 turns panics and silently ignored inputs intoResults.
A minimal screen capture in ~25 lines. Everything else builds on these four steps: (1) list shareable content, (2) build a content filter, (3) configure the stream, (4) add an output handler and start.
use screencapturekit::prelude::*;
struct Handler;
impl SCStreamOutputTrait for Handler {
fn did_output_sample_buffer(&self, sample: CMSampleBuffer, _: SCStreamOutputType) {
println!("πΉ frame @ {:?}", sample.presentation_timestamp());
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let content = SCShareableContent::get()?;
let display = &content.displays()[0];
let filter = SCContentFilter::create()
.with_display(display)
.with_excluding_windows(&[])
.build()?;
let config = SCStreamConfiguration::new()
.with_width(1920)
.with_height(1080)
.with_pixel_format(PixelFormat::BGRA);
let mut stream = SCStream::new(&filter, &config)?;
stream.add_output_handler(Handler, SCStreamOutputType::Screen)?;
stream.start_capture()?;
std::thread::sleep(std::time::Duration::from_secs(5));
stream.stop_capture()?;
Ok(())
}
Output / delegate handlers must be
Send + Syncβ Apple's dispatch queues may invoke them concurrently from arbitrary threads.
Permission required β see Requirements & Permissions.
Run it: cargo run --example 01_basic_capture.
Short snippets for the most common follow-on tasks. Every recipe is a runnable
example in examples/ β see the Examples table.
use screencapturekit::prelude::*;
# fn main() -> Result<(), Box<dyn std::error::Error>> {
let content = SCShareableContent::get()?;
let window = content.windows().into_iter()
.find(|w| w.title().as_deref() == Some("Safari"))
.ok_or("Safari window not found")?;
let filter = SCContentFilter::create().with_window(&window).build()?;
let config = SCStreamConfiguration::new()
.with_captures_audio(true)
.with_sample_rate(48_000)
.with_channel_count(2);
let mut stream = SCStream::new(&filter, &config)?;
// stream.add_output_handler(...) for Screen and/or Audio
stream.start_capture()?;
# Ok(()) }
# use screencapturekit::prelude::*;
# fn example(stream: &mut SCStream) {
stream.add_output_handler(
|sample: CMSampleBuffer, _of_type: SCStreamOutputType| {
println!("πΉ frame @ {:?}", sample.presentation_timestamp());
},
SCStreamOutputType::Screen,
).expect("register output handler");
# }
Closures must be Fn + Send + Sync + 'static.
use screencapturekit::async_api::{AsyncSCShareableContent, AsyncSCStream};
use screencapturekit::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let content = AsyncSCShareableContent::get().await?;
let display = &content.displays()[0];
let filter = SCContentFilter::create()
.with_display(display).with_excluding_windows(&[]).build()?;
let config = SCStreamConfiguration::new().with_width(1920).with_height(1080);
// 30-frame ring buffer; oldest frames are dropped if the consumer can't keep up.
let stream = AsyncSCStream::new(&filter, &config, 30, SCStreamOutputType::Screen)?;
// start/stop/update are real futures β awaiting parks the task via its
// Waker and never blocks the executor thread.
stream.start_capture().await?;
while let Some(_frame) = stream.next().await {
// process frame
# break;
}
stream.stop_capture().await?;
// Distinguish a normal end from an error stop (display gone, perms revoked, β¦):
if let Some(err) = stream.take_error() {
eprintln!("stream stopped with error: {err}");
}
Ok(())
}
Requires the async feature. Works with Tokio, async-std, smol, or any
custom executor β the binding does not spawn its own runtime, and the
lifecycle methods are waker-based so they never block the executor. AsyncSCStream
also exposes frames() / frames_typed() as futures::Streams, so you can use
the StreamExt combinators (take, map, filter, collect, β¦):
use futures_util::StreamExt;
let first_30: Vec<_> = stream.frames().take(30).collect().await;
use screencapturekit::async_api::{AsyncSCShareableContent, AsyncSCStream};
use screencapturekit::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let content = AsyncSCShareableContent::get().await?;
let display = &content.displays()[0];
let filter = SCContentFilter::create()
.with_display(display).with_excluding_windows(&[]).build()?;
// Enable audio in the configuration β¦
let config = SCStreamConfiguration::new()
.with_width(1920).with_height(1080)
.with_captures_audio(true);
let mut stream = AsyncSCStream::new(&filter, &config, 32, SCStreamOutputType::Screen)?;
// β¦ then register audio as a second output type on the SAME stream.
stream.add_output_type(SCStreamOutputType::Audio)?;
stream.start_capture().await?;
// `next_typed()` yields each sample tagged with its output type.
while let Some((_sample, kind)) = stream.next_typed().await {
match kind {
SCStreamOutputType::Screen => { /* video frame */ }
SCStreamOutputType::Audio => { /* system audio */ }
_ => {}
}
# break;
}
stream.stop_capture().await?;
Ok(())
}
# #[cfg(feature = "macos_14_0")]
# fn example(
# filter: &screencapturekit::stream::content_filter::SCContentFilter,
# config: &screencapturekit::stream::configuration::SCStreamConfiguration,
# ) -> Result<(), Box<dyn std::error::Error>> {
use screencapturekit::screenshot_manager::{CGImageExt, SCScreenshotManager};
let img = SCScreenshotManager::capture_image(filter, config)?;
let pixels = img.bgra_data()?; // native BGRA β skips RβB swap
// For sustained loops, reuse a buffer:
// img.bgra_data_into(&mut buffer)?;
# Ok(()) }
use screencapturekit::content_sharing_picker::*;
use screencapturekit::prelude::*;
let config = SCContentSharingPickerConfiguration::new()?;
SCContentSharingPicker::show(&config, |outcome| match outcome {
SCPickerOutcome::Picked(result) => {
let (w, h) = result.pixel_size();
let filter = result.filter();
// Use `filter` with SCStream as in the Quick Start.
let _ = (w, h, filter);
}
SCPickerOutcome::Cancelled => println!("user cancelled"),
SCPickerOutcome::Error(e) => eprintln!("picker error: {e}"),
});
For repeating selections, retain the subscription and route per-stream
updates with the non-owning StreamIdentity:
let subscription = SCContentSharingPicker::add_observer(|event| match event {
SCPickerEvent::Updated { result, stream } => {
let filter = result.filter();
match stream {
Some(identity) => println!("update for {identity:?}: {filter:?}"),
None => println!("new selection: {filter:?}"),
}
}
SCPickerEvent::Cancelled { stream } => println!("cancelled: {stream:?}"),
SCPickerEvent::Failed(error) => eprintln!("picker error: {error}"),
}).expect("register picker observer");
SCContentSharingPicker::present().expect("present picker");
drop(subscription);
For async contexts, use AsyncSCContentSharingPicker::show.
See examples/10_recording_output.rs β it
covers SCRecordingOutput, SCRecordingOutputConfiguration, and the
delegate callbacks for start / finish / error.
use screencapturekit::prelude::*;
use screencapturekit::dispatch_queue::{DispatchQueue, DispatchQoS};
# fn example(stream: &mut SCStream) {
let queue = DispatchQueue::new("com.myapp.capture", DispatchQoS::UserInteractive);
stream.add_output_handler_with_queue(
|_sample, _of_type| { /* runs on `queue` */ },
SCStreamOutputType::Screen,
Some(&queue),
).expect("register output handler");
# }
QoS levels: Background, Utility, Default, UserInitiated, UserInteractive (Quality of Service).
use screencapturekit::prelude::*;
struct H;
impl SCStreamOutputTrait for H {
fn did_output_sample_buffer(&self, sample: CMSampleBuffer, _: SCStreamOutputType) {
if let Some(pb) = sample.pixel_buffer() {
if let Some(surface) = pb.io_surface() {
let _ = (surface.width(), surface.height());
// Wrap as `MTLTexture` (see examples 17/18) β no copy.
}
}
}
}
Built-in Metal helpers live in screencapturekit::metal and ship a small
shader library (SHADER_SOURCE) covering BGRA, YCbCr, and UI overlay
rendering. Encode uniform values with Uniforms::to_bytes() and upload them
through MetalDevice::create_buffer_with_bytes(); the generic object-
representation upload is intentionally unsafe. See
examples/16_full_metal_app/ for a complete app
and examples/18_wgpu_integration.rs for
the wgpu equivalent.
23 runnable examples cover every API surface. The full table with feature
requirements lives in examples/README.md. A few
favourites to start with:
| Example | What it shows |
|---|---|
01_basic_capture | Minimal screen capture β start here |
08_async | Async API, picker, runtime-agnostic patterns |
09_closure_handlers | Closures + delegate callbacks |
10_recording_output | Direct-to-file recording (macOS 15.0+) |
11_content_picker | System picker UI (macOS 14.0+) |
16_full_metal_app/ | Full Metal viewer app (macOS 14.0+) |
18_wgpu_integration | Zero-copy wgpu integration |
19_ffmpeg_encoding | Real-time H.264 via ffmpeg |
24_batched_apis_showcase | Batched FFI vs per-element (perf) |
cargo run --example 01_basic_capture
cargo run --example 10_recording_output --features macos_15_0
cargo run --example 08_async --features "async,macos_14_0"
See the full feature table under Install. One small example of gating version-specific options:
let mut config = SCStreamConfiguration::new().with_width(1920).with_height(1080);
#[cfg(feature = "macos_14_2")]
{
config.set_ignores_shadows_single_window(true)?;
config.set_includes_child_windows(false)?;
}
| Where | What |
|---|---|
| docs.rs | Full API reference |
docs/MIGRATION.md | Upgrading between major versions |
docs/BENCHMARKS.md | Benchmark methodology + results |
examples/README.md | All 23 examples + feature requirements |
CHANGELOG.md | Release notes |
ScreenCaptureKit itself starts at 12.3,
but this crate's Swift bridge is built with a 13.0 deployment target and uses
the 13.0 audio APIs unconditionally, so 13.0 is the real floor.xcode-select --install)Screen capture always requires user permission. To grant it:
For distribution, add a purpose string to Info.plist β the user-facing
TCC prompt requires it and the app will be terminated without one:
<key>NSScreenCaptureUsageDescription</key>
<string>Capture your screen so the app can β¦</string>
ScreenCaptureKit is purely TCC-gated: there is no code-signing
entitlement that grants screen capture access. Capture is allowed solely
when the user enables your binary under System Settings β Privacy &
Security β Screen & System Audio Recording.
| App type | What you need |
|---|---|
| Any signed macOS app (sandboxed or not) | NSScreenCaptureUsageDescription in Info.plist + user TCC grant |
| Sandboxed app | Additionally com.apple.security.app-sandbox = true in Entitlements.plist β this only turns the sandbox on; it does not grant capture |
App capturing the microphone (macOS 15+, with_captures_microphone) | NSMicrophoneUsageDescription in Info.plist + the user's Microphone grant; sandboxed or hardened-runtime apps also need com.apple.security.device.audio-input = true. System audio needs nothing beyond Screen Recording |
There is no
com.apple.security.screen-captureentitlement. That key isn't part of Apple's security-entitlements reference; the onlycom.apple.security.device.*keys arecamera,microphone,audio-input,usb, andbluetooth. The two real screen-capture entitlements (com.apple.developer.screen-capture.include-passthroughandcom.apple.developer.protected-content) are Enterprise / visionOS managed entitlements and don't apply toScreenCaptureKiton macOS.
Full capture (60 fps + 48 kHz stereo) costs ~1.9% of one core end-to-end
on Apple Silicon β the binding itself is below the noise floor of a 4 kHz
sampling profiler; nearly all CPU lives in Apple's SkyLight /
libdispatch / libxpc pipeline.
| Resolution | Expected FPS | First-frame latency |
|---|---|---|
| 1080p | 30β60 | 30β100 ms |
| 4K | 15β30 | 50β150 ms |
Hot-path tips:
BGRA to skip the per-pixel RβB swap when uploading to Metal /
wgpu / ffmpeg (CGImageExt::bgra_data is ~5% faster than rgba_data).Vec<u8> across screenshots with the *_data_into variants
(saves a ~33 MB allocation per 4K frame β new in 2.1).SCShareableContent::snapshot()
API β collapses 1 + N + 6N FFI calls into one round-trip per category
(~2Γ faster on a typical desktop).SCStreamFrameInfo attachment in one cast via
CMSampleBuffer::frame_info().use screencapturekit::prelude::*;
use screencapturekit::shareable_content::ContentSnapshot;
# fn example() -> Result<(), Box<dyn std::error::Error>> {
let content = SCShareableContent::get()?;
let ContentSnapshot { displays, windows, applications, truncation, .. } =
content.snapshot().ok_or("snapshot failed")?;
for w in &windows {
let app = w.owning_app_index.and_then(|i| applications.get(i));
println!("{} - {}", app.map(|a| &*a.application_name).unwrap_or(""),
w.title.as_deref().unwrap_or(""));
}
# let _ = (displays, truncation);
# Ok(()) }
Run benchmarks on your hardware:
cargo bench
cargo bench --bench hotspots --features macos_14_0
See docs/BENCHMARKS.md for methodology, throughput
numbers at various resolutions, and tuning guidance.
| Symptom | Likely cause / fix |
|---|---|
SCShareableContent::get() returns empty / errors | Missing Screen Recording permission β grant it in System Settings, then restart |
| Black / empty frames | Captured window minimized; pixel format mismatch; filter doesn't include the right display/window |
| No audio samples | Did you set .with_captures_audio(true) and add a handler for SCStreamOutputType::Audio? |
| Build fails with Swift bridge errors | xcode-select --install; then cargo clean && cargo build |
| App crashes after notarization | Missing NSScreenCaptureUsageDescription in Info.plist β the system terminates apps that trigger the Screen Recording TCC prompt without one (see Requirements) |
match on PixelFormat / SCStreamErrorCode no longer compiles | Both are #[non_exhaustive] in 2.0 β add a wildcard _ => β¦ arm |
Upgrading? See docs/MIGRATION.md for the full guide,
including a section for every major version bump.
Highlights by major version:
Send + Sync; PixelFormat and SCStreamErrorCode became
#[non_exhaustive]; PixelFormat gained Unknown(FourCharCode); every
macos_* Cargo feature now propagates to the Swift bridge build.IOSurface / Core
Video) moved onto the shared apple-cf
/ apple-metal crates as re-exports;
ScreenCaptureKit-specific CMSampleBuffer accessors moved to the
CMSampleBufferExt / CMSampleBufferSCExt extension traits (both in the
prelude).ScreenshotManager::capture_image returns apple_cf::cg::CGImage
and screencapturekit::cm::CMTime is an apple-cf re-export (drop any
cross-crate conversions).apple-cf 0.8's nested CGRect layout: use
rect.origin.x / rect.size.width instead of flat rect.x / rect.width.CMSampleTimingInfo and CMClock are now re-exported from
apple-cf as well.CGImageExt::rgba_data_into_strided /
bgra_data_into_strided) plus a locked IOSurface CPU view, and relaxes the
AudioBufferRef::data() slice lifetime so the returned slice is tied to the
wrapped buffer.AsyncSCStream::{start_capture, stop_capture, update_configuration, update_content_filter} now return a waker-based future
instead of blocking β add .await (e.g. stream.start_capture().await?). The
stream engine now reports stops only through
SCStreamDelegateTrait::did_stop_with_error (the redundant stream_did_stop
is deprecated). New: AsyncSCStream::{take_error, add_output_type, next_typed, try_next_typed} for error visibility and audio+video on one stream. The
synchronous SCStream API is unchanged.SCScreenshotOutput::file_url() -> Option<String> became
file_path() -> Option<PathBuf>, and
SCScreenshotConfiguration::with_file_path takes impl AsRef<Path> (&str
still works). Recording codecs and file types are open, string-backed
identifiers rather than integer enums, and recording delegates now require
Sync. SCContentFilter is immutable once built: the nonfunctional
content-rect setters are gone (crop with
SCStreamConfiguration::with_source_rect) and includeMenuBar is set while
building via SCContentFilterBuilder::with_include_menu_bar. AudioBuffer's
fields are private, with owner-tied mutable bytes behind
unsafe AudioBufferList::data_mut, and
MetalDevice::as_apple_metal hands out a borrow instead of a second owner.
SCShareableContent::current_process returns SCError::FeatureNotAvailable
below macOS 14.4 instead of quietly falling back to system-wide content, and
SCStream::update_configuration now requires the macos_14_0 feature.
Build-SDK stub mode was removed. New: repeating picker observers
(SCContentSharingPicker::add_observer) so
allows_changing_selected_content actually delivers re-selections.SCContentFilterBuilder::build,
string setters, output-handler registration and picker operations return a
Result instead of panicking, returning a bare None, or ignoring invalid
input, and their try_* twins are gone. The cm audio types come from
apple-cf 0.11, configurations are copied when handed to ScreenCaptureKit,
and the minimum Rust version is 1.82.If you only use the prelude / screencapturekit::{cg, cm} types, the 4.0β7.0
upgrades are typically just the 5.0 CGRect field-access change. 8.0 affects
you if you use the async API; 9.0 affects you if you write screenshots to
disk, configure recording outputs, crop through the content filter, read
AudioBuffer fields, or call SCShareableContent::current_process.
10.0 affects audio-buffer mutation, Metal upload/encoding paths, repeating
picker events/configuration, and direct apple-cf raw or locked-byte access.
11.0 affects almost every caller: add ? where constructors, builders and
registration calls now return a Result.
Contributions welcome! Please:
::new() and .with_*()cargo fmt && cargo clippy --all-features -- -D warnings && cargo test.
A plain cargo test never captures the screen: the live capture tests run
only with SCREENCAPTUREKIT_LIVE_TESTS=1 and Screen Recording permission.CHANGELOG.mdPowering 50+ open-source projects across screen recording, AI agents, meeting transcription, and remote desktop. A few highlights:
GStreamer pluginfl_caption, Lycoris, Hindsight, kivio, Drift, Phantom, ruhear, Tab5-Screen-Streamer, macloop, beer, phantom-ear, Logia, VibeTube, silly-ai, aresampler, xos, scriberr-desktop, echonote, zest-wallpaper, mira, overlay-ai, open-rec, omnirec, oxiremote, LocalWhisper, Hush, cocuyo, openhush, tucknotes, domino, bridge, screen-recorder, orbit, audio-capture, AFFiNE-teto, loom, transkit-desktop, iced_live_cast, vloom.
Using screencapturekit-rs? Open an issue and we'll add you.
Thanks to everyone who has contributed!
Per Johansson (maintainer) Β· Iason Paraskevopoulos Β· Kris Krolak Β· Tokuhiro Matsuno Β· Pranav Joglekar Β· Alex Jiao Β· Charles Β· bigduu Β· Andrew N
Licensed under either of Apache-2.0 or MIT at your option.
Rust
80.2%
Swift
16.8%
Python
2.8%
Safe, idiomatic Rust bindings for Apple ScreenCaptureKit β high-performance screen, window, and audio capture on macOS.
See the codeSafe, idiomatic Rust bindings for Apple's ScreenCaptureKit framework.
Capture screens, windows, and applications on macOS 13.0+ with high performance and low overhead.
πΌ Looking for a hosted desktop recording API? Check out Recall.ai β an API for recording video-conferencing services, in-person meetings, and more.
https://github.com/user-attachments/assets/8a272c48-7ec3-4132-9111-4602b4fa991d
IOSurface / Metalapple-cf / apple-metal binding crates (plus trait-only futures-core when the async feature is on; no heavy third-party runtime deps)[dependencies]
screencapturekit = "11"
Opt-in features (additive):
| Feature | Enables |
|---|---|
async | Runtime-agnostic async API (Tokio / async-std / smol / β¦) |
macos_13_0 | Synchronization clock (audio capture is part of the 13.0 baseline) |
macos_14_0 | Screenshots, content picker/info, aspect ratio, stream names |
macos_14_2 | Menu bar capture, child windows, presenter overlay |
macos_14_4 | Current-process shareable content |
macos_15_0 | Recording output, HDR capture, microphone |
macos_15_2 | Screenshot in rect, stream active/inactive delegates |
macos_26_0 | Advanced screenshot config, HDR screenshot output |
macos_* features are cumulative β enabling macos_15_0 automatically enables every earlier version. Pick the highest version your minimum-supported macOS will satisfy:
screencapturekit = { version = "11", features = ["async", "macos_15_0"] }
Upgrading a major version? See
docs/MIGRATION.mdfor a per-version guide. Releases 3.0β6.0 consolidated the Core Graphics / Core Media foundation types onto the sharedapple-cfcrate and 7.0 hardens the FFI boundary; the only likely source change across that line is 5.0's nestedCGRectlayout (rect.origin.x/rect.size.width). 9.0 tightens stream and picker lifecycle handling; 10.0 finalizes the audio, Metal, picker, and shared Core Media/Core Video safety contracts described below; 11.0 turns panics and silently ignored inputs intoResults.
A minimal screen capture in ~25 lines. Everything else builds on these four steps: (1) list shareable content, (2) build a content filter, (3) configure the stream, (4) add an output handler and start.
use screencapturekit::prelude::*;
struct Handler;
impl SCStreamOutputTrait for Handler {
fn did_output_sample_buffer(&self, sample: CMSampleBuffer, _: SCStreamOutputType) {
println!("πΉ frame @ {:?}", sample.presentation_timestamp());
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let content = SCShareableContent::get()?;
let display = &content.displays()[0];
let filter = SCContentFilter::create()
.with_display(display)
.with_excluding_windows(&[])
.build()?;
let config = SCStreamConfiguration::new()
.with_width(1920)
.with_height(1080)
.with_pixel_format(PixelFormat::BGRA);
let mut stream = SCStream::new(&filter, &config)?;
stream.add_output_handler(Handler, SCStreamOutputType::Screen)?;
stream.start_capture()?;
std::thread::sleep(std::time::Duration::from_secs(5));
stream.stop_capture()?;
Ok(())
}
Output / delegate handlers must be
Send + Syncβ Apple's dispatch queues may invoke them concurrently from arbitrary threads.
Permission required β see Requirements & Permissions.
Run it: cargo run --example 01_basic_capture.
Short snippets for the most common follow-on tasks. Every recipe is a runnable
example in examples/ β see the Examples table.
use screencapturekit::prelude::*;
# fn main() -> Result<(), Box<dyn std::error::Error>> {
let content = SCShareableContent::get()?;
let window = content.windows().into_iter()
.find(|w| w.title().as_deref() == Some("Safari"))
.ok_or("Safari window not found")?;
let filter = SCContentFilter::create().with_window(&window).build()?;
let config = SCStreamConfiguration::new()
.with_captures_audio(true)
.with_sample_rate(48_000)
.with_channel_count(2);
let mut stream = SCStream::new(&filter, &config)?;
// stream.add_output_handler(...) for Screen and/or Audio
stream.start_capture()?;
# Ok(()) }
# use screencapturekit::prelude::*;
# fn example(stream: &mut SCStream) {
stream.add_output_handler(
|sample: CMSampleBuffer, _of_type: SCStreamOutputType| {
println!("πΉ frame @ {:?}", sample.presentation_timestamp());
},
SCStreamOutputType::Screen,
).expect("register output handler");
# }
Closures must be Fn + Send + Sync + 'static.
use screencapturekit::async_api::{AsyncSCShareableContent, AsyncSCStream};
use screencapturekit::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let content = AsyncSCShareableContent::get().await?;
let display = &content.displays()[0];
let filter = SCContentFilter::create()
.with_display(display).with_excluding_windows(&[]).build()?;
let config = SCStreamConfiguration::new().with_width(1920).with_height(1080);
// 30-frame ring buffer; oldest frames are dropped if the consumer can't keep up.
let stream = AsyncSCStream::new(&filter, &config, 30, SCStreamOutputType::Screen)?;
// start/stop/update are real futures β awaiting parks the task via its
// Waker and never blocks the executor thread.
stream.start_capture().await?;
while let Some(_frame) = stream.next().await {
// process frame
# break;
}
stream.stop_capture().await?;
// Distinguish a normal end from an error stop (display gone, perms revoked, β¦):
if let Some(err) = stream.take_error() {
eprintln!("stream stopped with error: {err}");
}
Ok(())
}
Requires the async feature. Works with Tokio, async-std, smol, or any
custom executor β the binding does not spawn its own runtime, and the
lifecycle methods are waker-based so they never block the executor. AsyncSCStream
also exposes frames() / frames_typed() as futures::Streams, so you can use
the StreamExt combinators (take, map, filter, collect, β¦):
use futures_util::StreamExt;
let first_30: Vec<_> = stream.frames().take(30).collect().await;
use screencapturekit::async_api::{AsyncSCShareableContent, AsyncSCStream};
use screencapturekit::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let content = AsyncSCShareableContent::get().await?;
let display = &content.displays()[0];
let filter = SCContentFilter::create()
.with_display(display).with_excluding_windows(&[]).build()?;
// Enable audio in the configuration β¦
let config = SCStreamConfiguration::new()
.with_width(1920).with_height(1080)
.with_captures_audio(true);
let mut stream = AsyncSCStream::new(&filter, &config, 32, SCStreamOutputType::Screen)?;
// β¦ then register audio as a second output type on the SAME stream.
stream.add_output_type(SCStreamOutputType::Audio)?;
stream.start_capture().await?;
// `next_typed()` yields each sample tagged with its output type.
while let Some((_sample, kind)) = stream.next_typed().await {
match kind {
SCStreamOutputType::Screen => { /* video frame */ }
SCStreamOutputType::Audio => { /* system audio */ }
_ => {}
}
# break;
}
stream.stop_capture().await?;
Ok(())
}
# #[cfg(feature = "macos_14_0")]
# fn example(
# filter: &screencapturekit::stream::content_filter::SCContentFilter,
# config: &screencapturekit::stream::configuration::SCStreamConfiguration,
# ) -> Result<(), Box<dyn std::error::Error>> {
use screencapturekit::screenshot_manager::{CGImageExt, SCScreenshotManager};
let img = SCScreenshotManager::capture_image(filter, config)?;
let pixels = img.bgra_data()?; // native BGRA β skips RβB swap
// For sustained loops, reuse a buffer:
// img.bgra_data_into(&mut buffer)?;
# Ok(()) }
use screencapturekit::content_sharing_picker::*;
use screencapturekit::prelude::*;
let config = SCContentSharingPickerConfiguration::new()?;
SCContentSharingPicker::show(&config, |outcome| match outcome {
SCPickerOutcome::Picked(result) => {
let (w, h) = result.pixel_size();
let filter = result.filter();
// Use `filter` with SCStream as in the Quick Start.
let _ = (w, h, filter);
}
SCPickerOutcome::Cancelled => println!("user cancelled"),
SCPickerOutcome::Error(e) => eprintln!("picker error: {e}"),
});
For repeating selections, retain the subscription and route per-stream
updates with the non-owning StreamIdentity:
let subscription = SCContentSharingPicker::add_observer(|event| match event {
SCPickerEvent::Updated { result, stream } => {
let filter = result.filter();
match stream {
Some(identity) => println!("update for {identity:?}: {filter:?}"),
None => println!("new selection: {filter:?}"),
}
}
SCPickerEvent::Cancelled { stream } => println!("cancelled: {stream:?}"),
SCPickerEvent::Failed(error) => eprintln!("picker error: {error}"),
}).expect("register picker observer");
SCContentSharingPicker::present().expect("present picker");
drop(subscription);
For async contexts, use AsyncSCContentSharingPicker::show.
See examples/10_recording_output.rs β it
covers SCRecordingOutput, SCRecordingOutputConfiguration, and the
delegate callbacks for start / finish / error.
use screencapturekit::prelude::*;
use screencapturekit::dispatch_queue::{DispatchQueue, DispatchQoS};
# fn example(stream: &mut SCStream) {
let queue = DispatchQueue::new("com.myapp.capture", DispatchQoS::UserInteractive);
stream.add_output_handler_with_queue(
|_sample, _of_type| { /* runs on `queue` */ },
SCStreamOutputType::Screen,
Some(&queue),
).expect("register output handler");
# }
QoS levels: Background, Utility, Default, UserInitiated, UserInteractive (Quality of Service).
use screencapturekit::prelude::*;
struct H;
impl SCStreamOutputTrait for H {
fn did_output_sample_buffer(&self, sample: CMSampleBuffer, _: SCStreamOutputType) {
if let Some(pb) = sample.pixel_buffer() {
if let Some(surface) = pb.io_surface() {
let _ = (surface.width(), surface.height());
// Wrap as `MTLTexture` (see examples 17/18) β no copy.
}
}
}
}
Built-in Metal helpers live in screencapturekit::metal and ship a small
shader library (SHADER_SOURCE) covering BGRA, YCbCr, and UI overlay
rendering. Encode uniform values with Uniforms::to_bytes() and upload them
through MetalDevice::create_buffer_with_bytes(); the generic object-
representation upload is intentionally unsafe. See
examples/16_full_metal_app/ for a complete app
and examples/18_wgpu_integration.rs for
the wgpu equivalent.
23 runnable examples cover every API surface. The full table with feature
requirements lives in examples/README.md. A few
favourites to start with:
| Example | What it shows |
|---|---|
01_basic_capture | Minimal screen capture β start here |
08_async | Async API, picker, runtime-agnostic patterns |
09_closure_handlers | Closures + delegate callbacks |
10_recording_output | Direct-to-file recording (macOS 15.0+) |
11_content_picker | System picker UI (macOS 14.0+) |
16_full_metal_app/ | Full Metal viewer app (macOS 14.0+) |
18_wgpu_integration | Zero-copy wgpu integration |
19_ffmpeg_encoding | Real-time H.264 via ffmpeg |
24_batched_apis_showcase | Batched FFI vs per-element (perf) |
cargo run --example 01_basic_capture
cargo run --example 10_recording_output --features macos_15_0
cargo run --example 08_async --features "async,macos_14_0"
See the full feature table under Install. One small example of gating version-specific options:
let mut config = SCStreamConfiguration::new().with_width(1920).with_height(1080);
#[cfg(feature = "macos_14_2")]
{
config.set_ignores_shadows_single_window(true)?;
config.set_includes_child_windows(false)?;
}
| Where | What |
|---|---|
| docs.rs | Full API reference |
docs/MIGRATION.md | Upgrading between major versions |
docs/BENCHMARKS.md | Benchmark methodology + results |
examples/README.md | All 23 examples + feature requirements |
CHANGELOG.md | Release notes |
ScreenCaptureKit itself starts at 12.3,
but this crate's Swift bridge is built with a 13.0 deployment target and uses
the 13.0 audio APIs unconditionally, so 13.0 is the real floor.xcode-select --install)Screen capture always requires user permission. To grant it:
For distribution, add a purpose string to Info.plist β the user-facing
TCC prompt requires it and the app will be terminated without one:
<key>NSScreenCaptureUsageDescription</key>
<string>Capture your screen so the app can β¦</string>
ScreenCaptureKit is purely TCC-gated: there is no code-signing
entitlement that grants screen capture access. Capture is allowed solely
when the user enables your binary under System Settings β Privacy &
Security β Screen & System Audio Recording.
| App type | What you need |
|---|---|
| Any signed macOS app (sandboxed or not) | NSScreenCaptureUsageDescription in Info.plist + user TCC grant |
| Sandboxed app | Additionally com.apple.security.app-sandbox = true in Entitlements.plist β this only turns the sandbox on; it does not grant capture |
App capturing the microphone (macOS 15+, with_captures_microphone) | NSMicrophoneUsageDescription in Info.plist + the user's Microphone grant; sandboxed or hardened-runtime apps also need com.apple.security.device.audio-input = true. System audio needs nothing beyond Screen Recording |
There is no
com.apple.security.screen-captureentitlement. That key isn't part of Apple's security-entitlements reference; the onlycom.apple.security.device.*keys arecamera,microphone,audio-input,usb, andbluetooth. The two real screen-capture entitlements (com.apple.developer.screen-capture.include-passthroughandcom.apple.developer.protected-content) are Enterprise / visionOS managed entitlements and don't apply toScreenCaptureKiton macOS.
Full capture (60 fps + 48 kHz stereo) costs ~1.9% of one core end-to-end
on Apple Silicon β the binding itself is below the noise floor of a 4 kHz
sampling profiler; nearly all CPU lives in Apple's SkyLight /
libdispatch / libxpc pipeline.
| Resolution | Expected FPS | First-frame latency |
|---|---|---|
| 1080p | 30β60 | 30β100 ms |
| 4K | 15β30 | 50β150 ms |
Hot-path tips:
BGRA to skip the per-pixel RβB swap when uploading to Metal /
wgpu / ffmpeg (CGImageExt::bgra_data is ~5% faster than rgba_data).Vec<u8> across screenshots with the *_data_into variants
(saves a ~33 MB allocation per 4K frame β new in 2.1).SCShareableContent::snapshot()
API β collapses 1 + N + 6N FFI calls into one round-trip per category
(~2Γ faster on a typical desktop).SCStreamFrameInfo attachment in one cast via
CMSampleBuffer::frame_info().use screencapturekit::prelude::*;
use screencapturekit::shareable_content::ContentSnapshot;
# fn example() -> Result<(), Box<dyn std::error::Error>> {
let content = SCShareableContent::get()?;
let ContentSnapshot { displays, windows, applications, truncation, .. } =
content.snapshot().ok_or("snapshot failed")?;
for w in &windows {
let app = w.owning_app_index.and_then(|i| applications.get(i));
println!("{} - {}", app.map(|a| &*a.application_name).unwrap_or(""),
w.title.as_deref().unwrap_or(""));
}
# let _ = (displays, truncation);
# Ok(()) }
Run benchmarks on your hardware:
cargo bench
cargo bench --bench hotspots --features macos_14_0
See docs/BENCHMARKS.md for methodology, throughput
numbers at various resolutions, and tuning guidance.
| Symptom | Likely cause / fix |
|---|---|
SCShareableContent::get() returns empty / errors | Missing Screen Recording permission β grant it in System Settings, then restart |
| Black / empty frames | Captured window minimized; pixel format mismatch; filter doesn't include the right display/window |
| No audio samples | Did you set .with_captures_audio(true) and add a handler for SCStreamOutputType::Audio? |
| Build fails with Swift bridge errors | xcode-select --install; then cargo clean && cargo build |
| App crashes after notarization | Missing NSScreenCaptureUsageDescription in Info.plist β the system terminates apps that trigger the Screen Recording TCC prompt without one (see Requirements) |
match on PixelFormat / SCStreamErrorCode no longer compiles | Both are #[non_exhaustive] in 2.0 β add a wildcard _ => β¦ arm |
Upgrading? See docs/MIGRATION.md for the full guide,
including a section for every major version bump.
Highlights by major version:
Send + Sync; PixelFormat and SCStreamErrorCode became
#[non_exhaustive]; PixelFormat gained Unknown(FourCharCode); every
macos_* Cargo feature now propagates to the Swift bridge build.IOSurface / Core
Video) moved onto the shared apple-cf
/ apple-metal crates as re-exports;
ScreenCaptureKit-specific CMSampleBuffer accessors moved to the
CMSampleBufferExt / CMSampleBufferSCExt extension traits (both in the
prelude).ScreenshotManager::capture_image returns apple_cf::cg::CGImage
and screencapturekit::cm::CMTime is an apple-cf re-export (drop any
cross-crate conversions).apple-cf 0.8's nested CGRect layout: use
rect.origin.x / rect.size.width instead of flat rect.x / rect.width.CMSampleTimingInfo and CMClock are now re-exported from
apple-cf as well.CGImageExt::rgba_data_into_strided /
bgra_data_into_strided) plus a locked IOSurface CPU view, and relaxes the
AudioBufferRef::data() slice lifetime so the returned slice is tied to the
wrapped buffer.AsyncSCStream::{start_capture, stop_capture, update_configuration, update_content_filter} now return a waker-based future
instead of blocking β add .await (e.g. stream.start_capture().await?). The
stream engine now reports stops only through
SCStreamDelegateTrait::did_stop_with_error (the redundant stream_did_stop
is deprecated). New: AsyncSCStream::{take_error, add_output_type, next_typed, try_next_typed} for error visibility and audio+video on one stream. The
synchronous SCStream API is unchanged.SCScreenshotOutput::file_url() -> Option<String> became
file_path() -> Option<PathBuf>, and
SCScreenshotConfiguration::with_file_path takes impl AsRef<Path> (&str
still works). Recording codecs and file types are open, string-backed
identifiers rather than integer enums, and recording delegates now require
Sync. SCContentFilter is immutable once built: the nonfunctional
content-rect setters are gone (crop with
SCStreamConfiguration::with_source_rect) and includeMenuBar is set while
building via SCContentFilterBuilder::with_include_menu_bar. AudioBuffer's
fields are private, with owner-tied mutable bytes behind
unsafe AudioBufferList::data_mut, and
MetalDevice::as_apple_metal hands out a borrow instead of a second owner.
SCShareableContent::current_process returns SCError::FeatureNotAvailable
below macOS 14.4 instead of quietly falling back to system-wide content, and
SCStream::update_configuration now requires the macos_14_0 feature.
Build-SDK stub mode was removed. New: repeating picker observers
(SCContentSharingPicker::add_observer) so
allows_changing_selected_content actually delivers re-selections.SCContentFilterBuilder::build,
string setters, output-handler registration and picker operations return a
Result instead of panicking, returning a bare None, or ignoring invalid
input, and their try_* twins are gone. The cm audio types come from
apple-cf 0.11, configurations are copied when handed to ScreenCaptureKit,
and the minimum Rust version is 1.82.If you only use the prelude / screencapturekit::{cg, cm} types, the 4.0β7.0
upgrades are typically just the 5.0 CGRect field-access change. 8.0 affects
you if you use the async API; 9.0 affects you if you write screenshots to
disk, configure recording outputs, crop through the content filter, read
AudioBuffer fields, or call SCShareableContent::current_process.
10.0 affects audio-buffer mutation, Metal upload/encoding paths, repeating
picker events/configuration, and direct apple-cf raw or locked-byte access.
11.0 affects almost every caller: add ? where constructors, builders and
registration calls now return a Result.
Contributions welcome! Please:
::new() and .with_*()cargo fmt && cargo clippy --all-features -- -D warnings && cargo test.
A plain cargo test never captures the screen: the live capture tests run
only with SCREENCAPTUREKIT_LIVE_TESTS=1 and Screen Recording permission.CHANGELOG.mdPowering 50+ open-source projects across screen recording, AI agents, meeting transcription, and remote desktop. A few highlights:
GStreamer pluginfl_caption, Lycoris, Hindsight, kivio, Drift, Phantom, ruhear, Tab5-Screen-Streamer, macloop, beer, phantom-ear, Logia, VibeTube, silly-ai, aresampler, xos, scriberr-desktop, echonote, zest-wallpaper, mira, overlay-ai, open-rec, omnirec, oxiremote, LocalWhisper, Hush, cocuyo, openhush, tucknotes, domino, bridge, screen-recorder, orbit, audio-capture, AFFiNE-teto, loom, transkit-desktop, iced_live_cast, vloom.
Using screencapturekit-rs? Open an issue and we'll add you.
Thanks to everyone who has contributed!
Per Johansson (maintainer) Β· Iason Paraskevopoulos Β· Kris Krolak Β· Tokuhiro Matsuno Β· Pranav Joglekar Β· Alex Jiao Β· Charles Β· bigduu Β· Andrew N
Licensed under either of Apache-2.0 or MIT at your option.
Rust
80.2%
Swift
16.8%
Python
2.8%