ScreenCaptureKit: Complete Swift API Guide for macOS
ScreenCaptureKit: Complete Swift API Guide for macOS
ScreenCaptureKit is Apple's framework for capturing screen content on macOS 12.3 and later. It replaced two aging APIs (CGWindowListCreateImage and AVCaptureScreenInput) with a single, async, permission-aware streaming API that gives developers fine-grained control over what gets captured, at what resolution, and at what frame rate.
This guide covers the entire framework surface: content discovery, filtering, configuration, stream management, audio capture, and the permission model. Every code block compiles on macOS 14 with Xcode 15 or later.
What ScreenCaptureKit Replaces
Before macOS 12.3, capturing the screen required choosing between two bad options. CGWindowListCreateImage was synchronous and only captured a single frame at a time. AVCaptureScreenInput could stream but was deprecated in macOS 13 and had limited filtering capabilities.
| API | Streaming | Async | Per-window filter | Status |
|---|---|---|---|---|
| CGWindowListCreateImage | No | No | No | Available (legacy) |
| AVCaptureScreenInput | Yes | No | No | Deprecated macOS 13 |
| ScreenCaptureKit | Yes | Yes | Yes | Active (macOS 12.3+) |
ScreenCaptureKit is the only API Apple actively develops. Every new macOS screen capture feature (audio capture, presenter overlay, SCRecordingOutput in macOS 14) lands here, not in the older APIs.
Core Concepts
The framework has five primary types that form a capture pipeline:
SCShareableContent- queries what is available to capture (displays, windows, apps)SCContentFilter- specifies exactly what to include or exclude from captureSCStreamConfiguration- controls resolution, frame rate, pixel format, and audio settingsSCStream- the running capture sessionSCStreamOutput- the delegate that receives video and audio frames
Permissions
ScreenCaptureKit requires the user to grant screen recording permission before any content can be captured. The permission system changed in macOS 14.
On macOS 12 and 13, apps without the App Sandbox could capture the screen without a permission prompt in some cases. On macOS 14 and later, all apps require explicit permission regardless of sandbox status.
import ScreenCaptureKit
// Check permission status before attempting capture
func checkPermission() async -> Bool {
do {
// This call triggers the permission dialog if not yet granted
let _ = try await SCShareableContent.excludingDesktopWindows(
false,
onScreenWindowsOnly: true
)
return true
} catch SCStreamError.userDeclined {
// User denied permission - direct them to System Settings
return false
} catch {
return false
}
}
For sandboxed apps, add the com.apple.security.screen-capture entitlement to your .entitlements file:
<key>com.apple.security.screen-capture</key>
<true/>
There is no API to re-prompt the user after they deny permission. Your app needs to show a message explaining how to enable it in System Settings > Privacy and Security > Screen Recording.
SCShareableContent - Discovering What to Capture
SCShareableContent is the starting point for any capture session. It queries the system for available displays, windows, and running applications.
let content = try await SCShareableContent.excludingDesktopWindows(
false, // include windows that are not on screen
onScreenWindowsOnly: true // only return windows currently visible
)
// Available displays
for display in content.displays {
print("Display \(display.displayID): \(display.width)x\(display.height)")
}
// Available windows
for window in content.windows {
let title = window.title ?? "(no title)"
let appName = window.owningApplication?.applicationName ?? "(unknown)"
let isOnScreen = window.isOnScreen
print("\(appName): \(title) - on screen: \(isOnScreen)")
}
// Running applications
for app in content.applications {
print("\(app.applicationName) (\(app.bundleIdentifier ?? "?"))")
}
The excludingDesktopWindows parameter controls whether system-level windows (desktop, Dock, menu bar) appear in the results. Setting it to true hides them, which is usually what you want.
SCContentFilter - Specifying What to Capture
The content filter tells the stream what to include and exclude. There are several initializers depending on your use case:
// Capture an entire display (include everything)
let displayFilter = SCContentFilter(
display: content.displays.first!,
excludingApplications: [],
exceptingWindows: []
)
// Capture a display, excluding your own app
let selfApp = content.applications.first {
$0.bundleIdentifier == Bundle.main.bundleIdentifier
}
let selfExcludedFilter = SCContentFilter(
display: content.displays.first!,
excludingApplications: selfApp.map { [$0] } ?? [],
exceptingWindows: []
)
// Capture a single window
let safariWindow = content.windows.first {
$0.owningApplication?.bundleIdentifier == "com.apple.Safari"
}
if let window = safariWindow {
let windowFilter = SCContentFilter(desktopIndependentWindow: window)
}
// Capture specific windows from a display (allowlist approach)
let targetWindows = content.windows.filter {
$0.owningApplication?.bundleIdentifier == "com.apple.Xcode"
}
let allowlistFilter = SCContentFilter(
display: content.displays.first!,
including: targetWindows
)
The including: initializer is useful for multi-window capture where you want to specify an explicit allowlist rather than an exclusion list.
SCStreamConfiguration - Controlling Capture Parameters
SCStreamConfiguration controls every aspect of the captured output before frames arrive at your delegate.
let config = SCStreamConfiguration()
// Resolution - use logical points or physical pixels
config.width = Int(display.width) * 2 // pixel-perfect on Retina
config.height = Int(display.height) * 2
// Frame rate via minimum interval between frames
config.minimumFrameInterval = CMTime(value: 1, timescale: 30) // 30 fps
// config.minimumFrameInterval = CMTime(value: 1, timescale: 60) // 60 fps
// Pixel format
config.pixelFormat = kCVPixelFormatType_32BGRA
// Cursor visibility
config.showsCursor = true
// Frame queue depth - frames buffer until processed
// Higher depth reduces drops but increases latency
config.queueDepth = 5
// Scale to fit if capturing a single window
config.scalesToFit = true
Pixel Format Selection
| Format | Constant | Best for | Memory |
|---|---|---|---|
| BGRA 8-bit | kCVPixelFormatType_32BGRA | UI display, image processing | 4 bytes/px |
| YUV 420 video range | kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange | H.264/HEVC encoding | 1.5 bytes/px |
| YUV 420 full range | kCVPixelFormatType_420YpCbCr8BiPlanarFullRange | H.264/HEVC (full color) | 1.5 bytes/px |
| l10r (10-bit BGRA) | kCVPixelFormatType_ARGB2101010LEPacked | HDR workflows | 4 bytes/px |
Choose YUV 420 when piping frames into AVAssetWriter or VideoToolbox. It saves a color space conversion step that would otherwise add CPU overhead on every frame.
Audio Configuration
// Enable audio capture
config.capturesAudio = true
// Audio sample rate
config.sampleRate = 48000
// Channel count (1 = mono, 2 = stereo)
config.channelCount = 2
SCStream - Managing the Session
With a filter and configuration ready, create and start the stream:
class CaptureSession: NSObject, SCStreamOutput, SCStreamDelegate {
private var stream: SCStream?
private let queue = DispatchQueue(label: "com.capture.output", qos: .userInteractive)
func start(filter: SCContentFilter, config: SCStreamConfiguration) async throws {
stream = SCStream(filter: filter, configuration: config, delegate: self)
// Add video output
try stream?.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue)
// Add audio output if capturesAudio is enabled
try stream?.addStreamOutput(self, type: .audio, sampleHandlerQueue: queue)
try await stream?.startCapture()
}
func stop() async throws {
try await stream?.stopCapture()
stream = nil
}
// Update configuration while the stream is running (macOS 13+)
func update(config: SCStreamConfiguration) async throws {
try await stream?.updateConfiguration(config)
}
// Update content filter while running (macOS 13+)
func update(filter: SCContentFilter) async throws {
try await stream?.updateContentFilter(filter)
}
// MARK: - SCStreamOutput
func stream(
_ stream: SCStream,
didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
of type: SCStreamOutputType
) {
switch type {
case .screen:
processVideoFrame(sampleBuffer)
case .audio:
processAudioFrame(sampleBuffer)
@unknown default:
break
}
}
// MARK: - SCStreamDelegate
func stream(_ stream: SCStream, didStopWithError error: Error) {
print("Stream stopped with error: \(error)")
}
// MARK: - Frame processing
private func processVideoFrame(_ sampleBuffer: CMSampleBuffer) {
guard let pixelBuffer = sampleBuffer.imageBuffer else { return }
let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
// Process or display the frame
}
private func processAudioFrame(_ sampleBuffer: CMSampleBuffer) {
// Extract PCM audio data for recording or playback
}
}
Threading Note
Frame callbacks fire on the queue you specify in addStreamOutput, not the main thread. Dispatch UI updates to the main actor. Heavy processing (encoding, compression) should happen on a background queue to avoid blocking the callback and dropping frames.
Live Updates Without Restarting
One of ScreenCaptureKit's most useful features is the ability to update the content filter or stream configuration while the stream is running. This avoids the cost of stopping and restarting capture when the user switches windows or changes settings.
// User selected a different window - update filter without restarting
let newWindow = content.windows.first { $0.title == "New Window" }
if let window = newWindow {
let newFilter = SCContentFilter(desktopIndependentWindow: window)
try await stream?.updateContentFilter(newFilter)
}
// User changed frame rate preference
let newConfig = SCStreamConfiguration()
newConfig.minimumFrameInterval = CMTime(value: 1, timescale: 60) // bump to 60fps
try await stream?.updateConfiguration(newConfig)
This API requires macOS 13 or later. On macOS 12.3, you must stop and restart the stream to change parameters.
SCRecordingOutput - Direct to File (macOS 14+)
macOS 14 added SCRecordingOutput, which writes captured content directly to a file without requiring your app to process individual frames. This is the simplest way to record the screen to a video file.
import ScreenCaptureKit
// macOS 14+ only
if #available(macOS 14.0, *) {
let outputConfig = SCRecordingOutputConfiguration()
outputConfig.outputURL = URL(fileURLWithPath: "/tmp/recording.mp4")
outputConfig.videoCodecType = .h264
let recordingOutput = SCRecordingOutput(configuration: outputConfig, delegate: recordingDelegate)
try stream?.addRecordingOutput(recordingOutput)
}
Use SCRecordingOutput when you want to save to a file. Use SCStreamOutput with manual frame processing when you need to analyze, display, or encode frames in a custom pipeline.
Handling Permission Denial
When the user denies screen recording permission, SCShareableContent throws SCStreamError.userDeclined. Your app cannot re-trigger the permission prompt; the user must go to System Settings manually.
do {
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
// proceed with capture
} catch SCStreamError.userDeclined {
// Show UI directing user to System Settings
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")!
NSWorkspace.shared.open(url)
} catch {
print("Unexpected error: \(error)")
}
The URL x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture opens System Settings directly to the Screen Recording section on macOS 13 and later.
Common Pitfalls
Retina resolution. SCDisplay.width and SCDisplay.height return logical points. On a Retina display, multiply by 2 for pixel-accurate capture. Setting the configuration size to logical points captures at half resolution.
// Wrong for Retina - captures at half resolution
config.width = Int(display.width)
// Correct - pixel-accurate on Retina
let scale = Int(NSScreen.main?.backingScaleFactor ?? 2)
config.width = Int(display.width) * scale
config.height = Int(display.height) * scale
Frame drops from slow delegates. If stream(_:didOutputSampleBuffer:of:) takes longer than the frame interval, frames queue up to queueDepth and then get dropped. The stream calls stream(_:didDropSampleBuffer:of:reason:) when this happens. Listen for it and either reduce frame rate or offload processing.
func stream(
_ stream: SCStream,
didDropSampleBuffer sampleBuffer: CMSampleBuffer,
of type: SCStreamOutputType,
reason: SCStreamFrameInfo.Status
) {
print("Frame dropped, reason: \(reason)")
// Consider reducing minimumFrameInterval
}
Not stopping the stream on app exit. If the stream keeps running when your app quits, the screen recording indicator in the menu bar can linger. Always call stopCapture() before deallocating the stream.
Audio and video sync. When capturing both audio and video, each frame's CMSampleBuffer contains presentation timestamps. Use these timestamps (not wall clock time) to sync audio and video when writing to a file, or you will get drift over long recordings.
Performance Characteristics
ScreenCaptureKit uses IOSurface-based zero-copy frame delivery. The pixel buffer in each CMSampleBuffer points directly to shared GPU memory; no copy happens between the capture driver and your app.
| Metric | macOS 12.3 (Intel) | macOS 14+ (Apple Silicon) | |---|---|---| | CPU for 1080p@30fps capture | ~3-5% | ~1-2% | | CPU for 4K@60fps capture | ~15-25% | ~3-5% | | Latency (capture to callback) | ~16-33ms | ~8-16ms | | Hardware encoder available | Yes (H.264) | Yes (H.264, HEVC, ProRes) |
On Apple Silicon, the hardware encoder is extremely efficient. Recording 4K@60fps with HEVC encoding uses less CPU than recording 1080p@30fps with software H.264 encoding on older Intel hardware.
Framework Availability by macOS Version
| Feature | macOS 12.3 | macOS 13 | macOS 14 | |---|---|---|---| | SCStream (video) | Yes | Yes | Yes | | Audio capture | No | Yes | Yes | | Live filter update | No | Yes | Yes | | Live config update | No | Yes | Yes | | SCRecordingOutput | No | No | Yes | | Presenter overlay | No | No | Yes |
Always guard version-specific features with #available:
if #available(macOS 13.0, *) {
config.capturesAudio = true
}
if #available(macOS 14.0, *) {
// Use SCRecordingOutput for file-based recording
}
Minimal Compilable Example
Here is a self-contained macOS app that captures the primary display and shows frames in a window:
import SwiftUI
import ScreenCaptureKit
import CoreMedia
import CoreImage
@main
struct CaptureApp: App {
var body: some Scene {
WindowGroup { CaptureView() }
}
}
@MainActor
class CaptureModel: NSObject, ObservableObject, SCStreamOutput {
@Published var frame: NSImage?
private var stream: SCStream?
private let queue = DispatchQueue(label: "capture", qos: .userInteractive)
private let ctx = CIContext()
func start() async {
do {
let content = try await SCShareableContent.excludingDesktopWindows(
false, onScreenWindowsOnly: true
)
guard let display = content.displays.first else { return }
let filter = SCContentFilter(
display: display,
excludingApplications: [],
exceptingWindows: []
)
let config = SCStreamConfiguration()
config.width = Int(display.width) * 2
config.height = Int(display.height) * 2
config.minimumFrameInterval = CMTime(value: 1, timescale: 30)
config.pixelFormat = kCVPixelFormatType_32BGRA
config.showsCursor = true
stream = SCStream(filter: filter, configuration: config, delegate: nil)
try stream?.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue)
try await stream?.startCapture()
} catch {
print("Capture error: \(error)")
}
}
func stop() async {
try? await stream?.stopCapture()
stream = nil
}
nonisolated func stream(
_ stream: SCStream,
didOutputSampleBuffer buf: CMSampleBuffer,
of type: SCStreamOutputType
) {
guard type == .screen, let pb = buf.imageBuffer else { return }
let ci = CIImage(cvPixelBuffer: pb)
guard let cg = ctx.createCGImage(ci, from: ci.extent) else { return }
let ns = NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height))
Task { @MainActor in self.frame = ns }
}
}
struct CaptureView: View {
@StateObject private var model = CaptureModel()
@State private var running = false
var body: some View {
VStack(spacing: 16) {
if let f = model.frame {
Image(nsImage: f)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 800, maxHeight: 500)
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {
Color.black.opacity(0.1)
.frame(maxWidth: 800, maxHeight: 500)
.overlay(Text("Press Start to capture").foregroundColor(.secondary))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
Button(running ? "Stop" : "Start") {
Task {
if running { await model.stop() } else { await model.start() }
running.toggle()
}
}
.buttonStyle(.borderedProminent)
.tint(Color(hex: "#0d9488"))
}
.padding(24)
.frame(minWidth: 600, minHeight: 400)
}
}
Add ScreenCaptureKit.framework to your target's linked frameworks, run, click Start, and approve the permission prompt.
Testing Without a Physical Display
When running in CI or unit tests, there may be no display available. SCShareableContent will return an empty displays array rather than throwing an error. Test your fallback path explicitly:
func testNoDisplayFallback() async throws {
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
if content.displays.isEmpty {
// Fallback: skip capture, show error, etc.
return
}
// ... proceed with test
}
Summary
ScreenCaptureKit provides a clean, async API for everything from a simple screenshot replacement to a full screen recording app with audio. The key points:
- Use
SCShareableContentto discover what is capturable - Use
SCContentFilterto specify exactly what to include or exclude - Configure resolution, frame rate, and pixel format with
SCStreamConfiguration - Receive frames on your chosen queue via
SCStreamOutput - Use
SCRecordingOutput(macOS 14+) for direct-to-file recording without frame-by-frame processing - Always handle
SCStreamError.userDeclinedgracefully and direct users to System Settings
Fazm is an open source macOS AI agent that uses ScreenCaptureKit for real-time screen understanding. Open source on GitHub.