Taking one screenshot with ScreenCaptureKit

ScreenCaptureKit replaced the old CoreGraphics capture APIs, and for video it is a clear improvement. For a single still image it is fine too — once you get past a permission model that is genuinely confusing.

Capturing one image

SCScreenshotManager.captureImage is the single-shot API. You need a content filter describing what to capture and a configuration describing how:

import ScreenCaptureKit

func snapshot(of display: SCDisplay,
              excluding apps: [SCRunningApplication]) async throws -> CGImage {
    let filter = SCContentFilter(display: display,
                                 excludingApplications: apps,
                                 exceptingWindows: [])
    let config = SCStreamConfiguration()
    config.width  = CGDisplayPixelsWide(display.displayID)
    config.height = CGDisplayPixelsHigh(display.displayID)
    config.showsCursor = false
    config.capturesAudio = false
    config.pixelFormat = kCVPixelFormatType_32BGRA
    return try await SCScreenshotManager.captureImage(contentFilter: filter,
                                                     configuration: config)
}

Excluding your own app matters more than it sounds. If you are about to draw the captured image on screen, and your own window is in the capture, you get a feedback loop — a picture of a picture of a picture.

Capturing every display in one pass

A common mistake is capturing only the main display, or only the built-in one. On a laptop driving an external monitor, that means your capture covers the screen the user is not looking at.

SCShareableContent is the slow part of this, so query it once and reuse it across displays rather than asking per screen — and the display set can change between calls, which is its own bug:

func snapshotAll() async throws -> [(NSScreen, CGImage)] {
    let content = try await SCShareableContent.excludingDesktopWindows(
        false, onScreenWindowsOnly: true)
    let mine = content.applications.filter {
        $0.bundleIdentifier == Bundle.main.bundleIdentifier
    }

    var shots: [(NSScreen, CGImage)] = []
    for screen in NSScreen.screens {
        guard let number = screen.deviceDescription[
                  NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber,
              let display = content.displays.first(where: {
                  $0.displayID == number.uint32Value
              }) else { continue }
        shots.append((screen, try await snapshot(of: display, excluding: mine)))
    }
    return shots
}

Why a screenshot needs Screen Recording

There is no "take one screenshot" permission on macOS. Every capture API lives under the Screen Recording TCC permission, so an app that grabs a single frame and throws it away triggers the same dialog as one that records your screen continuously — the one that says the app "would like to record this computer's screen and audio".

You cannot soften that wording. NSScreenCaptureUsageDescription in your Info.plist lets you explain yourself, but the system dialog says what it says. If your app genuinely only takes a still, the honest move is to explain it in your own UI before the prompt appears, and to make the code easy to check.

The TCC trap

This is the part that cost me most of a day, so it is the part worth writing down.

CGRequestScreenCaptureAccess() is the CoreGraphics call for requesting access. On current macOS it can return false and show no dialog at all. Silently. Your app looks broken and there is nothing to click.

Two reasons:

  • A decision is already recorded. Once an app has been offered the prompt and refused, it will not be asked again. tccutil reset ScreenCapture <bundle-id> clears it.
  • The process is not user-launched. macOS attributes a TCC request to the responsible process, walking up the process tree. An app launched from a terminal, a script or a build tool can be attributed to that launcher — and answered with the launcher's decision. No dialog ever appears for your app because your app was never the one being asked.

The second one is brutal to debug, because every fix you try from the terminal fails identically. The tell is that touching ScreenCaptureKit produces a real error message where the CoreGraphics call produces only a bool:

_ = try await SCShareableContent.excludingDesktopWindows(
        false, onScreenWindowsOnly: true)
// throws: "The user declined TCCs for application, window, display capture"

Two practical conclusions. First, prefer touching SCShareableContent over CGRequestScreenCaptureAccess() when you want the prompt — it is the modern path and it tells you what went wrong. Second, test permission flows by launching the app from Finder, like a user would. A build-and-run loop from a terminal can produce a permission state that no real user will ever hit.

One more: ad-hoc signing drops the grant

TCC binds a grant to the app's designated requirement. Sign ad hoc with codesign --sign - and that requirement is a bare hash of the binary — so every rebuild is a different app, and the permission the user granted five minutes ago is silently gone. System Settings will still show the app listed and enabled, pointing at a build that no longer exists.

Sign with a real development certificate instead. The requirement then names the bundle ID and the certificate, both of which survive a rebuild. If you automate picking the certificate, pin it — security find-identity does not guarantee a stable order, and quietly alternating between two certificates reproduces the original bug exactly.

The app this came out of

Still takes one ScreenCaptureKit snapshot when your MacBook lid starts to close, folds it with a Metal shader, and throws it away when you open the lid again. Around 40 lines of capture code, MIT licensed, all of it on GitHub.