Lab note · from Anchor
CGWindowListCreateImage unavailable in macOS 15: use SCStream
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 atmacos14.0("'CGWindowListCreateImage' was deprecated in macOS 14.0: Please use ScreenCaptureKit instead."), and an error atmacos15.0andmacos27.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 -uon 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&CGWindowListCreateImagecan 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.hsays thesourceRectrectangle "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, inGeometry.swift) becomesminimumFrameInterval. When the tracked window moves or the anchor count changes,updateConfiguration(_:)applies a new value without restarting the stream. - The layer stays.
CALayer.hdescribescontentsas "typically a CGImageRef or an IOSurfaceRef", and Apple's sample sets the surface "as the layer content of an NSView". Anchor'sCALayerdisplay path carries over unchanged. - Unchanged screens produce no new image. A frame whose status isn't
.completecarries 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 atScreenCaptureEngine.swift:80with "is unavailable in macOS", so a leftover call can't pass silently. - Run
nm -uon the built binary._CGWindowListCreateImageshould 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
.completeframes 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.swiftsets swift-tools 5.9 and.macOS(.v13), andInfo.plistsetsLSMinimumSystemVersion13.0.pointPixelScaleandSCScreenshotManagerneed macOS 14, so the migration either raises the floor or keeps the old path behind#available. - Permission. Screen Recording, with the
NSScreenCaptureUsageDescriptionkey that Apple's ScreenCaptureKit overview asks for; Anchor's plist has it. Anchor treats anilimage as "permission missing", butCGWindow.hdocumentsNULLonly when "the caller is not running within a Quartz GUI session or the window server is disabled". The same header providesCGPreflightScreenCaptureAccess()to check without prompting andCGRequestScreenCaptureAccess()to prompt: "A previously denied process is not re-prompted." ScreenCaptureKit'sSCError.hdefinesSCStreamErrorUserDeclined(-3801) for "The user chose not to authorize capture". The ⌘⇧A hotkey is aCGEventTap, 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 honourssourceRect, but thesourceRectreference says "The system doesn't reference this value when you capture a single window". Crop the frame yourself.
More lab notes
- Intercept Cmd+Q with a CGEventTap and repost it without a loopDrop Cmd+Q in an active session tap, then send a new key-down/up with CGEventPostToPid. It enters after session taps, so your own tap never sees it.
- Check a password against Have I Been Pwned without sending itSHA-1 the password, send only the first 5 hex characters to the Pwned Passwords range API, match the suffix locally, and add Add-Padding: true.
- Claude Code signed out by another app: refresh token rotationAnthropic rotates the refresh token on every refresh, so another app that refreshes Claude Code's login signs it out. Only Claude Code should refresh it.
- Hide a macOS app the moment it activates, then ask for Touch IDHide the app inside the didActivateApplicationNotification handler, then run LAContext. It can't promise no flash: the app is already active.