Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef1d1f247d | |||
| c68ca87234 | |||
| 506e15684b | |||
| 4bbdee96ac | |||
| 80d7555b60 |
+5
-1
@@ -9,6 +9,10 @@ let package = Package(
|
||||
.macOS("14.2")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "audiotee")
|
||||
.executableTarget(
|
||||
name: "audiotee",
|
||||
swiftSettings: [
|
||||
.define("ENABLE_TCC_SPI")
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
@@ -243,13 +243,35 @@ Info, error, and debug messages (useful for monitoring):
|
||||
|
||||
## Permissions
|
||||
|
||||
There is no provision in the code to pre-emptively check for the required `NSAudioCaptureUsageDescription` permission,
|
||||
so you'll be prompted the first time AudioTee tries to record anything. If you want to check and/or request permissions ahead of time, check out [AudioCap's clever TCC probing approach](https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift).
|
||||
AudioTee requires system audio recording permissions to function. You can handle these permissions in two ways:
|
||||
|
||||
### Lazy permissions (default approach)
|
||||
|
||||
Simply run `./audiotee` and you'll be prompted for permissions the first time AudioTee tries to record audio from the tap. Note that some terminal emulators (at least `iTerm`) will **not** prompt at all, nor will the process fail: instead, AudioTee will happily run but will record a stream of empty data. The built in macOS terminal **does** prompt for permissions and blocks until granted.
|
||||
|
||||
### Explicit permissions management
|
||||
|
||||
Use the `--permissions` flag to check or request permissions ahead of time:
|
||||
|
||||
```bash
|
||||
# Check current permission status
|
||||
./audiotee --permissions
|
||||
|
||||
# Request permissions with user prompt
|
||||
./audiotee --permissions --request
|
||||
```
|
||||
|
||||
Note that the same caveat as above exists here regarding terminal emulators. If you know why, or how to fix it, please help out.
|
||||
|
||||
**Exit codes** indicate permission status, making this approach ideal for scripting:
|
||||
- `0`: Permissions granted
|
||||
- `1`: Permission status unknown
|
||||
- `2`: Permissions denied
|
||||
|
||||
## References
|
||||
|
||||
- [Apple Core Audio Taps Documentation](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps)
|
||||
- [AudioCap Implementation](https://github.com/insidegui/AudioCap)
|
||||
- [AudioCap Implementation](https://github.com/insidegui/AudioCap) - in particular, their awesome TCC probing approach to check for the audio capture permissions, which AudioTee lifts almost in its entirety. Thank you.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -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 {
|
||||
let permissionsHandler = PermissionsHandler(shouldRequest: requestPermissions)
|
||||
permissionsHandler.handle() // This will exit with appropriate code
|
||||
}
|
||||
|
||||
// Continue with normal audio tapping functionality
|
||||
setupSignalHandlers()
|
||||
|
||||
Logger.info("Starting AudioTee...")
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import CoreFoundation
|
||||
import Foundation
|
||||
|
||||
/// Handles audio recording permissions for the CLI, including checking status and requesting permissions.
|
||||
/// Uses exit codes to communicate permission status:
|
||||
/// - 0: granted (authorized)
|
||||
/// - 1: unknown
|
||||
/// - 2: denied
|
||||
struct PermissionsHandler {
|
||||
private let shouldRequest: Bool
|
||||
|
||||
init(shouldRequest: Bool) {
|
||||
self.shouldRequest = shouldRequest
|
||||
}
|
||||
|
||||
/// Handles the permissions workflow and exits with appropriate exit code
|
||||
func handle() -> Never {
|
||||
let permissionHandler = AudioRecordingPermission()
|
||||
|
||||
if shouldRequest {
|
||||
print("Requesting audio recording permissions...")
|
||||
permissionHandler.request()
|
||||
|
||||
// Wait for the permission request to complete
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get final status and exit with appropriate code
|
||||
let status = permissionHandler.status
|
||||
print("Audio recording permission status: \(status.rawValue)")
|
||||
|
||||
switch status {
|
||||
case .authorized:
|
||||
exit(0) // granted
|
||||
case .unknown:
|
||||
exit(1) // unknown
|
||||
case .denied:
|
||||
exit(2) // denied
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
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
|
||||
}()
|
||||
|
||||
/// `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 {
|
||||
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
|
||||
}()
|
||||
#endif // ENABLE_TCC_SPI
|
||||
}
|
||||
Reference in New Issue
Block a user