WIP attempt to request audio capture permissions

This commit is contained in:
Nick Payne
2025-07-04 10:53:42 +01:00
parent 4bbdee96ac
commit 506e15684b
5 changed files with 109 additions and 3 deletions
+8 -2
View File
@@ -13,7 +13,13 @@ let package = Package(
name: "audiotee", name: "audiotee",
swiftSettings: [ swiftSettings: [
.define("ENABLE_TCC_SPI") .define("ENABLE_TCC_SPI")
] ])
) // linkerSettings: [
// .unsafeFlags([
// "-Xlinker", "-sectcreate",
// "-Xlinker", "__TEXT",
// "-Xlinker", "__info_plist",
// "-Xlinker", "Resources/Info.plist",
// ])
] ]
) )
+12
View File
@@ -35,6 +35,18 @@ swift build -c release
## Usage ## 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 ### Basic usage
Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64-apple-macosx/release/audiotee` for a release build on Apple Silicon. Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64-apple-macosx/release/audiotee` for a release build on Apple Silicon.
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAudioCaptureUsageDescription</key>
<string>This application requires access to system audio to record and stream audio output to external services such as speech recognition APIs.</string>
</dict>
</plist>
+62
View File
@@ -8,6 +8,8 @@ struct AudioTee {
var mute: Bool = false var mute: Bool = false
var sampleRate: Double? var sampleRate: Double?
var chunkDuration: Double = 0.2 var chunkDuration: Double = 0.2
var permissionsMode: Bool = false
var requestPermissions: Bool = false
init() {} init() {}
@@ -18,6 +20,10 @@ struct AudioTee {
discussion: """ discussion: """
AudioTee captures system audio using Core Audio taps and streams it as structured output. 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: Output formats:
• json: Base64-encoded audio in JSON messages (safe for terminals) • json: Base64-encoded audio in JSON messages (safe for terminals)
• binary: Raw binary audio with JSON metadata headers (efficient for pipes) • binary: Raw binary audio with JSON metadata headers (efficient for pipes)
@@ -29,6 +35,8 @@ struct AudioTee {
• mute: How to handle processes being tapped • mute: How to handle processes being tapped
Examples: Examples:
audiotee --permissions # Check audio recording permissions
audiotee --permissions --request # Request audio recording permissions
audiotee # Auto format, tap all processes audiotee # Auto format, tap all processes
audiotee --format=json # Always use JSON format audiotee --format=json # Always use JSON format
audiotee --format=binary # Always use binary format audiotee --format=binary # Always use binary format
@@ -42,6 +50,8 @@ struct AudioTee {
) )
// Configure arguments // 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.addOption(name: "format", shortName: "f", help: "Output format", defaultValue: "auto")
parser.addArrayOption( parser.addArrayOption(
name: "include-processes", name: "include-processes",
@@ -62,6 +72,8 @@ struct AudioTee {
var audioTee = AudioTee() var audioTee = AudioTee()
// Extract values // Extract values
audioTee.permissionsMode = parser.getFlag("permissions")
audioTee.requestPermissions = parser.getFlag("request")
audioTee.format = try parser.getValue("format", as: OutputFormat.self) audioTee.format = try parser.getValue("format", as: OutputFormat.self)
audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self) audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self) audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self)
@@ -96,9 +108,21 @@ struct AudioTee {
throw ArgumentParserError.validationFailed( throw ArgumentParserError.validationFailed(
"Cannot specify both --include-processes and --exclude-processes") "Cannot specify both --include-processes and --exclude-processes")
} }
if requestPermissions && !permissionsMode {
throw ArgumentParserError.validationFailed(
"--request can only be used with --permissions")
}
} }
func run() throws { func run() throws {
// Handle permissions mode
if permissionsMode {
try handlePermissions()
return
}
// Continue with normal audio tapping functionality
setupSignalHandlers() setupSignalHandlers()
Logger.info("Starting AudioTee...") Logger.info("Starting AudioTee...")
@@ -161,6 +185,44 @@ struct AudioTee {
recorder.stopRecording() 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() { private func setupSignalHandlers() {
signal(SIGINT) { _ in signal(SIGINT) { _ in
Logger.info("Received SIGINT, initiating graceful shutdown...") Logger.info("Received SIGINT, initiating graceful shutdown...")
@@ -35,25 +35,34 @@ final class AudioRecordingPermission {
func request() { func request() {
#if ENABLE_TCC_SPI #if ENABLE_TCC_SPI
// logger.debug(#function) // logger.debug(#function)
print("DEBUG: TCC SPI request called")
guard let request = Self.requestSPI else { guard let request = Self.requestSPI else {
// logger.fault("Request SPI missing") // logger.fault("Request SPI missing")
print("DEBUG: Request SPI is nil - TCC framework loading failed")
return return
} }
print("DEBUG: Calling TCC request function...")
request("kTCCServiceAudioCapture" as CFString, nil) { [weak self] granted in request("kTCCServiceAudioCapture" as CFString, nil) { [weak self] granted in
guard let self else { return } guard let self else { return }
// self.logger.info("Request finished with result: \(granted, privacy: .public)") // self.logger.info("Request finished with result: \(granted, privacy: .public)")
print("DEBUG: TCC request completed with result: \(granted)")
DispatchQueue.main.async { DispatchQueue.main.async {
print("DEBUG: Updating status on main queue...")
if granted { if granted {
self.status = .authorized self.status = .authorized
print("DEBUG: Status set to authorized")
} else { } else {
self.status = .denied self.status = .denied
print("DEBUG: Status set to denied")
} }
} }
} }
#else
print("DEBUG: ENABLE_TCC_SPI not defined")
#endif // ENABLE_TCC_SPI #endif // ENABLE_TCC_SPI
} }
@@ -87,12 +96,15 @@ final class AudioRecordingPermission {
/// `dlopen` handle to the TCC framework. /// `dlopen` handle to the TCC framework.
private static let apiHandle: UnsafeMutableRawPointer? = { private static let apiHandle: UnsafeMutableRawPointer? = {
let tccPath = "/System/Library/PrivateFrameworks/TCC.framework/Versions/A/TCC" 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 { guard let handle = dlopen(tccPath, RTLD_NOW) else {
print("DEBUG: dlopen failed for TCC framework")
assertionFailure("dlopen failed") assertionFailure("dlopen failed")
return nil return nil
} }
print("DEBUG: TCC framework loaded successfully")
return handle return handle
}() }()
@@ -114,15 +126,21 @@ final class AudioRecordingPermission {
/// `dlsym` function handle for `TCCAccessRequest`. /// `dlsym` function handle for `TCCAccessRequest`.
private static let requestSPI: RequestFuncType? = { 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" let fnName = "TCCAccessRequest"
print("DEBUG: Looking for symbol: \(fnName)")
guard let funcSym = dlsym(apiHandle, fnName) else { guard let funcSym = dlsym(apiHandle, fnName) else {
print("DEBUG: Couldn't find symbol: \(fnName)")
assertionFailure("Couldn't find symbol") assertionFailure("Couldn't find symbol")
return nil return nil
} }
print("DEBUG: Found TCCAccessRequest symbol successfully")
let fn = unsafeBitCast(funcSym, to: RequestFuncType.self) let fn = unsafeBitCast(funcSym, to: RequestFuncType.self)
return fn return fn