diff --git a/Package.swift b/Package.swift index 99e9b48..ebd9d23 100644 --- a/Package.swift +++ b/Package.swift @@ -9,6 +9,11 @@ let package = Package( .macOS("14.2") ], targets: [ - .executableTarget(name: "audiotee") + .executableTarget( + name: "audiotee", + swiftSettings: [ + .define("ENABLE_TCC_SPI") + ] + ) ] ) diff --git a/Sources/Permissions/AudioRecordingPermission.swift b/Sources/Permissions/AudioRecordingPermission.swift new file mode 100644 index 0000000..450e7a1 --- /dev/null +++ b/Sources/Permissions/AudioRecordingPermission.swift @@ -0,0 +1,131 @@ +import OSLog +import Observation +import SwiftUI + +// Adapted with a huge debt of gratitude from https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift + +/// Uses TCC SPI in order to check/request system audio recording permission. +@Observable +final class AudioRecordingPermission { + // private let logger = Logger(subsystem: kAppSubsystem, category: String(describing: AudioRecordingPermission.self)) + + enum Status: String { + case unknown + case denied + case authorized + } + + private(set) var status: Status = .unknown + + init() { + #if ENABLE_TCC_SPI + NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + guard let self else { return } + self.updateStatus() + } + + updateStatus() + #else + status = .authorized + #endif // ENABLE_TCC_SPI + } + + func request() { + #if ENABLE_TCC_SPI + // logger.debug(#function) + + guard let request = Self.requestSPI else { + // logger.fault("Request SPI missing") + return + } + + request("kTCCServiceAudioCapture" as CFString, nil) { [weak self] granted in + guard let self else { return } + + // self.logger.info("Request finished with result: \(granted, privacy: .public)") + + DispatchQueue.main.async { + if granted { + self.status = .authorized + } else { + self.status = .denied + } + } + } + #endif // ENABLE_TCC_SPI + } + + private func updateStatus() { + #if ENABLE_TCC_SPI + // logger.debug(#function) + + guard let preflight = Self.preflightSPI else { + // logger.fault("Preflight SPI missing") + return + } + + let result = preflight("kTCCServiceAudioCapture" as CFString, nil) + + if result == 1 { + status = .denied + } else if result == 0 { + status = .authorized + } else { + status = .unknown + } + #endif // ENABLE_TCC_SPI + } + + #if ENABLE_TCC_SPI + private typealias PreflightFuncType = @convention(c) (CFString, CFDictionary?) -> Int + private typealias RequestFuncType = @convention(c) ( + CFString, CFDictionary?, @escaping (Bool) -> Void + ) -> Void + + /// `dlopen` handle to the TCC framework. + private static let apiHandle: UnsafeMutableRawPointer? = { + let tccPath = "/System/Library/PrivateFrameworks/TCC.framework/Versions/A/TCC" + + guard let handle = dlopen(tccPath, RTLD_NOW) else { + assertionFailure("dlopen failed") + return nil + } + + return handle + }() + + /// `dlsym` function handle for `TCCAccessPreflight`. + private static let preflightSPI: PreflightFuncType? = { + guard let apiHandle else { return nil } + + let fnName = "TCCAccessPreflight" + + guard let funcSym = dlsym(apiHandle, fnName) else { + assertionFailure("Couldn't find symbol") + return nil + } + + let fn = unsafeBitCast(funcSym, to: PreflightFuncType.self) + + return fn + }() + + /// `dlsym` function handle for `TCCAccessRequest`. + private static let requestSPI: RequestFuncType? = { + guard let apiHandle else { return nil } + + let fnName = "TCCAccessRequest" + + guard let funcSym = dlsym(apiHandle, fnName) else { + assertionFailure("Couldn't find symbol") + return nil + } + + let fn = unsafeBitCast(funcSym, to: RequestFuncType.self) + + return fn + }() + #endif // ENABLE_TCC_SPI +}