
Run this test on a Figment M-series Mac from $95/month — the microphone TCC path and the audio-input entitlement are macOS features that need real macOS audio hardware. The $29 virtual Intel macOS plan runs macOS and is fine for compiling and verifying the Info.plist/entitlement declarations with Xcode 26 or earlier, but it has no audio device, so AVCaptureDevice.requestAccess(for: .audio) cannot exercise the full prompt and grant flow there. Exact chip, RAM and disk for the M-series plan are confirmed in the quote; physical plans collect requests until supply is confirmed. Start at Figment Mac hosting or request a configuration.
A macOS app that records audio needs two independent declarations before it touches the microphone: an NSMicrophoneUsageDescription string in Info.plist and a com.apple.security.device.audio-input entitlement in the signed entitlements file. If either is missing the hardened runtime denies the request before any TCC row is written and the user never sees a prompt. This guide gives you an XCTest helper that checks both, calls AVCaptureDevice.requestAccess(for:) to drive the real permission flow, reads the resulting TCC row, and exports the state as a JSON XCTAttachment that travels with every build artefact.
Two declarations govern microphone access on macOS. In Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>MyApp uses the microphone to record audio notes.</string>
In the app's entitlements file (for example MyApp.entitlements):
<key>com.apple.security.device.audio-input</key>
<true/>
The description text appears in the system permission dialog. The entitlement is a hardened-runtime gate: without it, macOS rejects the request at the policy layer, before the user ever sees a prompt, and no TCC row is created. Your test must check both.
AVCaptureDevice.requestAccess(for: .audio) is an async API. It presents the system prompt on the first call and reads the cached TCC answer afterwards. Call it from a test method with async/await:
// MicrophoneEntitlementTests.swift
import XCTest
import AVFoundation
struct EntitlementResult: Codable {
let bundleID: String
let status: String // "authorized" | "denied" | "restricted" | "notDetermined"
let tccRowFound: Bool
let timestamp: String
}
final class MicrophoneEntitlementTests: XCTestCase {
let bundleID = "com.example.MyApp"
func testMicrophoneEntitlementIsAuthorized() async throws {
// 1. Verify the Info.plist key is present in the host app bundle.
// Bundle.main points at the host app when the test scheme sets one.
let desc = Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription")
XCTAssertNotNil(desc,
"NSMicrophoneUsageDescription must be set in the host app's Info.plist")
// 2. Request access (presents the prompt on first run).
let granted = await AVCaptureDevice.requestAccess(for: .audio)
XCTAssertTrue(granted,
"Microphone access was not granted. " +
"Check the com.apple.security.device.audio-input entitlement.")
// 3. Read the cached authorization status.
let status = AVCaptureDevice.authorizationStatus(for: .audio)
let statusString: String
switch status {
case .authorized: statusString = "authorized"
case .denied: statusString = "denied"
case .restricted: statusString = "restricted"
case .notDetermined: statusString = "notDetermined"
@unknown default: statusString = "unknown"
}
let tccFound = queryTCCMicrophoneRow(for: bundleID)
// 4. Build and attach the JSON artefact.
let result = EntitlementResult(
bundleID: bundleID,
status: statusString,
tccRowFound: tccFound,
timestamp: ISO8601DateFormatter().string(from: Date())
)
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(result)
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
attachment.name = "microphone-entitlement-\(bundleID).json"
attachment.lifetime = .keepAlways
add(attachment)
// 5. Final assertion.
XCTAssertEqual(statusString, "authorized",
"Expected .authorized but got \(statusString). See attached JSON.")
}
}
lifetime = .keepAlways keeps the JSON in the .xcresult bundle even when the test passes, so it survives as a per-build compliance record.
macOS stores privacy decisions in an SQLite database at ~/Library/Application Support/com.apple.TCC/TCC.db (per-user). A small helper shells out to sqlite3:
import Foundation
/// Returns true if the user TCC.db has an authorized mic row for this bundle ID.
func queryTCCMicrophoneRow(for bundleID: String) -> Bool {
let tccPath = ("~/Library/Application Support/com.apple.TCC/TCC.db" as NSString)
.expandingTildeInPath
guard FileManager.default.fileExists(atPath: tccPath) else { return false }
let pipe = Pipe()
let proc = Process()
proc.launchPath = "/usr/bin/sqlite3"
proc.arguments = [
tccPath,
// Parameter-bind at the shell layer: the bundleID comes from a trusted constant.
"SELECT client FROM access WHERE service='kTCCServiceMicrophone'" +
" AND client='\(bundleID)' AND auth_value=2 LIMIT 1;"
]
proc.standardOutput = pipe
proc.standardError = FileHandle.nullDevice
proc.launch()
proc.waitUntilExit()
let output = String(
data: pipe.fileHandleForReading.readDataToEndOfFile(),
encoding: .utf8
)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return output == bundleID
}
Caveats worth stating in the test comments:
/Library/Application Support/com.apple.TCC/TCC.db is not readable without elevated privileges, and SIP protects it.auth_value integer codes are not a documented public API. On current macOS releases 2 corresponds to authorized; verify before relying on it long-term.tccutil reset to return to the uncached state.To force the prompt on the next run (or to simulate a fresh install), reset the mic grant for the bundle ID before the test:
# Quit the app under test first, otherwise macOS may re-write the cached value.
/usr/bin/tccutil reset Microphone com.example.MyApp
Wire this into an Xcode scheme pre-action (Edit Scheme → Test → Pre-actions) or as a CI step that runs before xcodebuild test. Do not script writes directly to the SQLite file.
The microphone TCC chain — com.apple.security.device.audio-input, NSMicrophoneUsageDescription, the TCC.db row — is a macOS subsystem and does not exist in the iOS Simulator. Run with -destination 'platform=macOS' against your macOS app target, not an iOS/Simulator destination.
The Figment $29 virtual Intel macOS plan runs macOS and can build and link a mic-capable macOS app with Xcode 26 or earlier (Xcode 27 requires Apple silicon per Apple's porting guidance). What it cannot do is emulate a real audio input device: without a mic, the .audio request has no hardware to authorize against. Use the M-series plan for the end-to-end entitlement and TCC grant assertions in this test; keep the $29 VM for build-time plist/entitlement checks and Intel-compatible compilation.
For this single-app test the right starting point is one M-series Mac at $95/month. Exact chip, RAM and disk are set in the quote; the $95 is an entry price, not a committed M4/16 GB spec. Entry-level comparisons on monthly list:
Those are lower entry-price M-series options on equivalent hardware for the single-mic-test job, and this page is not going to hide them. What a Figment Managed Build at $249/month adds over a bare M-series rental is the specific managed work in our offer: one M-series Mac, CI setup, backups, monitoring and 30 minutes of monthly support — worth it when you want the TCC-reset pre-action, the JSON attachment pipeline and the xcodebuild test runner wired into a working CI rather than left as a scheme change. The Managed Mac Team at $999/month gives two M-series Macs, two CI runners, team setup, backups, monitoring and one hour of monthly support for a small fleet.
When you know which fits, request a configuration and state the Xcode/macOS pair and whether you need the pre-action and JSON artefact wired up for you.
Sources checked 2026-11-10: