From 0d072f1cec3f49c388d28bbee3f7bd393746abed Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:03:19 -1000 Subject: [PATCH 01/24] feat(helper): add AppFadersHelper executable target with placeholder entry point --- Package.swift | 5 +++++ Sources/AppFadersHelper/main.swift | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 Sources/AppFadersHelper/main.swift diff --git a/Package.swift b/Package.swift index 8ce019f..0681b5b 100644 --- a/Package.swift +++ b/Package.swift @@ -9,6 +9,7 @@ let package = Package( ], products: [ .executable(name: "AppFaders", targets: ["AppFaders"]), + .executable(name: "AppFadersHelper", targets: ["AppFadersHelper"]), .library(name: "AppFadersDriver", type: .dynamic, targets: ["AppFadersDriver"]), .plugin(name: "BundleAssembler", targets: ["BundleAssembler"]) ], @@ -22,6 +23,10 @@ let package = Package( .product(name: "CAAudioHardware", package: "CAAudioHardware") ] ), + .executableTarget( + name: "AppFadersHelper", + dependencies: [] + ), .target( name: "AppFadersDriverBridge", dependencies: [], diff --git a/Sources/AppFadersHelper/main.swift b/Sources/AppFadersHelper/main.swift new file mode 100644 index 0000000..4646b50 --- /dev/null +++ b/Sources/AppFadersHelper/main.swift @@ -0,0 +1,5 @@ +import Foundation + +// Placeholder - XPC listener implementation in task 6 +print("AppFadersHelper starting...") +RunLoop.main.run() From d983c3c1848152d6f37f7d8de3a8a3216cc71f75 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:03:26 -1000 Subject: [PATCH 02/24] feat(helper): add LaunchAgent plist for XPC service registration --- .../com.fbreidenbach.appfaders.helper.plist | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 Resources/com.fbreidenbach.appfaders.helper.plist diff --git a/Resources/com.fbreidenbach.appfaders.helper.plist b/Resources/com.fbreidenbach.appfaders.helper.plist new file mode 100644 index 0000000..d1442b0 --- /dev/null +++ b/Resources/com.fbreidenbach.appfaders.helper.plist @@ -0,0 +1,17 @@ + + + + + Label + com.fbreidenbach.appfaders.helper + MachServices + + com.fbreidenbach.appfaders.helper + + + ProgramArguments + + /Library/Application Support/AppFaders/AppFadersHelper + + + From af1c5888548694d13575aff49386f33f47b76cb2 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:07:58 -1000 Subject: [PATCH 03/24] feat(helper): add XPC protocol definitions for host and driver clients --- Sources/AppFadersHelper/Protocols.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 Sources/AppFadersHelper/Protocols.swift diff --git a/Sources/AppFadersHelper/Protocols.swift b/Sources/AppFadersHelper/Protocols.swift new file mode 100644 index 0000000..d616684 --- /dev/null +++ b/Sources/AppFadersHelper/Protocols.swift @@ -0,0 +1,17 @@ +// Protocols.swift +// XPC protocol definitions for host and driver clients + +import Foundation + +/// Protocol for host app connections (read-write) +@objc protocol AppFadersHostProtocol { + func setVolume(bundleID: String, volume: Float, reply: @escaping (NSError?) -> Void) + func getVolume(bundleID: String, reply: @escaping (Float, NSError?) -> Void) + func getAllVolumes(reply: @escaping ([String: Float], NSError?) -> Void) +} + +/// Protocol for driver connections (read-only) +@objc protocol AppFadersDriverProtocol { + func getVolume(bundleID: String, reply: @escaping (Float, NSError?) -> Void) + func getAllVolumes(reply: @escaping ([String: Float], NSError?) -> Void) +} From c435bcdf51b4272c4dca26ee2717c8d79d2b3ae6 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:09:34 -1000 Subject: [PATCH 04/24] feat(helper): add VolumeStore as central volume storage --- Sources/AppFadersHelper/VolumeStore.swift | 64 +++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 Sources/AppFadersHelper/VolumeStore.swift diff --git a/Sources/AppFadersHelper/VolumeStore.swift b/Sources/AppFadersHelper/VolumeStore.swift new file mode 100644 index 0000000..5ae0411 --- /dev/null +++ b/Sources/AppFadersHelper/VolumeStore.swift @@ -0,0 +1,64 @@ +// VolumeStore.swift +// Thread-safe storage for per-application volume settings +// +// Central source of truth for volume levels, accessed by host and driver via XPC. + +import Foundation +import os.log + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders.helper", category: "VolumeStore") + +/// Thread-safe storage for application-specific volumes +final class VolumeStore: @unchecked Sendable { + static let shared = VolumeStore() + + private let lock = NSLock() + private var volumes: [String: Float] = [:] + + private init() { + os_log(.info, log: log, "VolumeStore initialized") + } + + /// Set volume for a specific application + /// - Parameters: + /// - bundleID: application bundle identifier + /// - volume: volume level (0.0 to 1.0) + func setVolume(for bundleID: String, volume: Float) { + let clampedVolume = max(0.0, min(1.0, volume)) + + lock.lock() + volumes[bundleID] = clampedVolume + lock.unlock() + + os_log(.info, log: log, "Volume set for %{public}@: %.2f", bundleID, clampedVolume) + } + + /// Get volume for a specific application + /// - Parameter bundleID: application bundle identifier + /// - Returns: volume level (defaults to 1.0 if unknown) + func getVolume(for bundleID: String) -> Float { + lock.lock() + let volume = volumes[bundleID] ?? 1.0 + lock.unlock() + return volume + } + + /// Get all stored volumes + /// - Returns: dictionary of bundleID to volume + func getAllVolumes() -> [String: Float] { + lock.lock() + let copy = volumes + lock.unlock() + return copy + } + + /// Remove volume setting for an application + /// - Parameter bundleID: application bundle identifier + func removeVolume(for bundleID: String) { + lock.lock() + volumes.removeValue(forKey: bundleID) + lock.unlock() + + os_log(.info, log: log, "Volume removed for %{public}@", bundleID) + } +} From 7dabd9ca41371effb243e6905d47dca2adf5b52e Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:11:44 -1000 Subject: [PATCH 05/24] feat(helper): add HelperService implementing XPC protocols with validation --- Sources/AppFadersHelper/HelperService.swift | 89 +++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 Sources/AppFadersHelper/HelperService.swift diff --git a/Sources/AppFadersHelper/HelperService.swift b/Sources/AppFadersHelper/HelperService.swift new file mode 100644 index 0000000..699d0ee --- /dev/null +++ b/Sources/AppFadersHelper/HelperService.swift @@ -0,0 +1,89 @@ +// HelperService.swift +// XPC service implementation handling requests from host and driver + +import Foundation +import os.log + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders.helper", category: "HelperService") + +/// Error domain for helper service errors +private let errorDomain = "com.fbreidenbach.appfaders.helper" + +/// XPC service implementation for both host and driver protocols +final class HelperService: NSObject, AppFadersHostProtocol, AppFadersDriverProtocol, +@unchecked Sendable { + static let shared = HelperService() + + override private init() { + super.init() + os_log(.info, log: log, "HelperService initialized") + } + + // MARK: - Validation + + private func validateBundleID(_ bundleID: String) -> NSError? { + guard bundleID.count <= 255 else { + os_log(.error, log: log, "Bundle ID too long: %d chars", bundleID.count) + return NSError( + domain: errorDomain, + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Bundle ID exceeds 255 characters"] + ) + } + guard !bundleID.isEmpty else { + os_log(.error, log: log, "Bundle ID is empty") + return NSError( + domain: errorDomain, + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Bundle ID cannot be empty"] + ) + } + return nil + } + + private func validateVolume(_ volume: Float) -> NSError? { + guard volume >= 0.0, volume <= 1.0 else { + os_log(.error, log: log, "Volume out of range: %.2f", volume) + return NSError( + domain: errorDomain, + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Volume must be between 0.0 and 1.0"] + ) + } + return nil + } + + // MARK: - AppFadersHostProtocol + + func setVolume(bundleID: String, volume: Float, reply: @escaping (NSError?) -> Void) { + if let error = validateBundleID(bundleID) { + reply(error) + return + } + if let error = validateVolume(volume) { + reply(error) + return + } + + VolumeStore.shared.setVolume(for: bundleID, volume: volume) + os_log(.info, log: log, "setVolume: %{public}@ = %.2f", bundleID, volume) + reply(nil) + } + + func getVolume(bundleID: String, reply: @escaping (Float, NSError?) -> Void) { + if let error = validateBundleID(bundleID) { + reply(0, error) + return + } + + let volume = VolumeStore.shared.getVolume(for: bundleID) + os_log(.debug, log: log, "getVolume: %{public}@ = %.2f", bundleID, volume) + reply(volume, nil) + } + + func getAllVolumes(reply: @escaping ([String: Float], NSError?) -> Void) { + let volumes = VolumeStore.shared.getAllVolumes() + os_log(.debug, log: log, "getAllVolumes: %d entries", volumes.count) + reply(volumes, nil) + } +} From 504ad5344465fbb662c55fd57c1c3066f4c395da Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:13:29 -1000 Subject: [PATCH 06/24] feat(helper): implement XPC listener with connection handling --- Sources/AppFadersHelper/main.swift | 46 ++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/Sources/AppFadersHelper/main.swift b/Sources/AppFadersHelper/main.swift index 4646b50..c86cd42 100644 --- a/Sources/AppFadersHelper/main.swift +++ b/Sources/AppFadersHelper/main.swift @@ -1,5 +1,47 @@ +// main.swift +// Entry point for AppFadersHelper XPC service + import Foundation +import os.log + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders.helper", category: "Main") +private let machServiceName = "com.fbreidenbach.appfaders.helper" + +/// Delegate for accepting XPC connections +final class ListenerDelegate: NSObject, NSXPCListenerDelegate, @unchecked Sendable { + func listener( + _ listener: NSXPCListener, + shouldAcceptNewConnection newConnection: NSXPCConnection + ) -> Bool { + os_log(.info, log: log, "Accepting new XPC connection") + + // Configure the connection's exported interface + // Both host and driver use AppFadersHostProtocol for now + // (driver only calls the read methods) + newConnection.exportedInterface = NSXPCInterface(with: AppFadersHostProtocol.self) + newConnection.exportedObject = HelperService.shared + + // Handle connection lifecycle + newConnection.invalidationHandler = { + os_log(.info, log: log, "XPC connection invalidated") + } + newConnection.interruptionHandler = { + os_log(.info, log: log, "XPC connection interrupted") + } + + newConnection.resume() + return true + } +} + +// MARK: - Entry Point + +os_log(.info, log: log, "AppFadersHelper starting with service: %{public}@", machServiceName) + +let delegate = ListenerDelegate() +let listener = NSXPCListener(machServiceName: machServiceName) +listener.delegate = delegate +listener.resume() -// Placeholder - XPC listener implementation in task 6 -print("AppFadersHelper starting...") +os_log(.info, log: log, "XPC listener started, entering run loop") RunLoop.main.run() From e6ad29aec6f8b75d0153f2b5e68a228ce0b16577 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:25:16 -1000 Subject: [PATCH 07/24] refactor(helper): rename Protocols.swift to XPCProtocols.swift Also adds VS Code launch configurations for AppFadersHelper debug/release. --- .vscode/launch.json | 20 ++++++++++++++++++++ Sources/AppFadersHelper/Protocols.swift | 17 ----------------- Sources/AppFadersHelper/XPCProtocols.swift | 17 +++++++++++++++++ 3 files changed, 37 insertions(+), 17 deletions(-) delete mode 100644 Sources/AppFadersHelper/Protocols.swift create mode 100644 Sources/AppFadersHelper/XPCProtocols.swift diff --git a/.vscode/launch.json b/.vscode/launch.json index 0f104ab..1e3c569 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -19,6 +19,26 @@ "target": "AppFaders", "configuration": "release", "preLaunchTask": "swift: Build Release AppFaders" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:AppFaders}", + "name": "Debug AppFadersHelper", + "target": "AppFadersHelper", + "configuration": "debug", + "preLaunchTask": "swift: Build Debug AppFadersHelper" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:AppFaders}", + "name": "Release AppFadersHelper", + "target": "AppFadersHelper", + "configuration": "release", + "preLaunchTask": "swift: Build Release AppFadersHelper" } ] } \ No newline at end of file diff --git a/Sources/AppFadersHelper/Protocols.swift b/Sources/AppFadersHelper/Protocols.swift deleted file mode 100644 index d616684..0000000 --- a/Sources/AppFadersHelper/Protocols.swift +++ /dev/null @@ -1,17 +0,0 @@ -// Protocols.swift -// XPC protocol definitions for host and driver clients - -import Foundation - -/// Protocol for host app connections (read-write) -@objc protocol AppFadersHostProtocol { - func setVolume(bundleID: String, volume: Float, reply: @escaping (NSError?) -> Void) - func getVolume(bundleID: String, reply: @escaping (Float, NSError?) -> Void) - func getAllVolumes(reply: @escaping ([String: Float], NSError?) -> Void) -} - -/// Protocol for driver connections (read-only) -@objc protocol AppFadersDriverProtocol { - func getVolume(bundleID: String, reply: @escaping (Float, NSError?) -> Void) - func getAllVolumes(reply: @escaping ([String: Float], NSError?) -> Void) -} diff --git a/Sources/AppFadersHelper/XPCProtocols.swift b/Sources/AppFadersHelper/XPCProtocols.swift new file mode 100644 index 0000000..1f7c24d --- /dev/null +++ b/Sources/AppFadersHelper/XPCProtocols.swift @@ -0,0 +1,17 @@ +// XPCProtocols.swift +// XPC protocol definitions for host and driver clients + +import Foundation + +/// Protocol for host app connections (read-write) +@objc protocol AppFadersHostProtocol { + func setVolume(bundleID: String, volume: Float, reply: @escaping (NSError?) -> Void) + func getVolume(bundleID: String, reply: @escaping (Float, NSError?) -> Void) + func getAllVolumes(reply: @escaping ([String: Float], NSError?) -> Void) +} + +/// Protocol for driver connections (read-only) +@objc protocol AppFadersDriverProtocol { + func getVolume(bundleID: String, reply: @escaping (Float, NSError?) -> Void) + func getAllVolumes(reply: @escaping ([String: Float], NSError?) -> Void) +} From 1336c78ac79ffa4c93ee90017d161272eb00d75e Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:29:28 -1000 Subject: [PATCH 08/24] feat(driver): add AudioServerPlugIn_MachServices for XPC helper access --- Resources/Info.plist | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Resources/Info.plist b/Resources/Info.plist index c45a472..b4ed4f9 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -48,6 +48,12 @@ com.fbreidenbach.appfaders.virtualdevice + + AudioServerPlugIn_MachServices + + com.fbreidenbach.appfaders.helper + + NSHumanReadableCopyright Copyright 2026 AppFaders From 149e7cd09b2bd1b9351401e448a52ca079b3517c Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:31:50 -1000 Subject: [PATCH 09/24] feat(driver): add HelperBridge XPC client with real-time safe volume cache --- Sources/AppFadersDriver/HelperBridge.swift | 139 +++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 Sources/AppFadersDriver/HelperBridge.swift diff --git a/Sources/AppFadersDriver/HelperBridge.swift b/Sources/AppFadersDriver/HelperBridge.swift new file mode 100644 index 0000000..365096a --- /dev/null +++ b/Sources/AppFadersDriver/HelperBridge.swift @@ -0,0 +1,139 @@ +// HelperBridge.swift +// XPC client for driver to communicate with helper service + +import Foundation +import os.log + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders.driver", category: "HelperBridge") +private let machServiceName = "com.fbreidenbach.appfaders.helper" + +/// Protocol for driver connections (read-only) - must match helper's definition +@objc protocol AppFadersDriverProtocol { + func getVolume(bundleID: String, reply: @escaping (Float, NSError?) -> Void) + func getAllVolumes(reply: @escaping ([String: Float], NSError?) -> Void) +} + +/// XPC client for driver-side communication with helper service +/// Uses local cache for real-time audio safety - getVolume never blocks +final class HelperBridge: @unchecked Sendable { + static let shared = HelperBridge() + + private let lock = NSLock() + private var connection: NSXPCConnection? + private var volumeCache: [String: Float] = [:] + private var isConnected = false + + private init() { + os_log(.info, log: log, "HelperBridge initialized") + } + + // MARK: - Connection Management + + /// Establish connection to helper service + func connect() { + lock.lock() + defer { lock.unlock() } + + guard connection == nil else { + os_log(.debug, log: log, "Already connected") + return + } + + os_log(.info, log: log, "Connecting to helper: %{public}@", machServiceName) + + let conn = NSXPCConnection(machServiceName: machServiceName) + conn.remoteObjectInterface = NSXPCInterface(with: AppFadersDriverProtocol.self) + + conn.invalidationHandler = { [weak self] in + os_log(.info, log: log, "XPC connection invalidated") + self?.handleDisconnect() + } + + conn.interruptionHandler = { [weak self] in + os_log(.info, log: log, "XPC connection interrupted, will reconnect") + self?.handleDisconnect() + self?.scheduleReconnect() + } + + conn.resume() + connection = conn + isConnected = true + + os_log(.info, log: log, "XPC connection established") + + // Initial cache refresh (async, don't block) + refreshCacheAsync() + } + + /// Disconnect from helper service + func disconnect() { + lock.lock() + defer { lock.unlock() } + + connection?.invalidate() + connection = nil + isConnected = false + os_log(.info, log: log, "Disconnected from helper") + } + + private func handleDisconnect() { + lock.lock() + defer { lock.unlock() } + connection = nil + isConnected = false + } + + private func scheduleReconnect() { + DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.connect() + } + } + + // MARK: - Volume Access + + /// Get volume for bundle ID - synchronous, returns from cache (never blocks) + /// - Parameter bundleID: application bundle identifier + /// - Returns: volume level (defaults to 1.0 if not in cache) + func getVolume(for bundleID: String) -> Float { + lock.lock() + let volume = volumeCache[bundleID] ?? 1.0 + lock.unlock() + return volume + } + + /// Refresh volume cache from helper (async) + func refreshCache() { + refreshCacheAsync() + } + + private func refreshCacheAsync() { + lock.lock() + let conn = connection + lock.unlock() + + guard let conn = conn else { + os_log(.debug, log: log, "Cannot refresh cache - not connected") + return + } + + guard let proxy = conn.remoteObjectProxyWithErrorHandler({ error in + os_log(.error, log: log, "XPC error during cache refresh: %{public}@", error.localizedDescription) + }) as? AppFadersDriverProtocol else { + os_log(.error, log: log, "Failed to get remote object proxy") + return + } + + proxy.getAllVolumes { [weak self] volumes, error in + if let error = error { + os_log(.error, log: log, "getAllVolumes failed: %{public}@", error.localizedDescription) + return + } + + self?.lock.lock() + self?.volumeCache = volumes + self?.lock.unlock() + + os_log(.debug, log: log, "Cache refreshed: %d entries", volumes.count) + } + } +} From 77e2db386c4a2cb54149485d779c5464679ad233 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:33:24 -1000 Subject: [PATCH 10/24] feat(driver): connect to helper XPC service on initialization --- Sources/AppFadersDriver/DriverEntry.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/AppFadersDriver/DriverEntry.swift b/Sources/AppFadersDriver/DriverEntry.swift index 89f9d5a..0912f48 100644 --- a/Sources/AppFadersDriver/DriverEntry.swift +++ b/Sources/AppFadersDriver/DriverEntry.swift @@ -54,6 +54,9 @@ final class DriverEntry: @unchecked Sendable { self.host = host + // Connect to helper XPC service for volume data + HelperBridge.shared.connect() + // VirtualDevice is a singleton, accessed via VirtualDevice.shared os_log(.debug, log: log, "initialize complete - device ID: %u", deviceObjectID) return noErr From 0e5304e5c6565527770cd1e4d2cb3708e6110926 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:38:58 -1000 Subject: [PATCH 11/24] refactor(driver): remove custom AudioObject IPC code from VirtualDevice --- Sources/AppFadersDriver/VirtualDevice.swift | 83 ++------------------- 1 file changed, 7 insertions(+), 76 deletions(-) diff --git a/Sources/AppFadersDriver/VirtualDevice.swift b/Sources/AppFadersDriver/VirtualDevice.swift index 1d9b6af..40b6382 100644 --- a/Sources/AppFadersDriver/VirtualDevice.swift +++ b/Sources/AppFadersDriver/VirtualDevice.swift @@ -126,13 +126,6 @@ final class VirtualDevice: @unchecked Sendable { return true } - // custom IPC property for setting app volumes - if objectID == ObjectID.device, - address.mSelector == AppFadersProperty.setVolume - { - return true - } - // delegate stream properties to VirtualStream if objectID == ObjectID.outputStream { return VirtualStream.shared.isPropertySettable(address: address) @@ -351,9 +344,7 @@ final class VirtualDevice: @unchecked Sendable { kAudioDevicePropertyZeroTimeStampPeriod, kAudioDevicePropertyClockDomain, kAudioDevicePropertyIsHidden, - kAudioDevicePropertyPreferredChannelsForStereo, - AppFadersProperty.setVolume, - AppFadersProperty.getVolume: + kAudioDevicePropertyPreferredChannelsForStereo: true default: false @@ -402,21 +393,13 @@ final class VirtualDevice: @unchecked Sendable { address.mScope == kAudioObjectPropertyScopeGlobal) ? UInt32(MemoryLayout.size) : 0 - case kAudioObjectPropertyCustomPropertyInfoList: - UInt32(MemoryLayout.size * 2) - - case kAudioDevicePropertyControlList: - 0 // empty list + case kAudioObjectPropertyCustomPropertyInfoList, + kAudioDevicePropertyControlList: + 0 // empty lists - custom IPC removed, using XPC case kAudioDevicePropertyNominalSampleRate: UInt32(MemoryLayout.size) - case AppFadersProperty.setVolume: - UInt32(VolumeCommand.totalSize) - - case AppFadersProperty.getVolume: - UInt32(MemoryLayout.size) - case kAudioDevicePropertyAvailableNominalSampleRates: // 3 sample rates: 44100, 48000, 96000 UInt32(MemoryLayout.size * 3) @@ -494,33 +477,9 @@ final class VirtualDevice: @unchecked Sendable { } return (Data(), 0) // no input streams - case kAudioObjectPropertyCustomPropertyInfoList: - var info = [ - AudioServerPlugInCustomPropertyInfo( - mSelector: AppFadersProperty.setVolume, - mPropertyDataType: 0, - mQualifierDataType: 0 - ), - AudioServerPlugInCustomPropertyInfo( - mSelector: AppFadersProperty.getVolume, - mPropertyDataType: 0, - mQualifierDataType: 0 - ) - ] - let size = MemoryLayout.size * info.count - return (Data(bytes: &info, count: size), UInt32(size)) - - case AppFadersProperty.getVolume: - guard qualifierSize > 0, let qualifierData else { - return nil - } - let bundleID = String(cString: qualifierData.assumingMemoryBound(to: UInt8.self)) - var volume = Float32(VolumeStore.shared.getVolume(for: bundleID)) - return (Data(bytes: &volume, count: MemoryLayout.size), - UInt32(MemoryLayout.size)) - - case kAudioDevicePropertyControlList: - // empty list + case kAudioObjectPropertyCustomPropertyInfoList, + kAudioDevicePropertyControlList: + // empty lists - custom IPC removed, using XPC return (Data(), 0) case kAudioDevicePropertyNominalSampleRate: @@ -630,34 +589,6 @@ final class VirtualDevice: @unchecked Sendable { return noErr } - // set application volume - if objectID == ObjectID.device, - address.mSelector == AppFadersProperty.setVolume - { - guard size >= UInt32(VolumeCommand.totalSize) else { - return kAudioHardwareBadPropertySizeError - } - - // parse VolumeCommand from data - // wire format: [bundleIDLength: UInt8] [bundleIDBytes: 255 bytes] [volume: Float32] - let bundleIDLength = data.load(as: UInt8.self) - guard bundleIDLength <= UInt8(VolumeCommand.maxBundleIDLength) else { - return kAudioHardwareIllegalOperationError - } - - let bundleIDStart = data.advanced(by: 1) - let bundleIDData = Data(bytes: bundleIDStart, count: Int(bundleIDLength)) - guard let bundleID = String(data: bundleIDData, encoding: .utf8) else { - return kAudioHardwareIllegalOperationError - } - - let volumeStart = data.advanced(by: 1 + VolumeCommand.maxBundleIDLength) - let volume = volumeStart.load(as: Float32.self) - - VolumeStore.shared.setVolume(for: bundleID, volume: volume) - return noErr - } - // delegate stream properties if objectID == ObjectID.outputStream { return VirtualStream.shared.setPropertyData(address: address, data: data, size: size) From 94d0537df3895d9a13ada91ec08c92ac1bb814d9 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:39:33 -1000 Subject: [PATCH 12/24] refactor(driver): remove dead IPC types from AudioTypes --- Sources/AppFadersDriver/AudioTypes.swift | 46 ------------------------ 1 file changed, 46 deletions(-) diff --git a/Sources/AppFadersDriver/AudioTypes.swift b/Sources/AppFadersDriver/AudioTypes.swift index 75576bf..4096c45 100644 --- a/Sources/AppFadersDriver/AudioTypes.swift +++ b/Sources/AppFadersDriver/AudioTypes.swift @@ -115,49 +115,3 @@ public extension AudioDeviceConfiguration { } } -// MARK: - Custom Properties - -/// custom property selectors for AppFaders IPC -public enum AppFadersProperty { - /// set volume for an application: 'afvc' - public static let setVolume = AudioObjectPropertySelector(0x6166_7663) - /// get volume for an application: 'afvq' - public static let getVolume = AudioObjectPropertySelector(0x6166_7671) -} - -// MARK: - CoreAudio HAL Missing Types - -/// information about a custom property -/// matches AudioServerPlugInCustomPropertyInfo in AudioServerPlugIn.h -public struct AudioServerPlugInCustomPropertyInfo: Sendable { - public var mSelector: AudioObjectPropertySelector - public var mPropertyDataType: UInt32 - public var mQualifierDataType: UInt32 - - public init( - mSelector: AudioObjectPropertySelector, - mPropertyDataType: UInt32, - mQualifierDataType: UInt32 - ) { - self.mSelector = mSelector - self.mPropertyDataType = mPropertyDataType - self.mQualifierDataType = mQualifierDataType - } -} - -// MARK: - IPC Models - -/// IPC command to set volume for an application -/// matches the wire format: [length: UInt8] [bundleID: 255 bytes] [volume: Float32] -public struct VolumeCommand: Sendable { - public static let maxBundleIDLength = 255 - public static let totalSize = 1 + maxBundleIDLength + 4 // 260 bytes - - public let bundleID: String - public let volume: Float - - public init(bundleID: String, volume: Float) { - self.bundleID = bundleID - self.volume = volume - } -} From ab0e70e27d057a94bc8f8b86bf3c346f20171d79 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:40:01 -1000 Subject: [PATCH 13/24] refactor(driver): delete VolumeStore, now in helper --- Sources/AppFadersDriver/VolumeStore.swift | 57 ----------------------- 1 file changed, 57 deletions(-) delete mode 100644 Sources/AppFadersDriver/VolumeStore.swift diff --git a/Sources/AppFadersDriver/VolumeStore.swift b/Sources/AppFadersDriver/VolumeStore.swift deleted file mode 100644 index 8151e3e..0000000 --- a/Sources/AppFadersDriver/VolumeStore.swift +++ /dev/null @@ -1,57 +0,0 @@ -// VolumeStore.swift -// Thread-safe storage for per-application volume settings -// -// handles storage and retrieval of volume levels for different bundle IDs. -// used by the virtual device to apply gain in real-time. - -import Foundation -import os.log - -private let log = OSLog(subsystem: "com.fbreidenbach.appfaders.driver", category: "VolumeStore") - -/// thread-safe storage for application-specific volumes -final class VolumeStore: @unchecked Sendable { - static let shared = VolumeStore() - - private let lock = NSLock() - private var volumes: [String: Float] = [:] - - private init() { - os_log(.info, log: log, "VolumeStore initialized") - } - - /// set volume for a specific application - /// - Parameters: - /// - bundleID: application bundle identifier - /// - volume: volume level (0.0 to 1.0) - func setVolume(for bundleID: String, volume: Float) { - // clamp volume to valid range - let clampedVolume = max(0.0, min(1.0, volume)) - - lock.lock() - volumes[bundleID] = clampedVolume - lock.unlock() - - os_log(.info, log: log, "volume updated for %{public}@: %.2f", bundleID, clampedVolume) - } - - /// get volume for a specific application - /// - Parameter bundleID: application bundle identifier - /// - Returns: volume level (defaults to 1.0 if unknown) - func getVolume(for bundleID: String) -> Float { - lock.lock() - let volume = volumes[bundleID] ?? 1.0 - lock.unlock() - return volume - } - - /// remove volume setting for an application - /// - Parameter bundleID: application bundle identifier - func removeVolume(for bundleID: String) { - lock.lock() - volumes.removeValue(forKey: bundleID) - lock.unlock() - - os_log(.info, log: log, "volume removed for %{public}@", bundleID) - } -} From 7214f20e8e6bc3d5915ae3127fd595bee73df7a3 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:49:49 -1000 Subject: [PATCH 14/24] feat(host): add XPC error cases to DriverError Add four new error cases for XPC communication failures: - helperNotRunning: helper service not available - connectionFailed: XPC connection establishment failed - connectionInterrupted: XPC connection was interrupted - remoteError: helper returned an error --- Sources/AppFaders/DriverError.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Sources/AppFaders/DriverError.swift b/Sources/AppFaders/DriverError.swift index 1ded569..287b974 100644 --- a/Sources/AppFaders/DriverError.swift +++ b/Sources/AppFaders/DriverError.swift @@ -18,6 +18,17 @@ enum DriverError: Error, LocalizedError, Equatable { /// the bundle identifier exceeds the maximum allowed length case bundleIDTooLong(Int) + // MARK: - XPC Errors + + /// the helper service is not running and could not be started + case helperNotRunning + /// failed to establish XPC connection to helper + case connectionFailed(String) + /// XPC connection was interrupted + case connectionInterrupted + /// helper returned an error + case remoteError(String) + var errorDescription: String? { switch self { case .deviceNotFound: @@ -30,6 +41,14 @@ enum DriverError: Error, LocalizedError, Equatable { "Invalid volume level: \(volume). Must be between 0.0 and 1.0." case let .bundleIDTooLong(length): "Bundle identifier is too long (\(length) bytes). Max is 255." + case .helperNotRunning: + "AppFaders helper service is not running." + case let .connectionFailed(reason): + "Failed to connect to helper service: \(reason)" + case .connectionInterrupted: + "Connection to helper service was interrupted." + case let .remoteError(message): + "Helper service error: \(message)" } } } From 02084b1d3675a78e37bce00105cbd0ca9780948e Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:51:08 -1000 Subject: [PATCH 15/24] feat(host): add XPC protocol definition for helper communication Duplicate AppFadersHostProtocol in host target for NSXPCInterface. Must match helper's definition exactly for XPC to work. --- Sources/AppFaders/HelperProtocol.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 Sources/AppFaders/HelperProtocol.swift diff --git a/Sources/AppFaders/HelperProtocol.swift b/Sources/AppFaders/HelperProtocol.swift new file mode 100644 index 0000000..f017a95 --- /dev/null +++ b/Sources/AppFaders/HelperProtocol.swift @@ -0,0 +1,13 @@ +// HelperProtocol.swift +// XPC protocol definition for host to helper communication +// +// duplicated from AppFadersHelper/XPCProtocols.swift - must match exactly + +import Foundation + +/// Protocol for host app connections (read-write) +@objc protocol AppFadersHostProtocol { + func setVolume(bundleID: String, volume: Float, reply: @escaping (NSError?) -> Void) + func getVolume(bundleID: String, reply: @escaping (Float, NSError?) -> Void) + func getAllVolumes(reply: @escaping ([String: Float], NSError?) -> Void) +} From b21886b476da45458982794aa8f25fbb62d97370 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:54:57 -1000 Subject: [PATCH 16/24] refactor(host): rewrite DriverBridge to use XPC Replace AudioObject property IPC with XPC communication to helper service. - connect() now async, connects to mach service (no deviceID param) - setAppVolume/getAppVolume now async throws - Uses withCheckedThrowingContinuation for XPC reply handling - Handles connection invalidation and interruption - Removes all CoreAudio/AudioObject code --- Sources/AppFaders/DriverBridge.swift | 232 ++++++++++++--------------- 1 file changed, 99 insertions(+), 133 deletions(-) diff --git a/Sources/AppFaders/DriverBridge.swift b/Sources/AppFaders/DriverBridge.swift index a3b770c..1c12cce 100644 --- a/Sources/AppFaders/DriverBridge.swift +++ b/Sources/AppFaders/DriverBridge.swift @@ -1,180 +1,146 @@ // DriverBridge.swift -// Low-level IPC bridge for communicating with the AppFaders virtual driver +// XPC client for communicating with the AppFaders helper service // -// Handles serialization of volume commands and direct AudioObject property access. -// Manages the connection state to the specific AudioDeviceID of the virtual driver. +// Handles async volume commands via XPC. Replaces the defunct AudioObject property approach. -import CoreAudio import Foundation import os.log private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "DriverBridge") +private let machServiceName = "com.fbreidenbach.appfaders.helper" -// Re-defining constants here since we don't link against the driver target directly -// These must match AppFadersDriver/AudioTypes.swift -private enum AppFadersProperty { - // 'afvc' - Set Volume Command - static let setVolume = AudioObjectPropertySelector(0x6166_7663) - // 'afvq' - Get Volume Query - static let getVolume = AudioObjectPropertySelector(0x6166_7671) -} - -/// handles low-level communication with the AppFaders virtual driver +/// handles communication with the AppFaders helper service via XPC final class DriverBridge: @unchecked Sendable { - private var deviceID: AudioDeviceID? private let lock = NSLock() + private var connection: NSXPCConnection? - /// returns true if currently connected to a valid device ID + /// returns true if currently connected to the helper service var isConnected: Bool { - lock.withLock { deviceID != nil } + lock.withLock { connection != nil } } - /// connects to the specified audio device - /// - Parameter deviceID: The AudioDeviceID of the AppFaders Virtual Device - func connect(deviceID: AudioDeviceID) throws { - lock.withLock { - self.deviceID = deviceID + // MARK: - Connection Management + + /// establishes XPC connection to the helper service + func connect() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + lock.lock() + + if connection != nil { + lock.unlock() + continuation.resume() + return + } + + os_log(.info, log: log, "Connecting to helper: %{public}@", machServiceName) + + let conn = NSXPCConnection(machServiceName: machServiceName) + conn.remoteObjectInterface = NSXPCInterface(with: AppFadersHostProtocol.self) + + conn.invalidationHandler = { [weak self] in + os_log(.info, log: log, "XPC connection invalidated") + self?.handleDisconnect() + } + + conn.interruptionHandler = { [weak self] in + os_log(.info, log: log, "XPC connection interrupted") + self?.handleDisconnect() + } + + conn.resume() + connection = conn + lock.unlock() + + os_log(.info, log: log, "XPC connection established") + continuation.resume() } - os_log(.info, log: log, "DriverBridge connected to deviceID: %u", deviceID) } - /// clears the stored device ID + /// disconnects from the helper service func disconnect() { - lock.withLock { - deviceID = nil - } - os_log(.info, log: log, "DriverBridge disconnected") + lock.lock() + defer { lock.unlock() } + + connection?.invalidate() + connection = nil + os_log(.info, log: log, "Disconnected from helper") } - /// sends a volume command to the driver for a specific application + private func handleDisconnect() { + lock.lock() + defer { lock.unlock() } + connection = nil + } + + // MARK: - Volume Commands + + /// sends a volume command to the helper for a specific application /// - Parameters: /// - bundleID: The target application's bundle identifier /// - volume: The desired volume level (0.0 - 1.0) - /// - Throws: DriverError if validation fails or the property write fails - func setAppVolume(bundleID: String, volume: Float) throws { - let currentDeviceID = lock.withLock { deviceID } - guard let deviceID = currentDeviceID else { - throw DriverError.deviceNotFound - } - + /// - Throws: DriverError if validation fails or XPC call fails + func setAppVolume(bundleID: String, volume: Float) async throws { // Validation guard volume >= 0.0, volume <= 1.0 else { throw DriverError.invalidVolumeRange(volume) } - guard let bundleIDData = bundleID.data(using: .utf8) else { - throw DriverError.propertyWriteFailed(kAudioHardwareUnspecifiedError) - } - - guard bundleIDData.count <= 255 else { - throw DriverError.bundleIDTooLong(bundleIDData.count) + guard bundleID.utf8.count <= 255 else { + throw DriverError.bundleIDTooLong(bundleID.utf8.count) } - // Manual Serialization of VolumeCommand - // Format: [length: UInt8] [bundleID: 255 bytes] [volume: Float32] - // Total: 260 bytes - var data = Data() + let proxy = try getProxy() - // 1. Length (UInt8) - data.append(UInt8(bundleIDData.count)) - - // 2. Bundle ID (255 bytes, padded) - data.append(bundleIDData) - let padding = 255 - bundleIDData.count - if padding > 0 { - data.append(Data(repeating: 0, count: padding)) - } - - // 3. Volume (Float32) - var vol = volume - withUnsafeBytes(of: &vol) { buffer in - data.append(contentsOf: buffer) - } - - // Prepare Property Address - var address = AudioObjectPropertyAddress( - mSelector: AppFadersProperty.setVolume, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain - ) - - // Write Property - let status = data.withUnsafeBytes { buffer in - AudioObjectSetPropertyData( - deviceID, - &address, - 0, // inQualifierDataSize - nil, // inQualifierData - UInt32(buffer.count), - buffer.baseAddress! - ) - } - - guard status == noErr else { - os_log( - .error, - log: log, - "Failed to set volume for %{public}@: %d", - bundleID, - status - ) - throw DriverError.propertyWriteFailed(status) + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + proxy.setVolume(bundleID: bundleID, volume: volume) { error in + if let error = error { + continuation.resume(throwing: DriverError.remoteError(error.localizedDescription)) + } else { + continuation.resume() + } + } } } - /// retrieves the current volume for a specific application from the driver + /// retrieves the current volume for a specific application from the helper /// - Parameter bundleID: The target application's bundle identifier /// - Returns: The current volume level (0.0 - 1.0) - /// - Throws: DriverError if the property read fails - func getAppVolume(bundleID: String) throws -> Float { - let currentDeviceID = lock.withLock { deviceID } - guard let deviceID = currentDeviceID else { - throw DriverError.deviceNotFound + /// - Throws: DriverError if validation fails or XPC call fails + func getAppVolume(bundleID: String) async throws -> Float { + guard bundleID.utf8.count <= 255 else { + throw DriverError.bundleIDTooLong(bundleID.utf8.count) } - guard var bundleIDData = bundleID.data(using: .utf8) else { - throw DriverError.propertyReadFailed(kAudioHardwareUnspecifiedError) - } + let proxy = try getProxy() - guard bundleIDData.count <= 255 else { - throw DriverError.bundleIDTooLong(bundleIDData.count) + return try await withCheckedThrowingContinuation { continuation in + proxy.getVolume(bundleID: bundleID) { volume, error in + if let error = error { + continuation.resume(throwing: DriverError.remoteError(error.localizedDescription)) + } else { + continuation.resume(returning: volume) + } + } } + } + + // MARK: - Private Helpers + + private func getProxy() throws -> AppFadersHostProtocol { + lock.lock() + let conn = connection + lock.unlock() - // Append null terminator for C-string compatibility in the driver - bundleIDData.append(0) - - var address = AudioObjectPropertyAddress( - mSelector: AppFadersProperty.getVolume, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain - ) - - var volume: Float32 = 0.0 - var dataSize = UInt32(MemoryLayout.size) - - // Use bundleID (null-terminated) as qualifier data - let status = bundleIDData.withUnsafeBytes { qualifierBuffer in - AudioObjectGetPropertyData( - deviceID, - &address, - UInt32(bundleIDData.count), - qualifierBuffer.baseAddress, - &dataSize, - &volume - ) + guard let conn = conn else { + throw DriverError.helperNotRunning } - guard status == noErr else { - os_log( - .error, - log: log, - "Failed to get volume for %{public}@: %d", - bundleID, - status - ) - throw DriverError.propertyReadFailed(status) + guard let proxy = conn.remoteObjectProxyWithErrorHandler({ error in + os_log(.error, log: log, "XPC proxy error: %{public}@", error.localizedDescription) + }) as? AppFadersHostProtocol else { + throw DriverError.connectionFailed("Failed to get remote object proxy") } - return volume + return proxy } } From 31e238daa5a89b9a597b7bb0a94191866a7ada72 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:55:07 -1000 Subject: [PATCH 17/24] refactor(host): update AudioOrchestrator for async DriverBridge Adapt orchestrator to async XPC-based DriverBridge: - getVolume now async throws - setVolume now async with internal error handling - restoreVolumes and handleAppEvent now async - connect() no longer takes deviceID parameter --- Sources/AppFaders/AudioOrchestrator.swift | 99 +++++++++++------------ 1 file changed, 49 insertions(+), 50 deletions(-) diff --git a/Sources/AppFaders/AudioOrchestrator.swift b/Sources/AppFaders/AudioOrchestrator.swift index eadb6a1..1c7324c 100644 --- a/Sources/AppFaders/AudioOrchestrator.swift +++ b/Sources/AppFaders/AudioOrchestrator.swift @@ -2,7 +2,7 @@ // Central coordinator for the AppFaders host application // // Manages state for the UI, coordinates device discovery, app monitoring, -// and IPC communication with the virtual driver. +// and IPC communication with the helper service. import CAAudioHardware import Foundation @@ -20,7 +20,7 @@ final class AudioOrchestrator { /// Currently running applications that are tracked private(set) var trackedApps: [TrackedApp] = [] - /// Whether the AppFaders Virtual Driver is currently connected + /// Whether the helper service is currently connected private(set) var isDriverConnected: Bool = false /// Current volume levels for applications (Bundle ID -> Volume 0.0-1.0) @@ -45,7 +45,7 @@ final class AudioOrchestrator { /// Starts the orchestration process /// - Consumes updates from DeviceManager and AppAudioMonitor - /// - Maintains connection to the virtual driver + /// - Maintains connection to the helper service /// - Note: This method blocks until the task is cancelled. func start() async { os_log(.info, log: log, "AudioOrchestrator starting...") @@ -62,15 +62,15 @@ final class AudioOrchestrator { trackApp(app) } - // 4. Initial check for driver - await checkDriverConnection() + // 4. Initial connection to helper + await connectToHelper() // 5. Start consuming streams await withTaskGroup(of: Void.self) { group in - // Device List Updates + // Device List Updates (still useful for knowing if driver is available) group.addTask { [weak self] in for await _ in deviceUpdates { - await self?.checkDriverConnection() + await self?.checkDriverAvailability() } } @@ -93,34 +93,33 @@ final class AudioOrchestrator { // MARK: - Actions - /// Gets the current volume for an application from the driver + /// Gets the current volume for an application from the helper /// - Parameter bundleID: The bundle identifier of the application /// - Returns: The volume level (0.0 - 1.0) - /// - Throws: Error if the driver communication fails - func getVolume(for bundleID: String) throws -> Float { + /// - Throws: Error if the helper communication fails + func getVolume(for bundleID: String) async throws -> Float { guard driverBridge.isConnected else { - throw DriverError.deviceNotFound + throw DriverError.helperNotRunning } - return try driverBridge.getAppVolume(bundleID: bundleID) + return try await driverBridge.getAppVolume(bundleID: bundleID) } /// Sets the volume for a specific application /// - Parameters: /// - bundleID: The bundle identifier of the application /// - volume: The volume level (0.0 - 1.0) - /// - Throws: Error if the driver communication fails - func setVolume(for bundleID: String, volume: Float) throws { + func setVolume(for bundleID: String, volume: Float) async { let oldVolume = appVolumes[bundleID] // 1. Update local state immediately for UI responsiveness appVolumes[bundleID] = volume - // 2. Send command to driver + // 2. Send command to helper do { if driverBridge.isConnected { - try driverBridge.setAppVolume(bundleID: bundleID, volume: volume) + try await driverBridge.setAppVolume(bundleID: bundleID, volume: volume) } else { - os_log(.debug, log: log, "Driver not connected, volume cached for %{public}@", bundleID) + os_log(.debug, log: log, "Helper not connected, volume cached for %{public}@", bundleID) } } catch { // Revert on error to maintain consistency @@ -133,74 +132,74 @@ final class AudioOrchestrator { os_log( .error, log: log, - "Failed to set volume for %{public}@: %@", + "Failed to set volume for %{public}@: %{public}@", bundleID, - error as CVarArg + error.localizedDescription ) - throw error } } // MARK: - Private Helpers - /// Checks if the virtual driver is present and updates connection state - private func checkDriverConnection() async { - if let device = deviceManager.appFadersDevice { - if !driverBridge.isConnected { - do { - try driverBridge.connect(deviceID: device.objectID) - isDriverConnected = true - os_log(.info, log: log, "Connected to AppFaders Virtual Driver") + /// Connects to the helper service + private func connectToHelper() async { + do { + try await driverBridge.connect() + isDriverConnected = true + os_log(.info, log: log, "Connected to AppFaders Helper Service") - // Restore volumes to driver - restoreVolumes() - } catch { - os_log(.error, log: log, "Failed to connect to driver: %@", error as CVarArg) - isDriverConnected = false - } - } - } else { - if driverBridge.isConnected { - driverBridge.disconnect() - isDriverConnected = false - os_log(.info, log: log, "Disconnected from AppFaders Virtual Driver") - } + // Restore volumes to helper + await restoreVolumes() + } catch { + os_log(.error, log: log, "Failed to connect to helper: %{public}@", error.localizedDescription) + isDriverConnected = false } } - private func restoreVolumes() { + /// Checks if driver is available (informational - connection is to helper) + private func checkDriverAvailability() { + let driverAvailable = deviceManager.appFadersDevice != nil + os_log( + .debug, + log: log, + "Driver availability check: %{public}@", + driverAvailable ? "available" : "not found" + ) + } + + private func restoreVolumes() async { for (bundleID, volume) in appVolumes { do { - try driverBridge.setAppVolume(bundleID: bundleID, volume: volume) + try await driverBridge.setAppVolume(bundleID: bundleID, volume: volume) } catch { os_log( .error, log: log, - "Failed to restore volume for %{public}@: %@", + "Failed to restore volume for %{public}@: %{public}@", bundleID, - error as CVarArg + error.localizedDescription ) } } } /// Handles app launch and termination events - private func handleAppEvent(_ event: AppLifecycleEvent) { + private func handleAppEvent(_ event: AppLifecycleEvent) async { switch event { case let .didLaunch(app): trackApp(app) - // Sync volume to driver if it exists + // Sync volume to helper if connected if let vol = appVolumes[app.bundleID], driverBridge.isConnected { do { - try driverBridge.setAppVolume(bundleID: app.bundleID, volume: vol) + try await driverBridge.setAppVolume(bundleID: app.bundleID, volume: vol) } catch { os_log( .error, log: log, - "Failed to sync volume for launched app %{public}@: %@", + "Failed to sync volume for launched app %{public}@: %{public}@", app.bundleID, - error as CVarArg + error.localizedDescription ) } } From 6f010cf85488862a6ed42bb3042ece3b0b986486 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:23:01 -1000 Subject: [PATCH 18/24] docs: adding some newer ones --- docs/README.md | 3 ++ docs/caaudiohardware-evaluation.md | 68 ++++++++++++++++++++++++++ docs/integration-test-report-v0.2.0.md | 48 ++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/caaudiohardware-evaluation.md create mode 100644 docs/integration-test-report-v0.2.0.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..18dddfe --- /dev/null +++ b/docs/README.md @@ -0,0 +1,3 @@ +# Docs + +In here is a bunch of random stuff that I want to keep around for the MVP phase. diff --git a/docs/caaudiohardware-evaluation.md b/docs/caaudiohardware-evaluation.md new file mode 100644 index 0000000..d5cc14a --- /dev/null +++ b/docs/caaudiohardware-evaluation.md @@ -0,0 +1,68 @@ +# CAAudioHardware Evaluation + +**Date:** January 20, 2026 +**Subject:** Evaluation of `sbooth/CAAudioHardware` as a replacement for `SimplyCoreAudio`. + +## Summary + +`CAAudioHardware` is a robust, actively maintained Swift wrapper for the Core Audio HAL. It provides all the necessary primitives to replace `SimplyCoreAudio` in the `host-audio-orchestrator` spec, including device enumeration, notifications, and custom property I/O. + +## Feature Mapping + +| Requirement | SimplyCoreAudio | CAAudioHardware Implementation | +| :--- | :--- | :--- | +| **Enumerate Devices** | `simplyCA.allOutputDevices` | `AudioDevice.devices` (returns all, requires filtering) | +| **Find by UID** | `device.uid == "..."` | `AudioSystem.instance.deviceID(forUID:)` | +| **Notifications** | `NotificationCenter.default` | `AudioSystem.instance.whenSelectorChanges(.devices)` | +| **Custom Properties** | (Manual `AudioObjectGetPropertyData`) | `device.getProperty(PropertyAddress(...))` | +| **Concurrency** | Non-Sendable (Legacy) | `@unchecked Sendable` classes, callback-based events | + +## Migration Strategy + +### 1. Device Discovery + +**SimplyCoreAudio:** + +```swift +simplyCA.allOutputDevices.first { $0.uid == "..." } +``` + +**CAAudioHardware:** + +```swift +if let id = try AudioSystem.instance.deviceID(forUID: "...") { + let device = AudioDevice(id) +} +``` + +### 2. Notifications (AsyncStream Adapter) + +`CAAudioHardware` uses closure callbacks. We can adapt this to `AsyncStream` easily: + +```swift +var deviceListUpdates: AsyncStream { + AsyncStream { continuation in + let observer = try? AudioSystem.instance.whenSelectorChanges(.devices, on: .main) { _ in + continuation.yield() + } + // Cleanup requires removing the listener + continuation.onTermination = { _ in + // AudioSystem.instance.whenSelectorChanges(.devices, perform: nil) // removes listener + } + } +} +``` + +### 3. Custom Properties (Volume Control) + +`CAAudioHardware` shines here by providing type-safe property wrappers. + +```swift +let selector = AudioObjectSelector(0x61667663) // 'afvc' +let address = PropertyAddress(PropertySelector(selector.rawValue), scope: .global) +try device.setProperty(address, to: volumeData) +``` + +## Recommendation + +**Adopt CAAudioHardware.** It offers a cleaner, lower-level abstraction that fits our "driver-first" mental model better than `SimplyCoreAudio`, while still hiding the C pointer complexity. It is actively maintained and supports the exact feature set we need. diff --git a/docs/integration-test-report-v0.2.0.md b/docs/integration-test-report-v0.2.0.md new file mode 100644 index 0000000..8a9de8d --- /dev/null +++ b/docs/integration-test-report-v0.2.0.md @@ -0,0 +1,48 @@ +# Integration Test Report: IPC Volume Control (v0.2.0) + +**Date:** January 25, 2026 +**Tester:** Gemini CLI Agent +**Scope:** Verification of host-to-driver IPC for volume control (Task 17) + +## Environment + +- **OS:** macOS 15.2 (Darwin 24.2.0) +- **Project Version:** Phase 2 (Host Audio Orchestrator) +- **Driver Version:** 0.2.0 (Prototype) + +## Test Procedure + +### 1. Driver Installation + +- **Command:** `Scripts/install-driver.sh` +- **Result:** [PASS] Driver built and registered successfully. Virtual Device visible in `system_profiler`. + +### 2. Host Application Launch + +- **Command:** `swift run AppFaders` +- **Result:** [PASS] Host app started and correctly resolved `AudioDeviceID` for the virtual device. + +### 3. Volume Command Round-Trip + +- **Action:** Programmatically set volume for `com.apple.Safari` to `0.5` using `AudioObjectSetPropertyData` with custom selector `'afvc'`. +- **Result:** [FAIL] + - Status: `kAudioHardwareUnknownPropertyError` (2003332927) + - Logs: `coreaudiod` did NOT forward the request to the driver's handlers. + +## Findings & Critical Issues + +1. **Custom Property Blockage:** Even with corrected `AudioServerPlugInCustomPropertyInfo` struct alignment (including `mName` and `mCategory`), `coreaudiod` continues to reject custom property writes with `kAudioHardwareUnknownPropertyError`. +2. **System Instability:** Attempting to register custom properties via the `kAudioObjectPropertyCustomPropertyInfoList` selector resulted in significant system-wide performance degradation and `coreaudiod` instability. +3. **Sandbox/Security Limits:** The failure likely stems from modern macOS security policies that restrict HAL drivers (running via `com.apple.audio.Core-Audio-Driver-Service.helper`) from exposing custom IPC interfaces through the standard `AudioObject` property system to external processes. + +## Conclusion & Path Forward + +The "AudioObject Properties" IPC mechanism is **not viable** for custom app-volume control in modern macOS HAL drivers. To salvage the project, we must pivot: + +1. **Mandatory XPC:** Implement an XPC listener inside the HAL driver and have the Host App connect directly via XPC. This bypasses `coreaudiod` property filtering. +2. **Mach Ports:** Alternatively, use Mach message passing if XPC setup within the HAL bundle proves too restrictive. +3. **Abandon Custom Properties:** All code related to `'afvc'` and `'afvq'` should be removed from the driver's property handlers to maintain system stability. + +## Status + +**Overall Result:** FAILED (IPC Connectivity - Architectural Pivot Required) From ebb0c307d0501eda1d11d92098b8a17f5e82f1b2 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:41:48 -1000 Subject: [PATCH 19/24] feat(scripts): update install-driver.sh to install helper Add helper installation before driver: - Build helper with swift build - Create /Library/Application Support/AppFaders/ - Copy helper binary with proper permissions - Install LaunchAgent plist to /Library/LaunchAgents/ - Load LaunchAgent with launchctl --- Scripts/install-driver.sh | 58 ++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/Scripts/install-driver.sh b/Scripts/install-driver.sh index 7c78bc5..14f078d 100755 --- a/Scripts/install-driver.sh +++ b/Scripts/install-driver.sh @@ -11,6 +11,10 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" HAL_PLUGINS_DIR="/Library/Audio/Plug-Ins/HAL" DRIVER_NAME="AppFadersDriver.driver" +HELPER_NAME="AppFadersHelper" +HELPER_SUPPORT_DIR="/Library/Application Support/AppFaders" +LAUNCHAGENT_PLIST="com.fbreidenbach.appfaders.helper.plist" +LAUNCHAGENTS_DIR="/Library/LaunchAgents" # colors for output RED='\033[0;31m' @@ -27,37 +31,71 @@ error() { cd "$PROJECT_DIR" -# step 1: build +# step 1: build driver and helper info "Building project..." swift build || error "Build failed" -# step 2: locate built dylib +# step 2: install helper (before driver so XPC service is available) +info "Installing helper service..." + +HELPER_BINARY=".build/debug/$HELPER_NAME" +if [[ ! -f $HELPER_BINARY ]]; then + error "Helper binary not found at $HELPER_BINARY" +fi + +# create support directory +sudo mkdir -p "$HELPER_SUPPORT_DIR" + +# copy helper binary +sudo cp "$HELPER_BINARY" "$HELPER_SUPPORT_DIR/" +sudo chmod 755 "$HELPER_SUPPORT_DIR/$HELPER_NAME" +sudo chown root:wheel "$HELPER_SUPPORT_DIR/$HELPER_NAME" +info "Helper binary installed to $HELPER_SUPPORT_DIR" + +# install LaunchAgent plist +PLIST_SOURCE="$PROJECT_DIR/Resources/$LAUNCHAGENT_PLIST" +if [[ ! -f $PLIST_SOURCE ]]; then + error "LaunchAgent plist not found at $PLIST_SOURCE" +fi + +# unload existing if present (ignore errors) +sudo launchctl unload "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" 2>/dev/null || true + +sudo cp "$PLIST_SOURCE" "$LAUNCHAGENTS_DIR/" +sudo chown root:wheel "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" +sudo chmod 644 "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" + +# load the LaunchAgent +sudo launchctl load "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" || warn "Failed to load LaunchAgent (may already be loaded)" +info "Helper LaunchAgent installed and loaded" + +# step 3: locate built driver dylib DYLIB_PATH=".build/debug/libAppFadersDriver.dylib" if [[ ! -f $DYLIB_PATH ]]; then error "Built dylib not found at $DYLIB_PATH" fi info "Found dylib: $DYLIB_PATH" -# step 3: locate bundle structure created by plugin +# step 4: locate bundle structure created by plugin BUNDLE_PATH=$(find .build -path "*BundleAssembler/$DRIVER_NAME" -type d 2>/dev/null | head -1) if [[ -z $BUNDLE_PATH || ! -d $BUNDLE_PATH ]]; then error "Bundle structure not found. Make sure BundleAssembler plugin ran." fi info "Found bundle: $BUNDLE_PATH" -# step 4: copy dylib to bundle Contents/MacOS/ +# step 5: copy dylib to bundle Contents/MacOS/ MACOS_DIR="$BUNDLE_PATH/Contents/MacOS" BINARY_DEST="$MACOS_DIR/AppFadersDriver" info "Copying dylib to bundle..." cp "$DYLIB_PATH" "$BINARY_DEST" chmod 755 "$BINARY_DEST" -# step 5: fix install name (dylib references @rpath/libAppFadersDriver.dylib which won't resolve) +# step 6: fix install name (dylib references @rpath/libAppFadersDriver.dylib which won't resolve) info "Fixing install name..." install_name_tool -id "@loader_path/AppFadersDriver" "$BINARY_DEST" install_name_tool -change "@rpath/libAppFadersDriver.dylib" "@loader_path/AppFadersDriver" "$BINARY_DEST" -# step 6: code sign the binary +# step 7: code sign the binary info "Code signing binary..." # remove marker file that interferes with signing rm -f "$BUNDLE_PATH/.bundle-ready" @@ -70,7 +108,7 @@ info "Using identity hash: $SIGNING_HASH" codesign --force --options runtime --timestamp --sign "$SIGNING_HASH" "$BINARY_DEST" || error "Code signing failed" info "Binary signed" -# step 7: verify bundle structure +# step 8: verify bundle structure if [[ ! -f "$BUNDLE_PATH/Contents/Info.plist" ]]; then error "Info.plist missing from bundle" fi @@ -79,7 +117,7 @@ if [[ ! -f $BINARY_DEST ]]; then fi info "Bundle structure verified" -# step 8: install to HAL directory (requires sudo) +# step 9: install to HAL directory (requires sudo) INSTALL_PATH="$HAL_PLUGINS_DIR/$DRIVER_NAME" info "Installing to $INSTALL_PATH (requires sudo)..." @@ -93,12 +131,12 @@ sudo chown -R root:wheel "$INSTALL_PATH" sudo chmod -R 755 "$INSTALL_PATH" info "Driver installed" -# step 9: restart coreaudiod +# step 10: restart coreaudiod info "Restarting coreaudiod (requires sudo)..." sudo killall coreaudiod 2>/dev/null || true sleep 2 -# step 10: verify device appears +# step 11: verify device appears info "Verifying device registration..." sleep 1 From c139810c16901db137b59f1ea33dfc7bd0daa952 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:43:25 -1000 Subject: [PATCH 20/24] feat(scripts): update uninstall-driver.sh to remove helper Add helper cleanup after driver removal: - Unload LaunchAgent with launchctl - Remove LaunchAgent plist from /Library/LaunchAgents/ - Remove helper binary from support directory - Remove support directory if empty - Handle missing files gracefully --- Scripts/uninstall-driver.sh | 61 +++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/Scripts/uninstall-driver.sh b/Scripts/uninstall-driver.sh index 6ccfa9f..780b163 100755 --- a/Scripts/uninstall-driver.sh +++ b/Scripts/uninstall-driver.sh @@ -1,21 +1,64 @@ #!/bin/bash # uninstall-driver.sh -# Removes the AppFaders driver from the system +# Removes the AppFaders driver and helper service from the system set -e DRIVER_PATH="/Library/Audio/Plug-Ins/HAL/AppFadersDriver.driver" +HELPER_NAME="AppFadersHelper" +HELPER_SUPPORT_DIR="/Library/Application Support/AppFaders" +LAUNCHAGENT_PLIST="com.fbreidenbach.appfaders.helper.plist" +LAUNCHAGENTS_DIR="/Library/LaunchAgents" -if [[ ! -d $DRIVER_PATH ]]; then - echo "Driver not installed at $DRIVER_PATH" - exit 0 -fi +# colors for output +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } -echo "Removing $DRIVER_PATH..." -sudo rm -rf "$DRIVER_PATH" +# step 1: remove driver +if [[ -d $DRIVER_PATH ]]; then + info "Removing driver at $DRIVER_PATH..." + sudo rm -rf "$DRIVER_PATH" +else + warn "Driver not installed at $DRIVER_PATH" +fi -echo "Restarting coreaudiod..." +# step 2: restart coreaudiod +info "Restarting coreaudiod..." sudo killall coreaudiod 2>/dev/null || true sleep 2 -echo "Done. Driver uninstalled." +# step 3: unload LaunchAgent (ignore errors if not loaded) +info "Unloading helper LaunchAgent..." +sudo launchctl unload "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" 2>/dev/null || true + +# step 4: remove LaunchAgent plist +if [[ -f "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" ]]; then + info "Removing LaunchAgent plist..." + sudo rm -f "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" +else + warn "LaunchAgent plist not found" +fi + +# step 5: remove helper binary +if [[ -f "$HELPER_SUPPORT_DIR/$HELPER_NAME" ]]; then + info "Removing helper binary..." + sudo rm -f "$HELPER_SUPPORT_DIR/$HELPER_NAME" +else + warn "Helper binary not found" +fi + +# step 6: remove support directory if empty +if [[ -d "$HELPER_SUPPORT_DIR" ]]; then + if [[ -z "$(ls -A "$HELPER_SUPPORT_DIR")" ]]; then + info "Removing empty support directory..." + sudo rmdir "$HELPER_SUPPORT_DIR" + else + warn "Support directory not empty, leaving in place" + fi +fi + +info "Done. Driver and helper uninstalled." From 994cca0e4a715dc39c605e8eae7a1cfde6dcf0d5 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:56:08 -1000 Subject: [PATCH 21/24] test(host): update DriverBridgeTests for async XPC interface - Convert all tests to async - Remove CoreAudio import and connect(deviceID:) calls - Update #expect blocks to use await - Rename deviceNotFound test to helperNotRunning - Delete orphaned VolumeStoreTests (VolumeStore moved to helper) --- .../VolumeStoreTests.swift | 88 ------------------- Tests/AppFadersTests/DriverBridgeTests.swift | 85 +++++++++--------- 2 files changed, 40 insertions(+), 133 deletions(-) delete mode 100644 Tests/AppFadersDriverTests/VolumeStoreTests.swift diff --git a/Tests/AppFadersDriverTests/VolumeStoreTests.swift b/Tests/AppFadersDriverTests/VolumeStoreTests.swift deleted file mode 100644 index 1606d7b..0000000 --- a/Tests/AppFadersDriverTests/VolumeStoreTests.swift +++ /dev/null @@ -1,88 +0,0 @@ -// VolumeStoreTests.swift -// unit tests for VolumeStore -// -// trying to keep it simple for now - -@testable import AppFadersDriver -import Foundation -import Testing - -@Suite("VolumeStore") -struct VolumeStoreTests { - // Helper to generate unique bundle IDs to avoid state collision in shared singleton - func makeBundleID(function: String = #function) -> String { - "com.test.app.\(function)-\(UUID().uuidString)" - } - - @Test("getVolume returns default 1.0 for unknown bundleID") - func defaultVolume() { - let store = VolumeStore.shared - let bundleID = makeBundleID() - - #expect(store.getVolume(for: bundleID) == 1.0) - } - - @Test("setVolume updates volume correctly") - func setVolume() { - let store = VolumeStore.shared - let bundleID = makeBundleID() - - store.setVolume(for: bundleID, volume: 0.5) - #expect(store.getVolume(for: bundleID) == 0.5) - - store.setVolume(for: bundleID, volume: 0.0) - #expect(store.getVolume(for: bundleID) == 0.0) - - store.setVolume(for: bundleID, volume: 1.0) - #expect(store.getVolume(for: bundleID) == 1.0) - } - - @Test("setVolume clamps values to 0.0-1.0 range") - func volumeClamping() { - let store = VolumeStore.shared - let bundleID = makeBundleID() - - store.setVolume(for: bundleID, volume: 1.5) - #expect(store.getVolume(for: bundleID) == 1.0) - - store.setVolume(for: bundleID, volume: -0.5) - #expect(store.getVolume(for: bundleID) == 0.0) - } - - @Test("removeVolume resets to default") - func removeVolume() { - let store = VolumeStore.shared - let bundleID = makeBundleID() - - store.setVolume(for: bundleID, volume: 0.3) - #expect(store.getVolume(for: bundleID) == 0.3) - - store.removeVolume(for: bundleID) - #expect(store.getVolume(for: bundleID) == 1.0) - } - - @Test("concurrent access is thread-safe") - func concurrentAccess() async { - let store = VolumeStore.shared - let bundleID = makeBundleID() - let iterations = 1000 - - // use dispatch queue concurrent perform to stress the lock - await withCheckedContinuation { continuation in - DispatchQueue.global().async { - DispatchQueue.concurrentPerform(iterations: iterations) { i in - if i % 2 == 0 { - store.setVolume(for: bundleID, volume: Float(i) / Float(iterations)) - } else { - _ = store.getVolume(for: bundleID) - } - } - continuation.resume() - } - } - - // Verify it didn't crash and returns a valid value - let finalVol = store.getVolume(for: bundleID) - #expect(finalVol >= 0.0 && finalVol <= 1.0) - } -} diff --git a/Tests/AppFadersTests/DriverBridgeTests.swift b/Tests/AppFadersTests/DriverBridgeTests.swift index 8fa09dc..900ddb2 100644 --- a/Tests/AppFadersTests/DriverBridgeTests.swift +++ b/Tests/AppFadersTests/DriverBridgeTests.swift @@ -1,10 +1,9 @@ // DriverBridgeTests.swift // Unit tests for DriverBridge validation logic // -// bit of nasty code never hurt a +// Tests validation before XPC calls - helper doesn't need to be running @testable import AppFaders -import CoreAudio import Foundation import Testing @@ -13,140 +12,136 @@ struct DriverBridgeTests { // MARK: - Validation Tests @Test("setAppVolume throws invalidVolumeRange for negative values") - func validateNegativeVolume() { + func validateNegativeVolume() async { let bridge = DriverBridge() - try? bridge.connect(deviceID: 123) - #expect(throws: DriverError.invalidVolumeRange(-0.1)) { - try bridge.setAppVolume(bundleID: "com.test.app", volume: -0.1) + await #expect(throws: DriverError.invalidVolumeRange(-0.1)) { + try await bridge.setAppVolume(bundleID: "com.test.app", volume: -0.1) } } @Test("setAppVolume throws invalidVolumeRange for values > 1.0") - func validateExcessiveVolume() { + func validateExcessiveVolume() async { let bridge = DriverBridge() - try? bridge.connect(deviceID: 123) - #expect(throws: DriverError.invalidVolumeRange(1.1)) { - try bridge.setAppVolume(bundleID: "com.test.app", volume: 1.1) + await #expect(throws: DriverError.invalidVolumeRange(1.1)) { + try await bridge.setAppVolume(bundleID: "com.test.app", volume: 1.1) } } @Test("setAppVolume accepts valid volume range (0.0 - 1.0)") - func validateValidVolume() { + func validateValidVolume() async { let bridge = DriverBridge() - try? bridge.connect(deviceID: 123) - // Helper to check validation - func check(_ volume: Float) { + // Valid volumes should pass validation and fail at XPC (helper not running) + // We check that invalidVolumeRange is NOT thrown + func check(_ volume: Float) async { do { - try bridge.setAppVolume(bundleID: "com.test.app", volume: volume) + try await bridge.setAppVolume(bundleID: "com.test.app", volume: volume) } catch let error as DriverError { - // We expect it to PASS validation and fail at the CoreAudio call if case .invalidVolumeRange = error { Issue.record("Should not throw invalidVolumeRange for \(volume)") } + // Other errors (helperNotRunning) are expected } catch { // Other errors are expected } } - check(0.0) - check(0.5) - check(1.0) + await check(0.0) + await check(0.5) + await check(1.0) } @Test("setAppVolume throws bundleIDTooLong for huge bundle IDs") - func validateBundleIDLength() { + func validateBundleIDLength() async { let bridge = DriverBridge() - try? bridge.connect(deviceID: 123) let hugeID = String(repeating: "a", count: 256) - #expect(throws: DriverError.bundleIDTooLong(256)) { - try bridge.setAppVolume(bundleID: hugeID, volume: 0.5) + await #expect(throws: DriverError.bundleIDTooLong(256)) { + try await bridge.setAppVolume(bundleID: hugeID, volume: 0.5) } } @Test("setAppVolume accepts max length bundle IDs (255 bytes)") - func validateMaxBundleIDLength() { + func validateMaxBundleIDLength() async { let bridge = DriverBridge() - try? bridge.connect(deviceID: 123) // 255 'a' characters = 255 bytes let maxID = String(repeating: "a", count: 255) do { - try bridge.setAppVolume(bundleID: maxID, volume: 0.5) + try await bridge.setAppVolume(bundleID: maxID, volume: 0.5) } catch let error as DriverError { if case .bundleIDTooLong = error { Issue.record("Should accept 255-byte bundle ID") } + // Other errors (helperNotRunning) are expected } catch { - // Ignore write failure + // Other errors are expected } } @Test("setAppVolume correctly handles multi-byte UTF-8 length") - func validateMultiByteBundleID() { + func validateMultiByteBundleID() async { let bridge = DriverBridge() - try? bridge.connect(deviceID: 123) // 🚀 is 4 bytes. 256 / 4 = 64 rockets = 256 bytes (too long) let hugeEmojiID = String(repeating: "🚀", count: 64) - #expect(throws: DriverError.bundleIDTooLong(256)) { - try bridge.setAppVolume(bundleID: hugeEmojiID, volume: 0.5) + await #expect(throws: DriverError.bundleIDTooLong(256)) { + try await bridge.setAppVolume(bundleID: hugeEmojiID, volume: 0.5) } // 63 rockets = 252 bytes (valid) let validEmojiID = String(repeating: "🚀", count: 63) do { - try bridge.setAppVolume(bundleID: validEmojiID, volume: 0.5) + try await bridge.setAppVolume(bundleID: validEmojiID, volume: 0.5) } catch let error as DriverError { if case .bundleIDTooLong = error { Issue.record("Should accept 252-byte bundle ID") } } catch { - // Ignore write failure + // Other errors are expected } } @Test("getAppVolume throws bundleIDTooLong for huge bundle IDs") - func validateGetVolumeBundleIDLength() { + func validateGetVolumeBundleIDLength() async { let bridge = DriverBridge() - try? bridge.connect(deviceID: 123) let hugeID = String(repeating: "a", count: 256) - #expect(throws: DriverError.bundleIDTooLong(256)) { - _ = try bridge.getAppVolume(bundleID: hugeID) + await #expect(throws: DriverError.bundleIDTooLong(256)) { + _ = try await bridge.getAppVolume(bundleID: hugeID) } } // MARK: - Connection State Tests - @Test("Methods throw deviceNotFound when disconnected") - func deviceNotFound() { + @Test("Methods throw helperNotRunning when disconnected") + func helperNotRunning() async { let bridge = DriverBridge() // Ensure disconnected bridge.disconnect() - #expect(throws: DriverError.deviceNotFound) { - try bridge.setAppVolume(bundleID: "com.test.app", volume: 0.5) + await #expect(throws: DriverError.helperNotRunning) { + try await bridge.setAppVolume(bundleID: "com.test.app", volume: 0.5) } - #expect(throws: DriverError.deviceNotFound) { - _ = try bridge.getAppVolume(bundleID: "com.test.app") + await #expect(throws: DriverError.helperNotRunning) { + _ = try await bridge.getAppVolume(bundleID: "com.test.app") } } @Test("Connection state is managed correctly") - func connectionState() { + func connectionState() async { let bridge = DriverBridge() #expect(!bridge.isConnected) - try? bridge.connect(deviceID: 123) + // connect() will succeed (creates connection object) even without helper + try? await bridge.connect() #expect(bridge.isConnected) bridge.disconnect() From 8da5cec7f9bb5b92aef777d06b1cd2abbc31f40a Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:58:07 -1000 Subject: [PATCH 22/24] docs: add XPC integration test procedure Manual test procedure for verifying XPC communication: - Install/uninstall steps - Log stream commands for driver and helper - Volume set/get verification - Driver cache verification - Troubleshooting section - Success criteria checklist --- docs/xpc-integration-test.md | 150 +++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 docs/xpc-integration-test.md diff --git a/docs/xpc-integration-test.md b/docs/xpc-integration-test.md new file mode 100644 index 0000000..2f92b51 --- /dev/null +++ b/docs/xpc-integration-test.md @@ -0,0 +1,150 @@ +# XPC Integration Test Procedure + +Manual test to verify XPC communication between host app, helper service, and driver. + +## Prerequisites + +- macOS 26+ (arm64) +- Developer ID Application certificate in keychain +- Admin access for installation + +## Test Steps + +### 1. Install Helper and Driver + +```bash +./Scripts/install-driver.sh +``` + +**Expected output:** +- Helper binary copied to `/Library/Application Support/AppFaders/` +- LaunchAgent plist installed to `/Library/LaunchAgents/` +- LaunchAgent loaded +- Driver installed to `/Library/Audio/Plug-Ins/HAL/` +- coreaudiod restarted +- "AppFaders Virtual Device is registered!" message + +### 2. Verify Helper Registration + +```bash +launchctl list | grep appfaders +``` + +**Expected:** Line containing `com.fbreidenbach.appfaders.helper` + +Note: If using system-wide installation, use `sudo launchctl list` instead. + +### 3. Check Driver Logs (Terminal 1) + +```bash +log stream --predicate 'subsystem == "com.fbreidenbach.appfaders.driver"' --level debug +``` + +**Expected on startup:** "Connecting to helper" and "XPC connection established" messages + +### 4. Check Helper Logs (Terminal 2) + +```bash +log stream --predicate 'subsystem == "com.fbreidenbach.appfaders.helper"' --level debug +``` + +**Expected:** "XPC connection accepted" when driver or host connects + +### 5. Run Host App + +```bash +swift run AppFaders +``` + +**Expected in host logs:** +- "Connected to AppFaders Helper Service" + +**Expected in helper logs:** +- Second "XPC connection accepted" (from host) + +### 6. Trigger Volume Change + +Since the UI isn't implemented yet, add temporary debug code to `AudioOrchestrator.start()`: + +```swift +// Temporary test - remove after verification +Task { + try? await Task.sleep(for: .seconds(2)) + await setVolume(for: "com.apple.Safari", volume: 0.5) +} +``` + +Or use lldb if running in debugger: +``` +(lldb) expr await orchestrator.setVolume(for: "com.apple.Safari", volume: 0.5) +``` + +**Expected in helper logs:** +- "setVolume: com.apple.Safari -> 0.5" + +### 7. Verify Round-Trip + +Query volume back: +- Get Safari volume: triggers `getVolume(bundleID: "com.apple.Safari")` + +**Expected:** +- Returns 0.5 +- Helper logs show "getVolume: com.apple.Safari -> 0.5" + +### 8. Verify Driver Cache + +The driver connects to helper on load and periodically refreshes its local cache. + +**Check Terminal 1 (driver logs) for:** +- "Connecting to helper: com.fbreidenbach.appfaders.helper" +- "XPC connection established" +- "Cache refreshed with N volumes" (N >= 1 after step 6) + +The driver's `HelperBridge.getVolume(for:)` returns from local cache synchronously - this is verified by the driver functioning without blocking audio callbacks. + +## Cleanup + +```bash +./Scripts/uninstall-driver.sh +``` + +**Expected:** +- Driver removed +- coreaudiod restarted +- LaunchAgent unloaded +- Helper binary and plist removed + +## Troubleshooting + +### Helper not starting +```bash +# Check plist syntax +plutil -lint /Library/LaunchAgents/com.fbreidenbach.appfaders.helper.plist + +# Manual load +sudo launchctl load /Library/LaunchAgents/com.fbreidenbach.appfaders.helper.plist + +# Check for errors +sudo launchctl error system/com.fbreidenbach.appfaders.helper +``` + +### XPC connection fails +- Verify `AudioServerPlugIn_MachServices` in driver Info.plist +- Check helper binary exists at `/Library/Application Support/AppFaders/AppFadersHelper` +- Verify helper is executable: `ls -la "/Library/Application Support/AppFaders/"` + +### Driver not connecting +- Restart coreaudiod: `sudo killall coreaudiod` +- Check Console.app for coreaudiod errors +- Filter by subsystem: `com.fbreidenbach.appfaders` + +## Success Criteria + +- [ ] Helper starts on-demand via launchd +- [ ] Host connects to helper via XPC +- [ ] Driver connects to helper via XPC +- [ ] setVolume from host stores in helper's VolumeStore +- [ ] getVolume from host retrieves from helper +- [ ] Driver's cache refresh fetches volumes from helper +- [ ] Driver's getVolume returns cached value (non-blocking) +- [ ] Uninstall cleanly removes all components From 59a3b843383d6581dd83b9693fec96246dca14e3 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:43:49 -1000 Subject: [PATCH 23/24] fix(driver): use LaunchDaemon for helper and defer XPC init Driver was failing to load because: 1. Helper was a LaunchAgent (user session) but driver runs in system context (Core-Audio-Driver-Service.helper) 2. XPC calls during driver init blocked for 30s causing timeout Changes: - Switch helper from LaunchAgent to LaunchDaemon - Use launchctl bootstrap/bootout for system domain - Defer initial cache refresh to background queue --- Scripts/install-driver.sh | 24 +++++++++++----------- Scripts/uninstall-driver.sh | 20 +++++++++--------- Sources/AppFadersDriver/HelperBridge.swift | 17 ++++++++++----- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/Scripts/install-driver.sh b/Scripts/install-driver.sh index 14f078d..baa4e99 100755 --- a/Scripts/install-driver.sh +++ b/Scripts/install-driver.sh @@ -13,8 +13,8 @@ HAL_PLUGINS_DIR="/Library/Audio/Plug-Ins/HAL" DRIVER_NAME="AppFadersDriver.driver" HELPER_NAME="AppFadersHelper" HELPER_SUPPORT_DIR="/Library/Application Support/AppFaders" -LAUNCHAGENT_PLIST="com.fbreidenbach.appfaders.helper.plist" -LAUNCHAGENTS_DIR="/Library/LaunchAgents" +LAUNCHDAEMON_PLIST="com.fbreidenbach.appfaders.helper.plist" +LAUNCHDAEMONS_DIR="/Library/LaunchDaemons" # colors for output RED='\033[0;31m' @@ -52,22 +52,22 @@ sudo chmod 755 "$HELPER_SUPPORT_DIR/$HELPER_NAME" sudo chown root:wheel "$HELPER_SUPPORT_DIR/$HELPER_NAME" info "Helper binary installed to $HELPER_SUPPORT_DIR" -# install LaunchAgent plist -PLIST_SOURCE="$PROJECT_DIR/Resources/$LAUNCHAGENT_PLIST" +# install LaunchDaemon plist +PLIST_SOURCE="$PROJECT_DIR/Resources/$LAUNCHDAEMON_PLIST" if [[ ! -f $PLIST_SOURCE ]]; then - error "LaunchAgent plist not found at $PLIST_SOURCE" + error "LaunchDaemon plist not found at $PLIST_SOURCE" fi # unload existing if present (ignore errors) -sudo launchctl unload "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" 2>/dev/null || true +sudo launchctl bootout system "$LAUNCHDAEMONS_DIR/$LAUNCHDAEMON_PLIST" 2>/dev/null || true -sudo cp "$PLIST_SOURCE" "$LAUNCHAGENTS_DIR/" -sudo chown root:wheel "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" -sudo chmod 644 "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" +sudo cp "$PLIST_SOURCE" "$LAUNCHDAEMONS_DIR/" +sudo chown root:wheel "$LAUNCHDAEMONS_DIR/$LAUNCHDAEMON_PLIST" +sudo chmod 644 "$LAUNCHDAEMONS_DIR/$LAUNCHDAEMON_PLIST" -# load the LaunchAgent -sudo launchctl load "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" || warn "Failed to load LaunchAgent (may already be loaded)" -info "Helper LaunchAgent installed and loaded" +# bootstrap the LaunchDaemon into system domain +sudo launchctl bootstrap system "$LAUNCHDAEMONS_DIR/$LAUNCHDAEMON_PLIST" || warn "Failed to bootstrap LaunchDaemon (may already be loaded)" +info "Helper LaunchDaemon installed and bootstrapped" # step 3: locate built driver dylib DYLIB_PATH=".build/debug/libAppFadersDriver.dylib" diff --git a/Scripts/uninstall-driver.sh b/Scripts/uninstall-driver.sh index 780b163..a6efcf5 100755 --- a/Scripts/uninstall-driver.sh +++ b/Scripts/uninstall-driver.sh @@ -7,8 +7,8 @@ set -e DRIVER_PATH="/Library/Audio/Plug-Ins/HAL/AppFadersDriver.driver" HELPER_NAME="AppFadersHelper" HELPER_SUPPORT_DIR="/Library/Application Support/AppFaders" -LAUNCHAGENT_PLIST="com.fbreidenbach.appfaders.helper.plist" -LAUNCHAGENTS_DIR="/Library/LaunchAgents" +LAUNCHDAEMON_PLIST="com.fbreidenbach.appfaders.helper.plist" +LAUNCHDAEMONS_DIR="/Library/LaunchDaemons" # colors for output GREEN='\033[0;32m' @@ -31,16 +31,16 @@ info "Restarting coreaudiod..." sudo killall coreaudiod 2>/dev/null || true sleep 2 -# step 3: unload LaunchAgent (ignore errors if not loaded) -info "Unloading helper LaunchAgent..." -sudo launchctl unload "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" 2>/dev/null || true +# step 3: unload LaunchDaemon (ignore errors if not loaded) +info "Unloading helper LaunchDaemon..." +sudo launchctl bootout system "$LAUNCHDAEMONS_DIR/$LAUNCHDAEMON_PLIST" 2>/dev/null || true -# step 4: remove LaunchAgent plist -if [[ -f "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" ]]; then - info "Removing LaunchAgent plist..." - sudo rm -f "$LAUNCHAGENTS_DIR/$LAUNCHAGENT_PLIST" +# step 4: remove LaunchDaemon plist +if [[ -f "$LAUNCHDAEMONS_DIR/$LAUNCHDAEMON_PLIST" ]]; then + info "Removing LaunchDaemon plist..." + sudo rm -f "$LAUNCHDAEMONS_DIR/$LAUNCHDAEMON_PLIST" else - warn "LaunchAgent plist not found" + warn "LaunchDaemon plist not found" fi # step 5: remove helper binary diff --git a/Sources/AppFadersDriver/HelperBridge.swift b/Sources/AppFadersDriver/HelperBridge.swift index 365096a..dc4953f 100644 --- a/Sources/AppFadersDriver/HelperBridge.swift +++ b/Sources/AppFadersDriver/HelperBridge.swift @@ -61,8 +61,10 @@ final class HelperBridge: @unchecked Sendable { os_log(.info, log: log, "XPC connection established") - // Initial cache refresh (async, don't block) - refreshCacheAsync() + // Defer initial cache refresh - XPC calls during driver init can block + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.refreshCacheAsync() + } } /// Disconnect from helper service @@ -111,20 +113,25 @@ final class HelperBridge: @unchecked Sendable { let conn = connection lock.unlock() - guard let conn = conn else { + guard let conn else { os_log(.debug, log: log, "Cannot refresh cache - not connected") return } guard let proxy = conn.remoteObjectProxyWithErrorHandler({ error in - os_log(.error, log: log, "XPC error during cache refresh: %{public}@", error.localizedDescription) + os_log( + .error, + log: log, + "XPC error during cache refresh: %{public}@", + error.localizedDescription + ) }) as? AppFadersDriverProtocol else { os_log(.error, log: log, "Failed to get remote object proxy") return } proxy.getAllVolumes { [weak self] volumes, error in - if let error = error { + if let error { os_log(.error, log: log, "getAllVolumes failed: %{public}@", error.localizedDescription) return } From d9457692e4ccfdeb4877656fb1bf00b125e3f05a Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:53:11 -1000 Subject: [PATCH 24/24] docs: update integration test with results --- docs/xpc-integration-test.md | 68 +++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/docs/xpc-integration-test.md b/docs/xpc-integration-test.md index 2f92b51..b0a7a54 100644 --- a/docs/xpc-integration-test.md +++ b/docs/xpc-integration-test.md @@ -17,9 +17,10 @@ Manual test to verify XPC communication between host app, helper service, and dr ``` **Expected output:** + - Helper binary copied to `/Library/Application Support/AppFaders/` -- LaunchAgent plist installed to `/Library/LaunchAgents/` -- LaunchAgent loaded +- LaunchDaemon plist installed to `/Library/LaunchDaemons/` +- LaunchDaemon loaded - Driver installed to `/Library/Audio/Plug-Ins/HAL/` - coreaudiod restarted - "AppFaders Virtual Device is registered!" message @@ -27,12 +28,10 @@ Manual test to verify XPC communication between host app, helper service, and dr ### 2. Verify Helper Registration ```bash -launchctl list | grep appfaders +sudo launchctl print system/com.fbreidenbach.appfaders.helper | grep -E "state|runs" ``` -**Expected:** Line containing `com.fbreidenbach.appfaders.helper` - -Note: If using system-wide installation, use `sudo launchctl list` instead. +**Expected:** `state = running` and `runs = 1` (or higher) ### 3. Check Driver Logs (Terminal 1) @@ -57,9 +56,11 @@ swift run AppFaders ``` **Expected in host logs:** + - "Connected to AppFaders Helper Service" **Expected in helper logs:** + - Second "XPC connection accepted" (from host) ### 6. Trigger Volume Change @@ -75,19 +76,23 @@ Task { ``` Or use lldb if running in debugger: + ``` (lldb) expr await orchestrator.setVolume(for: "com.apple.Safari", volume: 0.5) ``` **Expected in helper logs:** + - "setVolume: com.apple.Safari -> 0.5" ### 7. Verify Round-Trip Query volume back: + - Get Safari volume: triggers `getVolume(bundleID: "com.apple.Safari")` **Expected:** + - Returns 0.5 - Helper logs show "getVolume: com.apple.Safari -> 0.5" @@ -96,6 +101,7 @@ Query volume back: The driver connects to helper on load and periodically refreshes its local cache. **Check Terminal 1 (driver logs) for:** + - "Connecting to helper: com.fbreidenbach.appfaders.helper" - "XPC connection established" - "Cache refreshed with N volumes" (N >= 1 after step 6) @@ -109,42 +115,64 @@ The driver's `HelperBridge.getVolume(for:)` returns from local cache synchronous ``` **Expected:** + - Driver removed - coreaudiod restarted -- LaunchAgent unloaded +- LaunchDaemon unloaded - Helper binary and plist removed ## Troubleshooting ### Helper not starting + ```bash # Check plist syntax -plutil -lint /Library/LaunchAgents/com.fbreidenbach.appfaders.helper.plist +plutil -lint /Library/LaunchDaemons/com.fbreidenbach.appfaders.helper.plist -# Manual load -sudo launchctl load /Library/LaunchAgents/com.fbreidenbach.appfaders.helper.plist +# Manual bootstrap into system domain +sudo launchctl bootstrap system /Library/LaunchDaemons/com.fbreidenbach.appfaders.helper.plist -# Check for errors -sudo launchctl error system/com.fbreidenbach.appfaders.helper +# Check status +sudo launchctl print system/com.fbreidenbach.appfaders.helper ``` ### XPC connection fails + - Verify `AudioServerPlugIn_MachServices` in driver Info.plist - Check helper binary exists at `/Library/Application Support/AppFaders/AppFadersHelper` - Verify helper is executable: `ls -la "/Library/Application Support/AppFaders/"` ### Driver not connecting + - Restart coreaudiod: `sudo killall coreaudiod` - Check Console.app for coreaudiod errors - Filter by subsystem: `com.fbreidenbach.appfaders` +### Driver hangs on load (30s timeout) + +**Symptom:** Install script hangs, logs show "Not loading the driver plug-in into coreaudiod" after 30s. + +**Key findings:** + +1. **Must use LaunchDaemon, not LaunchAgent** - The driver runs in `Core-Audio-Driver-Service.helper` (system context). LaunchAgents only run in user sessions, so the driver can't connect to them. Use `/Library/LaunchDaemons/` with `launchctl bootstrap system`. + +2. **XPC calls block driver init** - Even async XPC calls can block during driver initialization. Defer any XPC operations (like cache refresh) to a background queue: + + ```swift + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) { + self?.refreshCacheAsync() + } + ``` + ## Success Criteria -- [ ] Helper starts on-demand via launchd -- [ ] Host connects to helper via XPC -- [ ] Driver connects to helper via XPC -- [ ] setVolume from host stores in helper's VolumeStore -- [ ] getVolume from host retrieves from helper -- [ ] Driver's cache refresh fetches volumes from helper -- [ ] Driver's getVolume returns cached value (non-blocking) -- [ ] Uninstall cleanly removes all components +- [x] Helper starts on-demand via launchd +- [x] Host connects to helper via XPC +- [x] Driver connects to helper via XPC +- [x] setVolume from host stores in helper's VolumeStore +- [x] getVolume from host retrieves from helper +- [x] Driver's cache refresh fetches volumes from helper +- [x] Driver's getVolume returns cached value (non-blocking) +- [x] Uninstall cleanly removes all components + +*Verified 2026-01-26*