Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Screen Recording Permission in macOS XCUITest

If your macOS app reads the window list — for a screenshot tool, meeting overlay, accessibility helper, or screen recorder — you need an automated check that the screen-recording TCC gate is actually open on the runner before the suite starts. This guide shows how to assert permission state from XCUITest, fail loudly when the gate is closed, and export a JSON evidence file per test run.

Apple does not ship a supported command to grant a TCC decision from the shell: tccutil exposes only reset. Treat screen-recording permission as a one-time setup step on your runner (an interactive grant preserved in the TCC database, or an MDM/PPPC configuration profile on supervised fleets), then let the test suite enforce the grant on every run.

The TCC Screen-Recording Gate

Since macOS Catalina, CGWindowListCopyWindowInfo does not prompt and does not throw when screen-recording permission is absent. It silently filters the metadata it returns: the kCGWindowName key is withheld from every window, and kCGWindowSharingState is set to zero for windows your app does not own.

That silent degradation is the common CI bug: the test passes because the call succeeds, but your app receives empty window names and cannot do its job.

The modern API surface for checking and requesting permission is on CGPreflightScreenCaptureAccess / CGRequestScreenCaptureAccess:

// Swift — check before reading the window list
if CGPreflightScreenCaptureAccess() {
    // permission already granted; proceed
} else {
    CGRequestScreenCaptureAccess()
    // macOS shows the system prompt; a CI runner must pre-grant interactively or via PPPC
}

CGRequestScreenCaptureAccess() shows a UI prompt, which makes it unsuitable for a headless test leg. Pre-grant on the runner once, then use the preflight call as your gate.

Setting Up Permission on the Runner

tccutil only supports the reset verb; there is no public insert or add verb that writes a grant. Scripts that call tccutil insert ScreenCapture ... allowed will fail on current macOS. For a CI Mac you have two supported options:

  1. One-time interactive grant. Open the runner's desktop (on a hosted Mac this is the browser console or a local session), run the test host once, approve the Screen Recording prompt in System Settings → Privacy & Security → Screen Recording, and leave the decision in the TCC database. Grant both the app under test and the signed xctest tool. Check the exact identity with codesign -dr - $(xcrun --find xctest); the bundle changes across Xcode versions.
  2. Managed profile. On a supervised fleet, deploy a PPPC (Privacy Preferences Policy Control) configuration profile that pre-authorises your signed bundles. This is the only Apple-supported way to pre-seed the decision without a prompt.

If you need to re-prompt during setup, tccutil reset ScreenCapture <bundle-id> is the supported verb. macOS 15 can show a weekly reminder sheet for screen capture; supervised fleets suppress it via PPPC, and on a solo runner you dismiss the sheet and re-verify the grant on the next run.

The XCUITest Assertion

The test launches the app, calls CGPreflightScreenCaptureAccess(), then queries the window list and asserts at least one window has a non-empty name — only possible when the test host already holds the grant. If permission is missing, the test fails loudly rather than silently passing on filtered metadata.

import XCTest
import CoreGraphics

final class ScreenRecordingPermissionTests: XCTestCase {

    func testWindowListReturnsNamesWithPermission() throws {
        let app = XCUIApplication()
        app.launch()
        XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 5))

        XCTAssertTrue(CGPreflightScreenCaptureAccess(),
            "Screen Recording not granted to the test host. Grant it in System Settings "
            + "to the signed xctest tool (check with `codesign -dr - $(xcrun --find xctest)`).")

        let windowList = CGWindowListCopyWindowInfo(
            [.optionAll, .excludeDesktopElements],
            kCGNullWindowID
        ) as? [[CFString: Any]] ?? []

        XCTAssertFalse(windowList.isEmpty, "CGWindowListCopyWindowInfo returned no windows")

        let namedCount = windowList.filter { dict in
            (dict[kCGWindowName] as? String).map { !$0.isEmpty } ?? false
        }.count

        XCTAssertGreaterThan(namedCount, 0,
            "No window names returned — screen-recording permission is likely absent")

        let evidence: [String: Any] = [
            "totalWindowCount": windowList.count,
            "namedWindowCount": namedCount,
            "permissionGranted": namedCount > 0,
            "timestamp": ISO8601DateFormatter().string(from: Date())
        ]
        try writeEvidenceJSON(evidence)
    }

    private func writeEvidenceJSON(_ dict: [String: Any]) throws {
        let data = try JSONSerialization.data(
            withJSONObject: dict,
            options: [.prettyPrinted, .sortedKeys]
        )
        let dir = ProcessInfo.processInfo.environment["EVIDENCE_DIR"] ?? NSTemporaryDirectory()
        let url = URL(fileURLWithPath: dir)
            .appendingPathComponent("screen-recording-evidence.json")
        try data.write(to: url, options: .atomic)
        print("Evidence written to \(url.path)")
    }
}

Attach the evidence file to the xcresult bundle via XCTAttachment or have your CI step copy it next to TestResults.xcresult. Code above is an unexecuted illustration; run it against your own project and scheme before trusting the output.

Keeping TCC State Predictable Between Runs

Do not reset Screen Recording at the end of every run. Once the record is cleared there is no supported command to put the decision back without a prompt. The practical workflow for a dedicated CI Mac:

  1. Grant permission once during runner setup for the app bundle and the signed xctest tool.
  2. Document the two bundle identifiers in your runner playbook so a rebuild knows what to re-approve.
  3. Only run tccutil reset ScreenCapture <bundle> when you need to re-verify the prompt flow, and schedule an interactive re-grant immediately after.

Both the app under test and the test host call CGWindowListCopyWindowInfo, so both must appear in the Screen Recording pane.

Pick the Right Mac for This Test

The Figment $29 virtual Intel macOS VM runs macOS on Linux/KVM without GPU acceleration or audio, so unit tests, compile steps, and headless logic exercise fine, but the window-server graph behind CGWindowListCopyWindowInfo is not the hardware-backed CoreGraphics stack your users run. For a faithful screen-recording TCC test, use a physical Mac.

A one-scenario worksheet for a solo iOS/macOS developer running this suite nightly:

OptionPublished priceTermNotes
Figment M-series (entry)from $95/momonthly, specs quoted on requestManaged CI add-ons start at $249/mo for one Mac with CI setup, backups, monitoring and 30 minutes of monthly support.
CloudMac M4, 16 GB, 256 GB$99.99/momonthly256 GB row shows "Reserve now"; 512 GB and 1 TB show "Fully reserved".
CloudMac M4, 16 GB, 256 GB annual$999/yr ($83.25/mo effective)12-month prepayCheaper than 12 × $95 = $1,140 at Figment's entry price; CloudMac quotes a specific M4/16 GB config, Figment's $95 entry is an unspecified M-series until quoted.

If you need a verified M4 at 16 GB, CloudMac's annual plan is the lower sticker. Choose Figment when you want the specific managed CI work — image prep, signing keychain layout, runner registration, xcresult retention — bundled with the box, or when you need the $29 Intel VM for cheap pre-flight build/unit legs alongside the physical runner. Confirm exact chip, RAM, disk, availability, and any taxes or fees in the actual offer before you commit.

Request a configuration at /mac-hosting/start or review plans at /mac-hosting.


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms