Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Safari WebRTC Permission Prompts on macOS

Short answer: Testing Safari's camera and microphone dialog needs a real macOS session with a working TCC daemon, plus the right entitlements on the test host. On Figment, the $29 virtual Intel macOS VM has no audio or GPU hardware exposed, so a full getUserMedia({video:true, audio:true}) stream test cannot complete there; use it for TCC and UI plumbing on compatible Intel Xcode (Xcode 26 or earlier). For an end-to-end video+audio permission test, request an M-series Mac (from $95/month; exact chip, RAM and disk are confirmed in the quote). Start at /mac-hosting/start.

Why a Real macOS Session Is Required for These Dialogs

macOS enforces camera and microphone access through Transparency, Consent, and Control (TCC). When Safari calls getUserMedia(), macOS checks whether the user has previously granted camera or microphone access to Safari and, if not, presents a system-level permission dialog owned by the TCC subsystem, not Safari itself.

The iOS Simulator does not surface a user-level TCC prompt for WebRTC, and WebView mocks that return a canned MediaStream cannot exercise the dialog at all. An XCUITest designed to tap an Allow button will fail in Simulator because there is no button to tap. A real macOS session — whether a developer workstation or a hosted Mac — is the only place the full code path runs.

Entitlements and Info.plist Keys the Test Host Must Carry

Apple's media-capture authorization documentation lists the Info.plist usage strings and Hardened Runtime keys the test host must declare (developer.apple.com — Requesting authorization to capture and save media). Without the usage descriptions, macOS suppresses the dialog silently:

<!-- TestHostApp-Info.plist -->
<key>NSCameraUsageDescription</key>
<string>Automated test verifying WebRTC camera permission flow</string>
<key>NSMicrophoneUsageDescription</key>
<string>Automated test verifying WebRTC microphone permission flow</string>

For macOS apps built with Hardened Runtime, the entitlements file also needs:

<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>

Without these, AVCaptureDevice.requestAccess(for:) returns .denied and the system records a silent rejection rather than prompting. These keys are on your test host or wrapper app; Safari itself already ships with the required entitlements, and resetting its TCC entry is what gets the prompt to reappear.

Triggering getUserMedia() and Intercepting the Dialog

The test drives a minimal HTML page loaded in Safari that calls navigator.mediaDevices.getUserMedia({video: true, audio: true}) on page load. Register the interruption monitor before navigation; addUIInterruptionMonitor is defined on XCTestCase and, per Apple's XCUIApplication reference, XCUIApplication(bundleIdentifier:) launches Safari by its bundle identifier:

import XCTest

final class SafariWebRTCPermissionTests: XCTestCase {

    func testGrantCameraAndMicPermission() throws {
        let safari = XCUIApplication(bundleIdentifier: "com.apple.Safari")

        // Register BEFORE launching/navigating so the handler is armed.
        addUIInterruptionMonitor(withDescription: "Camera/Mic permission") { alert in
            let allow = alert.buttons["Allow"]
            guard allow.exists else { return false }
            allow.tap()
            return true
        }

        safari.launch()
        safari.typeKey("l", modifierFlags: .command)
        let addressBar = safari.windows.firstMatch.textFields.firstMatch
        XCTAssert(addressBar.waitForExistence(timeout: 3), "Address bar did not appear")
        addressBar.typeText("http://localhost:8080/webrtc-test.html\n")

        // XCTest processes pending interruptions on the next interaction.
        safari.tap()
    }
}

addUIInterruptionMonitor only fires while there is a subsequent interaction with the host app, which is why safari.tap() follows the navigation. Without that nudge the handler can sit dormant and the test times out holding a visible dialog.

Asserting Permission State Without sleep()

Replace fixed delays with XCTNSPredicateExpectation. Have the test page flip a visible DOM element from "Requesting…" to "Stream active: video+audio" once the getUserMedia() promise resolves, then wait on that condition:

func assertWebRTCGranted(in safari: XCUIApplication, timeout: TimeInterval = 10) {
    let status = safari.webViews.firstMatch
        .staticTexts.matching(
            NSPredicate(format: "label BEGINSWITH 'Stream active'")
        ).firstMatch

    let expectation = XCTNSPredicateExpectation(
        predicate: NSPredicate(format: "exists == true"),
        object: status
    )
    let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
    XCTAssertEqual(result, .completed,
        "Stream active never appeared; permission may have been denied or getUserMedia rejected")
}

This gives XCTest a concrete, timing-independent condition and prevents a flaky sleep(3) from masking a real failure. If the promise rejects, the DOM text never changes and the expectation fails with a useful message instead of a green build on a broken stream.

Capturing Screenshot Evidence of the Permission Dialog

Grab the screenshot inside the interruption monitor so the dialog is still visible, and set lifetime = .keepAlways so the attachment is kept on passing runs too:

addUIInterruptionMonitor(withDescription: "Camera/Mic permission") { alert in
    let shot = alert.screenshot()
    let attachment = XCTAttachment(screenshot: shot)
    attachment.name = "webrtc-permission-dialog"
    attachment.lifetime = .keepAlways
    self.add(attachment)

    alert.buttons["Allow"].tap()
    return true
}

The attachment lands in the .xcresult bundle for the run and is browsable from the Xcode Report Navigator or extractable with xcrun xcresulttool get --path Build.xcresult --format json. Name attachments deterministically (not with a timestamp) if you want stable diffs across runs.

Resetting TCC State Between CI Jobs

Each run must start from an unprompted state. tccutil reset clears stored decisions for a service and bundle identifier so the next access shows a fresh dialog:

#!/bin/bash
set -euo pipefail
tccutil reset Camera     com.apple.Safari
tccutil reset Microphone com.apple.Safari

Run this as a pre-test step on the Mac agent. Without sudo it only affects the user-level TCC database, which is what Safari uses in a user-session CI agent. If you run several test classes per job, put the reset in setUp() on a shared base XCTestCase so it executes before every test method.

Choosing the Right Figment Plan for This Job

Requirement$29 virtual Intel macOSM-series Mac from $95/mo
macOS session with TCC subsystemYesYes
Compatible with Intel Xcode (Xcode 26 or earlier)YesYes
Xcode 27+ / arm64-only runtimesNo (Intel)Yes
Camera hardware exposed to macOSNoConfirm in quote
Audio input hardware exposed to macOSNoConfirm in quote
Suitable for the full video+audio dialog testNoYes, with hardware confirmed

Apple documents that Xcode 27 requires Apple silicon and that Intel hosts should stay on Xcode 26 or earlier (developer.apple.com — Porting your macOS apps to Apple silicon). The $29 VM is useful for building out the XCUITest harness, Info.plist entitlement work, and tccutil CI plumbing on a compatible Intel toolchain; the end-to-end camera+microphone stream test belongs on an M-series Mac where hardware devices are present.

For reference against the entry M-series price: CloudMac lists a dedicated Mac mini M4 (16 GB / 256 GB) at $99.99/month and a $999/year option on its homepage and pricing page, roughly $83.25/month effective. Figment's $95 entry is a lower monthly starting point for an M-series Mac; it is not a verified M4/16 GB/256 GB configuration, so confirm chip, RAM, disk and availability in the quote before comparing like-for-like. Managed Build Mac at $249/mo adds one M-series Mac, CI setup, backups, monitoring and 30 minutes of monthly support when you want the dialog-capture pipeline wired for you instead of built in-house.

Request a configuration at /mac-hosting or begin at /mac-hosting/start.

Related guides:


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms