Skip to content
All lab notes

Lab note · from Anchor

CGWindowListCreateImage unavailable in macOS 15: use SCStream

By Mark Santos · · 5 min read

In the macOS 27 SDK, CGWindowListCreateImage is marked deprecated in macOS 14.0 and obsoleted in 15.0, with the message "Please use ScreenCaptureKit instead." It compiles silently at a macOS 13 deployment target, warns at 14 and is a hard error at 15 or later. Anchor, which pins a live crop of the screen in a floating panel, still calls it up to 15 times a second from a macOS 13 target. The replacement is an SCStream over a display filter that excludes your own app, with sourceRect for the crop and minimumFrameInterval for the rate, delivering IOSurfaces that go straight into CALayer.contents.

Symptoms

  • Raising the deployment target turns every call into an error. A one-line call typechecked against the macOS 27.0 SDK: no diagnostic at macos13.0, a warning at macos14.0 ("'CGWindowListCreateImage' was deprecated in macOS 14.0: Please use ScreenCaptureKit instead."), and an error at macos15.0 and macos27.0 ("'CGWindowListCreateImage' is unavailable in macOS").
  • At a lower target you get no warning at all. A copy of Anchor's current source built with Swift 6.4 against the 27.0 SDK finished with zero warnings, and nm -u on the binary still lists _CGWindowListCreateImage.
  • Users can get system prompts. Apple's macOS 15 release notes say apps using "deprecated APIs for content capture such as CGDisplayStream & CGWindowListCreateImage can trigger system alerts indicating they might be able to collect detailed information about the user." The 15.1 notes say users "will see fewer dialogs" for apps they have already accepted.

Why it happens

Obsoleted is checked against your target

CGWindow.h in the 27.0 SDK:

#define SCREEN_CAPTURE_OBSOLETE(x,y,z) \
    __attribute__((availability(macos,introduced=x,deprecated=y,obsoleted=z,message="Please use ScreenCaptureKit instead.")));

CG_EXTERN CGImageRef __nullable CGWindowListCreateImage(CGRect screenBounds,
    CGWindowListOption listOption, CGWindowID windowID,
    CGWindowImageOption imageOption)
    SCREEN_CAPTURE_OBSOLETE(10.5,14.0,15.0);

The compiler compares those versions with your deployment target, not the Mac the app runs on. The symbol hasn't gone: it is still exported in the SDK's CoreGraphics.tbd, and dlsym finds it on macOS 27.2. A binary built for macOS 13 still links, and the symbol resolves at run time. We didn't run a capture to see what it returns there.

The performance case runs the other way

Anchor's app description called CGWindowListCreateImage hardware-accelerated. We found no Apple source for that. In WWDC22 session 10155, Apple says "ScreenCaptureKit has lower overhead than OBS's CGWindowListCreateImage-based capture", shows that capture dipping "as low as 7 fps" against 60 fps for ScreenCaptureKit, and says CPU use was "cut by up to half".

What Anchor leans on

Each anchor's panel is created at exactly the rectangle it captures, so it sits on top of its own source. AnchorPanel.swift hides it from the capture with sharingType = .none, and CGWindow.h backs that up for this function: "Any on-screen window with sharing type kCGWindowSharingNone will not be included in the image." That guarantee doesn't carry over. Apple's reference now calls NSWindow.SharingType.none "a legacy constant that macOS no longer uses" and says "Don't use this value to hide or omit content from being captured." When a developer reported such a window showing up in ScreenCaptureKit output on macOS 15.4, an Apple DTS engineer replied: "At this time there are no public APIs for preventing screen capture."

The fix

What Anchor does now, from ScreenCaptureEngine.swift and AnchorPanelContent.swift: a DispatchSourceTimer at 1/fps calls this, and the frame goes straight onto a layer with implicit animation off.

guard let image = CGWindowListCreateImage(
    captureRect, .optionOnScreenOnly, kCGNullWindowID, .bestResolution
) else {
    // First failure: Screen Recording permission likely missing
    if !hasDeliveredFrame { DispatchQueue.main.async { onCaptureFailure?() } }
    return
}
hasDeliveredFrame = true
DispatchQueue.main.async { onFrame?(image) }

// on the main thread
CATransaction.begin()
CATransaction.setDisableActions(true)
imageLayer.contents = image
CATransaction.commit()

The ScreenCaptureKit version below isn't in Anchor's repository. It follows Apple's Capturing screen content in macOS sample and typechecks against the 27.0 SDK at a macOS 14 target. We didn't run it.

import ScreenCaptureKit

final class RegionStream: NSObject, SCStreamOutput {
    private var stream: SCStream?
    private let layer: CALayer
    init(layer: CALayer) { self.layer = layer }

    /// `rect` is in global CoreGraphics points, the space Anchor's captureRect already uses.
    func start(rect: CGRect, fps: Int32) async throws {
        let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
        guard let display = content.displays.first(where: { $0.frame.contains(CGPoint(x: rect.midX, y: rect.midY)) })
        else { return }
        let own = content.applications.filter { $0.bundleIdentifier == Bundle.main.bundleIdentifier }
        let filter = SCContentFilter(display: display, excludingApplications: own, exceptingWindows: [])

        let config = SCStreamConfiguration()
        config.sourceRect = rect.offsetBy(dx: -display.frame.minX, dy: -display.frame.minY)
        let scale = CGFloat(filter.pointPixelScale)                    // macOS 14+
        config.width = Int(rect.width * scale)
        config.height = Int(rect.height * scale)
        config.minimumFrameInterval = CMTime(value: 1, timescale: fps) // 15, 10 or 5
        config.showsCursor = false

        let stream = SCStream(filter: filter, configuration: config, delegate: nil)
        try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: .main)
        try await stream.startCapture()
        self.stream = stream
    }

    func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
                of type: SCStreamOutputType) {
        guard type == .screen,
              let info = (CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false)
                          as? [[SCStreamFrameInfo: Any]])?.first,
              let raw = info[.status] as? Int, SCFrameStatus(rawValue: raw) == .complete,
              let pixels = sampleBuffer.imageBuffer,
              let surface = CVPixelBufferGetIOSurface(pixels)?.takeUnretainedValue()
        else { return }
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        layer.contents = surface
        CATransaction.commit()
    }
}

Why it works:

  • Self-exclusion is in the filter. Excluding your own bundle ID is how Apple's sample does it, and WWDC22 session 10156 gives the reason: "avoid the hall of mirrors effect by excluding your own capture application." This replaces sharingType = .none.
  • The crop and rate are configuration. SCStream.h says the sourceRect rectangle "is specified in points in the display’s logical coordinate system", hence the offset by the display's origin. Anchor's throttle (15 FPS for one anchor, 10 for two or three, 5 for four or more, in Geometry.swift) becomes minimumFrameInterval. When the tracked window moves or the anchor count changes, updateConfiguration(_:) applies a new value without restarting the stream.
  • The layer stays. CALayer.h describes contents as "typically a CGImageRef or an IOSurfaceRef", and Apple's sample sets the surface "as the layer content of an NSView". Anchor's CALayer display path carries over unchanged.
  • Unchanged screens produce no new image. A frame whose status isn't .complete carries none. The WWDC22 session puts it as "An idle frame status means the video sample hasn't changed, so there's no new IOSurface."
  • The cursor is opt-out. The header says of showsCursor: "By default the cursor is visible."

For a single still, SCScreenshotManager.captureImage(in:) takes a rect directly, but it needs macOS 15.2 and has no filter parameter, so its documentation doesn't say whether your own panel ends up in the image.

How to check you've fixed it

  • Set platforms: [.macOS("15.0")] in a branch and build. The string form works with Anchor's 5.9 manifest. A copy of Anchor failed at ScreenCaptureEngine.swift:80 with "is unavailable in macOS", so a leftover call can't pass silently.
  • Run nm -u on the built binary. _CGWindowListCreateImage should be gone.
  • Put an anchor over its own source and look for a panel inside the panel. If you see one, the self-exclusion isn't working.
  • Count .complete frames per second against the target, with the source both changing and static.
  • Remove the app from Privacy & Security > Screen Recording and confirm the first-run path shows your explanation instead of a blank panel.

Notes

  • Targets. Package.swift sets swift-tools 5.9 and .macOS(.v13), and Info.plist sets LSMinimumSystemVersion 13.0. pointPixelScale and SCScreenshotManager need macOS 14, so the migration either raises the floor or keeps the old path behind #available.
  • Permission. Screen Recording, with the NSScreenCaptureUsageDescription key that Apple's ScreenCaptureKit overview asks for; Anchor's plist has it. Anchor treats a nil image as "permission missing", but CGWindow.h documents NULL only when "the caller is not running within a Quartz GUI session or the window server is disabled". The same header provides CGPreflightScreenCaptureAccess() to check without prompting and CGRequestScreenCaptureAccess() to prompt: "A previously denied process is not re-prompted." ScreenCaptureKit's SCError.h defines SCStreamErrorUserDeclined (-3801) for "The user chose not to authorize capture". The ⌘⇧A hotkey is a CGEventTap, which Anchor gates on Accessibility, a separate permission.
  • Window-anchored mode. At selection time Anchor picks the frontmost layer-0 window under the rectangle's centre. Every 0.5 seconds it calls CGWindowListCopyWindowInfo([.optionIncludingWindow], windowID) and shifts the capture rect by the window's movement. The panel itself stays where it is. On macOS 27.2 that call returned 30 of 30 on-screen windows and 0 of 188 off-screen ones. A window that isn't on screen (minimized, hidden or on another Space, as well as closed) therefore reads as "Source window lost".
  • Occlusion. The current capture is whatever is on screen inside the rectangle, so a window dragged over the source shows up in the anchor. SCContentFilter(desktopIndependentWindow:) "captures just the independent window passed in". For cropping it, the header says a window stream honours sourceRect, but the sourceRect reference says "The system doesn't reference this value when you capture a single window". Crop the frame yourself.

More lab notes