ScreenCaptureKit: Complete Swift API Guide for macOS

Matthew Diakonov··15 min read

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 capture
  • SCStreamConfiguration - controls resolution, frame rate, pixel format, and audio settings
  • SCStream - the running capture session
  • SCStreamOutput - the delegate that receives video and audio frames
SCShareableContentquerySCContentFilterfilterSCStreamConfigurationcreateSCStream(running)framesSCStreamOutputprocessCMSampleBufferLegend:DiscoveryFilteringConfigStreamScreenCaptureKit capture pipeline

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 SCShareableContent to discover what is capturable
  • Use SCContentFilter to 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.userDeclined gracefully 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.

Related Posts