WIP attempt to request audio capture permissions
This commit is contained in:
+8
-2
@@ -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",
|
||||||
|
// ])
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user