Safe, idiomatic Rust bindings for Apple's ScreenCaptureKit framework.
Capture screens, windows, and applications on macOS 12.3+ with high performance and low overhead.
💼 Looking for a hosted desktop recording API? Check out Recall.ai — an API for recording Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
Screen.Recording.2025-12-11.at.11.56.46.mov
- 🎥 Screen, window, and app capture with a clean builder-pattern API
- 🔊 System audio + microphone capture (macOS 13.0+ / 15.0+)
- ⚡ Real-time, zero-copy frame delivery via
IOSurface/ Metal - 🔄 Async support that works with any executor (Tokio, async-std, smol, …)
- 📸 Screenshots + direct-to-file recording (macOS 14.0+ / 15.0+)
- 🖱️ System content picker UI (macOS 14.0+)
- 🛡️ Memory safe — proper retain/release, leak-tested
- 📦 Zero runtime dependencies
- Install · Quick Start · Recipes
- Feature Flags · Examples · Documentation
- Requirements & Permissions · Performance
- Troubleshooting · Migration · Contributing
[dependencies]
screencapturekit = "3"Opt-in features (additive):
| Feature | Enables |
|---|---|
async |
Runtime-agnostic async API (Tokio / async-std / smol / …) |
macos_13_0 |
Audio capture, sync clock |
macos_14_0 |
Screenshots, content picker, content info |
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 = "3", features = ["async", "macos_15_0"] }Upgrading from 1.x? See
docs/MIGRATION.md— the headline 2.0 changes are aSend + Syncbound on output / delegate traits,#[non_exhaustive]onPixelFormatandSCStreamErrorCode, and a newPixelFormat::Unknown(FourCharCode)variant.
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.
Window capture with audio
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(()) }Closure-based handler (no trait impl needed)
# use screencapturekit::prelude::*;
# fn example(stream: &mut SCStream) {
stream.add_output_handler(
|sample: CMSampleBuffer, _of_type: SCStreamOutputType| {
println!("📹 frame @ {:?}", sample.presentation_timestamp());
},
SCStreamOutputType::Screen,
);
# }Closures must be Fn + Send + Sync + 'static.
Async capture (any executor)
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);
stream.start_capture()?;
while let Some(_frame) = stream.next().await {
// process frame
# break;
}
stream.stop_capture()?;
Ok(())
}Requires the async feature. Works with Tokio, async-std, smol, or any
custom executor — the binding does not spawn its own runtime.
Screenshot (macOS 14.0+)
# #[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::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(()) }System content picker (macOS 14.0+)
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 async contexts, use AsyncSCContentSharingPicker::show.
Direct-to-file recording (macOS 15.0+)
See examples/10_recording_output.rs — it
covers SCRecordingOutput, SCRecordingOutputConfiguration, and the
delegate callbacks for start / finish / error.
Custom dispatch queue / QoS
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),
);
# }QoS levels: Background, Utility, Default, UserInitiated, UserInteractive (Quality of Service).
Zero-copy GPU access (IOSurface → Metal / wgpu)
use screencapturekit::prelude::*;
struct H;
impl SCStreamOutputTrait for H {
fn did_output_sample_buffer(&self, sample: CMSampleBuffer, _: SCStreamOutputType) {
if let Some(pb) = sample.image_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. 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 |
- macOS 12.3+ (Monterey) — base
ScreenCaptureKit - macOS 13.0+ — audio capture · 14.0+ — picker / screenshots · 15.0+ — recording / HDR / mic · 26.0+ — advanced screenshots
- Xcode Command Line Tools at build time (
xcode-select --install)
Screen capture always requires user permission. To grant it:
- System Settings → Privacy & Security → Screen Recording
- Enable your binary (during development this is usually your terminal or IDE)
- Restart the app
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 |
| Sandboxed app capturing system audio (macOS 13+) | Optionally com.apple.security.device.audio-input = true |
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:
- Prefer
BGRAto skip the per-pixel R↔B swap when uploading to Metal / wgpu / ffmpeg (SCScreenshotManager::bgra_datais ~5% faster thanrgba_data). - Reuse a
Vec<u8>across screenshots with the*_data_intovariants (saves a ~33 MB allocation per 4K frame — new in 2.1). - When iterating many windows / displays / apps, use the batched
SCShareableContent::snapshot()API — collapses1 + N + 6NFFI calls into one round-trip per category (~2× faster on a typical desktop). - Read every
SCStreamFrameInfoattachment in one cast viaCMSampleBuffer::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 } =
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;
# Ok(()) }Run benchmarks on your hardware:
cargo bench
cargo bench --bench hotspots --features macos_14_0See 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.
The 2.0 highlights:
SCStreamOutputTrait/SCStreamDelegateTrait(and closure overloads) now requireSend + SyncPixelFormatis#[non_exhaustive]and gainedUnknown(FourCharCode)for forward-compat with future Apple pixel formatsSCStreamErrorCodeis#[non_exhaustive]PixelFormat'sPartialEq/Hashare normalised throughFourCharCode- Every
macos_*Cargo feature now propagates to the Swift bridge build (the build fails loudly on SDK detection failure rather than silently dropping symbols)
2.1 added the bgra_data_into / rgba_data_into buffer-reuse APIs and a
native-BGRA fast path on SCScreenshotManager — both are non-breaking.
Contributions welcome! Please:
- Follow existing patterns — builder pattern with
::new()and.with_*() - Add tests for new functionality
cargo fmt && cargo clippy --all-features -- -D warnings && cargo test- Update docs and
CHANGELOG.md
See CLAUDE.md / AGENTS.md for the project conventions agents follow.
Powering 50+ open-source projects across screen recording, AI agents, meeting transcription, and remote desktop. A few highlights:
- AFFiNE — knowledge base, Notion / Miro alternative (68k+ ⭐)
- voicebox — open-source AI voice studio (25k+ ⭐)
- Cap — open-source Loom alternative (19k+ ⭐)
- Observer — local AI screen observer (1.4k+ ⭐)
- my-translator — real-time speech translation (1k+ ⭐)
- hylarana — cross-platform screen casting in Rust
- gst-screencapturekit —
GStreamerplugin - open-agent, watson.ai, harana/search, agent-native by Builder.io
And many more…
fl_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.
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.