diff --git a/Package.swift b/Package.swift index ebd9d23..7d57ae3 100644 --- a/Package.swift +++ b/Package.swift @@ -13,7 +13,13 @@ let package = Package( name: "audiotee", swiftSettings: [ .define("ENABLE_TCC_SPI") - ] - ) + ]) + // linkerSettings: [ + // .unsafeFlags([ + // "-Xlinker", "-sectcreate", + // "-Xlinker", "__TEXT", + // "-Xlinker", "__info_plist", + // "-Xlinker", "Resources/Info.plist", + // ]) ] ) diff --git a/README.md b/README.md index ef465db..3cbe931 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,18 @@ swift build -c release ## Usage +### Permission management + +AudioTee requires system audio recording permissions to work. Use these commands to check and request permissions: + +```bash +# Check current audio recording permissions +./audiotee --permissions + +# Request audio recording permissions (will prompt user) +./audiotee --permissions --request +``` + ### Basic usage Replace the path below with `.build///audiotee`, e.g. `build/arm64-apple-macosx/release/audiotee` for a release build on Apple Silicon. diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..90ac6a7 --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,8 @@ + + + + + NSAudioCaptureUsageDescription + This application requires access to system audio to record and stream audio output to external services such as speech recognition APIs. + + diff --git a/Sources/CLI/AudioTee.swift b/Sources/CLI/AudioTee.swift index a4fc6f2..394cb3a 100644 --- a/Sources/CLI/AudioTee.swift +++ b/Sources/CLI/AudioTee.swift @@ -8,6 +8,8 @@ struct AudioTee { var mute: Bool = false var sampleRate: Double? var chunkDuration: Double = 0.2 + var permissionsMode: Bool = false + var requestPermissions: Bool = false init() {} @@ -18,6 +20,10 @@ struct AudioTee { discussion: """ AudioTee captures system audio using Core Audio taps and streams it as structured output. + Permission modes: + • --permissions: Check current audio recording permissions + • --permissions --request: Request audio recording permissions + Output formats: • json: Base64-encoded audio in JSON messages (safe for terminals) • binary: Raw binary audio with JSON metadata headers (efficient for pipes) @@ -29,6 +35,8 @@ struct AudioTee { • mute: How to handle processes being tapped Examples: + audiotee --permissions # Check audio recording permissions + audiotee --permissions --request # Request audio recording permissions audiotee # Auto format, tap all processes audiotee --format=json # Always use JSON format audiotee --format=binary # Always use binary format @@ -42,6 +50,8 @@ struct AudioTee { ) // Configure arguments + parser.addFlag(name: "permissions", help: "Check audio recording permissions") + parser.addFlag(name: "request", help: "Request permissions (use with --permissions)") parser.addOption(name: "format", shortName: "f", help: "Output format", defaultValue: "auto") parser.addArrayOption( name: "include-processes", @@ -62,6 +72,8 @@ struct AudioTee { var audioTee = AudioTee() // Extract values + audioTee.permissionsMode = parser.getFlag("permissions") + audioTee.requestPermissions = parser.getFlag("request") audioTee.format = try parser.getValue("format", as: OutputFormat.self) audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self) audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self) @@ -96,9 +108,21 @@ struct AudioTee { throw ArgumentParserError.validationFailed( "Cannot specify both --include-processes and --exclude-processes") } + + if requestPermissions && !permissionsMode { + throw ArgumentParserError.validationFailed( + "--request can only be used with --permissions") + } } func run() throws { + // Handle permissions mode + if permissionsMode { + try handlePermissions() + return + } + + // Continue with normal audio tapping functionality setupSignalHandlers() Logger.info("Starting AudioTee...") @@ -161,6 +185,44 @@ struct AudioTee { recorder.stopRecording() } + private func handlePermissions() throws { + let permissionHandler = AudioRecordingPermission() + + if requestPermissions { + // Request permissions + print("Requesting audio recording permissions...") + print("Initial status: \(permissionHandler.status.rawValue)") + + // Trigger the request + permissionHandler.request() + print("Request method called...") + + // Wait for status to change from unknown with some progress indication + var waitCount = 0 + while permissionHandler.status == .unknown { + // Run the main run loop to allow DispatchQueue.main.async to execute + let result = CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.1, true) + if result == CFRunLoopRunResult.stopped || result == CFRunLoopRunResult.finished { + break + } + + waitCount += 1 + if waitCount % 50 == 0 { // Every 5 seconds (50 * 0.1) + print( + "Still waiting for permission response... (status: \(permissionHandler.status.rawValue))" + ) + } + } + + print("Permission status: \(permissionHandler.status.rawValue)") + + } else { + // Just check current permissions + let status = permissionHandler.status + print("Current permission status: \(status.rawValue)") + } + } + private func setupSignalHandlers() { signal(SIGINT) { _ in Logger.info("Received SIGINT, initiating graceful shutdown...") diff --git a/Sources/Permissions/AudioRecordingPermission.swift b/Sources/Permissions/AudioRecordingPermission.swift index 450e7a1..e24241b 100644 --- a/Sources/Permissions/AudioRecordingPermission.swift +++ b/Sources/Permissions/AudioRecordingPermission.swift @@ -35,25 +35,34 @@ final class AudioRecordingPermission { func request() { #if ENABLE_TCC_SPI // logger.debug(#function) + print("DEBUG: TCC SPI request called") guard let request = Self.requestSPI else { // logger.fault("Request SPI missing") + print("DEBUG: Request SPI is nil - TCC framework loading failed") return } + print("DEBUG: Calling TCC request function...") request("kTCCServiceAudioCapture" as CFString, nil) { [weak self] granted in guard let self else { return } // self.logger.info("Request finished with result: \(granted, privacy: .public)") + print("DEBUG: TCC request completed with result: \(granted)") DispatchQueue.main.async { + print("DEBUG: Updating status on main queue...") if granted { self.status = .authorized + print("DEBUG: Status set to authorized") } else { self.status = .denied + print("DEBUG: Status set to denied") } } } + #else + print("DEBUG: ENABLE_TCC_SPI not defined") #endif // ENABLE_TCC_SPI } @@ -87,12 +96,15 @@ final class AudioRecordingPermission { /// `dlopen` handle to the TCC framework. private static let apiHandle: UnsafeMutableRawPointer? = { let tccPath = "/System/Library/PrivateFrameworks/TCC.framework/Versions/A/TCC" + print("DEBUG: Attempting to load TCC framework from: \(tccPath)") guard let handle = dlopen(tccPath, RTLD_NOW) else { + print("DEBUG: dlopen failed for TCC framework") assertionFailure("dlopen failed") return nil } + print("DEBUG: TCC framework loaded successfully") return handle }() @@ -114,15 +126,21 @@ final class AudioRecordingPermission { /// `dlsym` function handle for `TCCAccessRequest`. private static let requestSPI: RequestFuncType? = { - guard let apiHandle else { return nil } + guard let apiHandle else { + print("DEBUG: No API handle for TCCAccessRequest") + return nil + } let fnName = "TCCAccessRequest" + print("DEBUG: Looking for symbol: \(fnName)") guard let funcSym = dlsym(apiHandle, fnName) else { + print("DEBUG: Couldn't find symbol: \(fnName)") assertionFailure("Couldn't find symbol") return nil } + print("DEBUG: Found TCCAccessRequest symbol successfully") let fn = unsafeBitCast(funcSym, to: RequestFuncType.self) return fn