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*