From 8aa11f1add5898053b02f8a50e5dfe971eba693f Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:55:09 -1000 Subject: [PATCH 01/24] feat(core): add AppFadersCore library with shared code for UI reuse Extract shared types and functionality from AppFaders into a dedicated library target for reuse by desktop UI and future TUI implementations. --- Package.swift | 5 + Sources/AppFadersCore/AppAudioMonitor.swift | 114 ++++++++++++++++ Sources/AppFadersCore/DriverBridge.swift | 142 ++++++++++++++++++++ Sources/AppFadersCore/DriverError.swift | 40 ++++++ Sources/AppFadersCore/HostProtocol.swift | 9 ++ Sources/AppFadersCore/TrackedApp.swift | 39 ++++++ 6 files changed, 349 insertions(+) create mode 100644 Sources/AppFadersCore/AppAudioMonitor.swift create mode 100644 Sources/AppFadersCore/DriverBridge.swift create mode 100644 Sources/AppFadersCore/DriverError.swift create mode 100644 Sources/AppFadersCore/HostProtocol.swift create mode 100644 Sources/AppFadersCore/TrackedApp.swift diff --git a/Package.swift b/Package.swift index 0681b5b..8c7cde4 100644 --- a/Package.swift +++ b/Package.swift @@ -11,12 +11,17 @@ let package = Package( .executable(name: "AppFaders", targets: ["AppFaders"]), .executable(name: "AppFadersHelper", targets: ["AppFadersHelper"]), .library(name: "AppFadersDriver", type: .dynamic, targets: ["AppFadersDriver"]), + .library(name: "AppFadersCore", targets: ["AppFadersCore"]), .plugin(name: "BundleAssembler", targets: ["BundleAssembler"]) ], dependencies: [ .package(url: "https://github.com/sbooth/CAAudioHardware", from: "0.7.1") ], targets: [ + .target( + name: "AppFadersCore", + dependencies: [] + ), .executableTarget( name: "AppFaders", dependencies: [ diff --git a/Sources/AppFadersCore/AppAudioMonitor.swift b/Sources/AppFadersCore/AppAudioMonitor.swift new file mode 100644 index 0000000..9aa5476 --- /dev/null +++ b/Sources/AppFadersCore/AppAudioMonitor.swift @@ -0,0 +1,114 @@ +import AppKit +import Foundation +import os.log + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "AppAudioMonitor") + +/// lifecycle events for tracked applications +public enum AppLifecycleEvent: Sendable { + case didLaunch(TrackedApp) + case didTerminate(String) // bundleID +} + +/// monitors running applications using NSWorkspace +public final class AppAudioMonitor: @unchecked Sendable { + private let workspace = NSWorkspace.shared + private let lock = NSLock() + private var _runningApps: [TrackedApp] = [] + + /// currently running tracked applications + public var runningApps: [TrackedApp] { + lock.lock() + defer { lock.unlock() } + return _runningApps + } + + /// async stream of app lifecycle events + public var events: AsyncStream { + AsyncStream { continuation in + let task = Task { [weak self] in + guard let self else { return } + + await withTaskGroup(of: Void.self) { group in + // Launch notifications + group.addTask { [weak self] in + for await notification in NotificationCenter.default.notifications( + named: NSWorkspace.didLaunchApplicationNotification + ) { + self?.handleAppLaunch(notification, continuation: continuation) + } + } + + // Termination notifications + group.addTask { [weak self] in + for await notification in NotificationCenter.default.notifications( + named: NSWorkspace.didTerminateApplicationNotification + ) { + self?.handleAppTerminate(notification, continuation: continuation) + } + } + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + public init() { + os_log(.info, log: log, "AppAudioMonitor initialized") + } + + /// starts monitoring and populates initial state + public func start() { + // initial snapshot + let currentApps = workspace.runningApplications + .compactMap { TrackedApp(from: $0) } + + lock.lock() + _runningApps = currentApps + lock.unlock() + + os_log(.info, log: log, "Started monitoring with %d initial apps", currentApps.count) + } + + private func handleAppLaunch( + _ notification: Notification, + continuation: AsyncStream.Continuation + ) { + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let trackedApp = TrackedApp(from: app) + else { return } + + lock.lock() + if !_runningApps.contains(where: { $0.bundleID == trackedApp.bundleID }) { + _runningApps.append(trackedApp) + os_log(.debug, log: log, "App launched: %{public}@", trackedApp.bundleID) + continuation.yield(.didLaunch(trackedApp)) + } else { + os_log(.debug, log: log, "App launched (already tracked): %{public}@", trackedApp.bundleID) + } + lock.unlock() + } + + private func handleAppTerminate( + _ notification: Notification, + continuation: AsyncStream.Continuation + ) { + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let bundleID = app.bundleIdentifier + else { return } + + lock.lock() + if let index = _runningApps.firstIndex(where: { $0.bundleID == bundleID }) { + _runningApps.remove(at: index) + } + lock.unlock() + + os_log(.debug, log: log, "App terminated: %{public}@", bundleID) + continuation.yield(.didTerminate(bundleID)) + } +} diff --git a/Sources/AppFadersCore/DriverBridge.swift b/Sources/AppFadersCore/DriverBridge.swift new file mode 100644 index 0000000..a8802ef --- /dev/null +++ b/Sources/AppFadersCore/DriverBridge.swift @@ -0,0 +1,142 @@ +import Foundation +import os.log + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "DriverBridge") +private let machServiceName = "com.fbreidenbach.appfaders.helper" + +/// handles communication with the AppFaders helper service via XPC +public final class DriverBridge: @unchecked Sendable { + private let lock = NSLock() + private var connection: NSXPCConnection? + + public init() {} + + /// returns true if currently connected to the helper service + public var isConnected: Bool { + lock.withLock { connection != nil } + } + + // MARK: - Connection Management + + /// establishes XPC connection to the helper service + public 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() + } + } + + /// disconnects from the helper service + public func disconnect() { + lock.lock() + defer { lock.unlock() } + + connection?.invalidate() + connection = nil + os_log(.info, log: log, "Disconnected from helper") + } + + 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 XPC call fails + public func setAppVolume(bundleID: String, volume: Float) async throws { + guard volume >= 0.0, volume <= 1.0 else { + throw DriverError.invalidVolumeRange(volume) + } + + guard bundleID.utf8.count <= 255 else { + throw DriverError.bundleIDTooLong(bundleID.utf8.count) + } + + let proxy = try getProxy() + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + proxy.setVolume(bundleID: bundleID, volume: volume) { error in + if let error { + continuation.resume(throwing: DriverError.remoteError(error.localizedDescription)) + } else { + continuation.resume() + } + } + } + } + + /// 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 validation fails or XPC call fails + public func getAppVolume(bundleID: String) async throws -> Float { + guard bundleID.utf8.count <= 255 else { + throw DriverError.bundleIDTooLong(bundleID.utf8.count) + } + + let proxy = try getProxy() + + return try await withCheckedThrowingContinuation { continuation in + proxy.getVolume(bundleID: bundleID) { volume, error in + if let 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() + + guard let conn else { + throw DriverError.helperNotRunning + } + + 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 proxy + } +} diff --git a/Sources/AppFadersCore/DriverError.swift b/Sources/AppFadersCore/DriverError.swift new file mode 100644 index 0000000..6a2b174 --- /dev/null +++ b/Sources/AppFadersCore/DriverError.swift @@ -0,0 +1,40 @@ +import Foundation + +/// errors related to driver communication and management +public enum DriverError: Error, LocalizedError, Equatable, Sendable { + case deviceNotFound + case propertyReadFailed(OSStatus) + case propertyWriteFailed(OSStatus) + case invalidVolumeRange(Float) + case bundleIDTooLong(Int) + + // MARK: - XPC Errors + + case helperNotRunning + case connectionFailed(String) + case connectionInterrupted + case remoteError(String) + + public var errorDescription: String? { + switch self { + case .deviceNotFound: + "AppFaders Virtual Device not found. Please ensure the driver is installed." + case let .propertyReadFailed(status): + "Failed to read driver property (OSStatus: \(status))." + case let .propertyWriteFailed(status): + "Failed to write driver property (OSStatus: \(status))." + case let .invalidVolumeRange(volume): + "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)" + } + } +} diff --git a/Sources/AppFadersCore/HostProtocol.swift b/Sources/AppFadersCore/HostProtocol.swift new file mode 100644 index 0000000..9d2e0cf --- /dev/null +++ b/Sources/AppFadersCore/HostProtocol.swift @@ -0,0 +1,9 @@ +import Foundation + +// NOTE: Must match AppFadersHelper/XPCProtocols.swift exactly +/// Protocol for host app connections (read-write) +@objc public 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) +} diff --git a/Sources/AppFadersCore/TrackedApp.swift b/Sources/AppFadersCore/TrackedApp.swift new file mode 100644 index 0000000..22305af --- /dev/null +++ b/Sources/AppFadersCore/TrackedApp.swift @@ -0,0 +1,39 @@ +import AppKit +import Foundation + +/// Application tracked by the host orchestrator +public struct TrackedApp: Identifiable, Sendable, Hashable { + public var id: String { bundleID } + + public let bundleID: String + public let localizedName: String + public let icon: NSImage? + public let launchDate: Date + + public init?(from runningApp: NSRunningApplication) { + guard let bundleID = runningApp.bundleIdentifier else { + return nil + } + + self.bundleID = bundleID + localizedName = runningApp.localizedName ?? bundleID + icon = runningApp.icon + launchDate = runningApp.launchDate ?? .distantPast + } + + public init(bundleID: String, localizedName: String, icon: NSImage?, launchDate: Date) { + self.bundleID = bundleID + self.localizedName = localizedName + self.icon = icon + self.launchDate = launchDate + } + + public static func == (lhs: TrackedApp, rhs: TrackedApp) -> Bool { + lhs.bundleID == rhs.bundleID && lhs.launchDate == rhs.launchDate + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(bundleID) + hasher.combine(launchDate) + } +} From 3e9435dfe8a253c68b8cb53fddc1da3d5ff416b5 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:58:10 -1000 Subject: [PATCH 02/24] refactor(app): extract shared types to AppFadersCore Move DriverBridge, DriverError, AppAudioMonitor, TrackedApp, and HelperProtocol from AppFaders to AppFadersCore shared library. Update Package.swift to add AppFadersCore dependency and update test imports. --- Package.swift | 3 +- Sources/AppFaders/AppAudioMonitor.swift | 114 -------------- Sources/AppFaders/AudioOrchestrator.swift | 1 + Sources/AppFaders/DriverBridge.swift | 140 ------------------ Sources/AppFaders/DriverError.swift | 40 ----- Sources/AppFaders/HelperProtocol.swift | 9 -- Sources/AppFaders/TrackedApp.swift | 39 ----- .../AppFadersTests/AppAudioMonitorTests.swift | 2 +- Tests/AppFadersTests/DriverBridgeTests.swift | 2 +- 9 files changed, 5 insertions(+), 345 deletions(-) delete mode 100644 Sources/AppFaders/AppAudioMonitor.swift delete mode 100644 Sources/AppFaders/DriverBridge.swift delete mode 100644 Sources/AppFaders/DriverError.swift delete mode 100644 Sources/AppFaders/HelperProtocol.swift delete mode 100644 Sources/AppFaders/TrackedApp.swift diff --git a/Package.swift b/Package.swift index 8c7cde4..a4f531f 100644 --- a/Package.swift +++ b/Package.swift @@ -25,6 +25,7 @@ let package = Package( .executableTarget( name: "AppFaders", dependencies: [ + "AppFadersCore", .product(name: "CAAudioHardware", package: "CAAudioHardware") ] ), @@ -66,7 +67,7 @@ let package = Package( ), .testTarget( name: "AppFadersTests", - dependencies: ["AppFaders"] + dependencies: ["AppFaders", "AppFadersCore"] ) ] ) diff --git a/Sources/AppFaders/AppAudioMonitor.swift b/Sources/AppFaders/AppAudioMonitor.swift deleted file mode 100644 index 088aa52..0000000 --- a/Sources/AppFaders/AppAudioMonitor.swift +++ /dev/null @@ -1,114 +0,0 @@ -import AppKit -import Foundation -import os.log - -private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "AppAudioMonitor") - -/// lifecycle events for tracked applications -enum AppLifecycleEvent: Sendable { - case didLaunch(TrackedApp) - case didTerminate(String) // bundleID -} - -/// monitors running applications using NSWorkspace -final class AppAudioMonitor: @unchecked Sendable { - private let workspace = NSWorkspace.shared - private let lock = NSLock() - private var _runningApps: [TrackedApp] = [] - - /// currently running tracked applications - var runningApps: [TrackedApp] { - lock.lock() - defer { lock.unlock() } - return _runningApps - } - - /// async stream of app lifecycle events - var events: AsyncStream { - AsyncStream { continuation in - let task = Task { [weak self] in - guard let self else { return } - - await withTaskGroup(of: Void.self) { group in - // Launch notifications - group.addTask { [weak self] in - for await notification in NotificationCenter.default.notifications( - named: NSWorkspace.didLaunchApplicationNotification - ) { - self?.handleAppLaunch(notification, continuation: continuation) - } - } - - // Termination notifications - group.addTask { [weak self] in - for await notification in NotificationCenter.default.notifications( - named: NSWorkspace.didTerminateApplicationNotification - ) { - self?.handleAppTerminate(notification, continuation: continuation) - } - } - } - } - - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } - - init() { - os_log(.info, log: log, "AppAudioMonitor initialized") - } - - /// starts monitoring and populates initial state - func start() { - // initial snapshot - let currentApps = workspace.runningApplications - .compactMap { TrackedApp(from: $0) } - - lock.lock() - _runningApps = currentApps - lock.unlock() - - os_log(.info, log: log, "Started monitoring with %d initial apps", currentApps.count) - } - - private func handleAppLaunch( - _ notification: Notification, - continuation: AsyncStream.Continuation - ) { - guard let app = notification - .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, - let trackedApp = TrackedApp(from: app) - else { return } - - lock.lock() - if !_runningApps.contains(where: { $0.bundleID == trackedApp.bundleID }) { - _runningApps.append(trackedApp) - os_log(.debug, log: log, "App launched: %{public}@", trackedApp.bundleID) - continuation.yield(.didLaunch(trackedApp)) - } else { - os_log(.debug, log: log, "App launched (already tracked): %{public}@", trackedApp.bundleID) - } - lock.unlock() - } - - private func handleAppTerminate( - _ notification: Notification, - continuation: AsyncStream.Continuation - ) { - guard let app = notification - .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, - let bundleID = app.bundleIdentifier - else { return } - - lock.lock() - if let index = _runningApps.firstIndex(where: { $0.bundleID == bundleID }) { - _runningApps.remove(at: index) - } - lock.unlock() - - os_log(.debug, log: log, "App terminated: %{public}@", bundleID) - continuation.yield(.didTerminate(bundleID)) - } -} diff --git a/Sources/AppFaders/AudioOrchestrator.swift b/Sources/AppFaders/AudioOrchestrator.swift index 2716d02..e26a2bf 100644 --- a/Sources/AppFaders/AudioOrchestrator.swift +++ b/Sources/AppFaders/AudioOrchestrator.swift @@ -1,3 +1,4 @@ +import AppFadersCore import CAAudioHardware import Foundation import Observation diff --git a/Sources/AppFaders/DriverBridge.swift b/Sources/AppFaders/DriverBridge.swift deleted file mode 100644 index c065b05..0000000 --- a/Sources/AppFaders/DriverBridge.swift +++ /dev/null @@ -1,140 +0,0 @@ -import Foundation -import os.log - -private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "DriverBridge") -private let machServiceName = "com.fbreidenbach.appfaders.helper" - -/// handles communication with the AppFaders helper service via XPC -final class DriverBridge: @unchecked Sendable { - private let lock = NSLock() - private var connection: NSXPCConnection? - - /// returns true if currently connected to the helper service - var isConnected: Bool { - lock.withLock { connection != nil } - } - - // 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() - } - } - - /// disconnects from the helper service - func disconnect() { - lock.lock() - defer { lock.unlock() } - - connection?.invalidate() - connection = nil - os_log(.info, log: log, "Disconnected from helper") - } - - 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 XPC call fails - func setAppVolume(bundleID: String, volume: Float) async throws { - guard volume >= 0.0, volume <= 1.0 else { - throw DriverError.invalidVolumeRange(volume) - } - - guard bundleID.utf8.count <= 255 else { - throw DriverError.bundleIDTooLong(bundleID.utf8.count) - } - - let proxy = try getProxy() - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - proxy.setVolume(bundleID: bundleID, volume: volume) { error in - if let error { - continuation.resume(throwing: DriverError.remoteError(error.localizedDescription)) - } else { - continuation.resume() - } - } - } - } - - /// 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 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) - } - - let proxy = try getProxy() - - return try await withCheckedThrowingContinuation { continuation in - proxy.getVolume(bundleID: bundleID) { volume, error in - if let 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() - - guard let conn else { - throw DriverError.helperNotRunning - } - - 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 proxy - } -} diff --git a/Sources/AppFaders/DriverError.swift b/Sources/AppFaders/DriverError.swift deleted file mode 100644 index 961b1ab..0000000 --- a/Sources/AppFaders/DriverError.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -/// errors related to driver communication and management -enum DriverError: Error, LocalizedError, Equatable { - case deviceNotFound - case propertyReadFailed(OSStatus) - case propertyWriteFailed(OSStatus) - case invalidVolumeRange(Float) - case bundleIDTooLong(Int) - - // MARK: - XPC Errors - - case helperNotRunning - case connectionFailed(String) - case connectionInterrupted - case remoteError(String) - - var errorDescription: String? { - switch self { - case .deviceNotFound: - "AppFaders Virtual Device not found. Please ensure the driver is installed." - case let .propertyReadFailed(status): - "Failed to read driver property (OSStatus: \(status))." - case let .propertyWriteFailed(status): - "Failed to write driver property (OSStatus: \(status))." - case let .invalidVolumeRange(volume): - "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)" - } - } -} diff --git a/Sources/AppFaders/HelperProtocol.swift b/Sources/AppFaders/HelperProtocol.swift deleted file mode 100644 index 548cb52..0000000 --- a/Sources/AppFaders/HelperProtocol.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -// NOTE: Must match AppFadersHelper/XPCProtocols.swift exactly -/// 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) -} diff --git a/Sources/AppFaders/TrackedApp.swift b/Sources/AppFaders/TrackedApp.swift deleted file mode 100644 index 19d4ef1..0000000 --- a/Sources/AppFaders/TrackedApp.swift +++ /dev/null @@ -1,39 +0,0 @@ -import AppKit -import Foundation - -/// Application tracked by the host orchestrator -struct TrackedApp: Identifiable, Sendable, Hashable { - var id: String { bundleID } - - let bundleID: String - let localizedName: String - let icon: NSImage? - let launchDate: Date - - init?(from runningApp: NSRunningApplication) { - guard let bundleID = runningApp.bundleIdentifier else { - return nil - } - - self.bundleID = bundleID - localizedName = runningApp.localizedName ?? bundleID - icon = runningApp.icon - launchDate = runningApp.launchDate ?? .distantPast - } - - init(bundleID: String, localizedName: String, icon: NSImage?, launchDate: Date) { - self.bundleID = bundleID - self.localizedName = localizedName - self.icon = icon - self.launchDate = launchDate - } - - static func == (lhs: TrackedApp, rhs: TrackedApp) -> Bool { - lhs.bundleID == rhs.bundleID && lhs.launchDate == rhs.launchDate - } - - func hash(into hasher: inout Hasher) { - hasher.combine(bundleID) - hasher.combine(launchDate) - } -} diff --git a/Tests/AppFadersTests/AppAudioMonitorTests.swift b/Tests/AppFadersTests/AppAudioMonitorTests.swift index a9ca37e..3fc47e8 100644 --- a/Tests/AppFadersTests/AppAudioMonitorTests.swift +++ b/Tests/AppFadersTests/AppAudioMonitorTests.swift @@ -1,4 +1,4 @@ -@testable import AppFaders +@testable import AppFadersCore import AppKit import Foundation import Testing diff --git a/Tests/AppFadersTests/DriverBridgeTests.swift b/Tests/AppFadersTests/DriverBridgeTests.swift index 900ddb2..faa88a6 100644 --- a/Tests/AppFadersTests/DriverBridgeTests.swift +++ b/Tests/AppFadersTests/DriverBridgeTests.swift @@ -3,7 +3,7 @@ // // Tests validation before XPC calls - helper doesn't need to be running -@testable import AppFaders +@testable import AppFadersCore import Foundation import Testing From d9e7e28d1f385967c8d9389c9a9a852a38156b71 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:30:27 -1000 Subject: [PATCH 03/24] feat(app): implement menu bar app lifecycle with NSStatusItem Replace CLI entry point with proper macOS menu bar application architecture: - AppFadersApp: @main entry using NSApplication.run() pattern for proper delegate lifecycle management - AppDelegate: NSApplicationDelegate with .accessory activation policy, AudioOrchestrator integration, and graceful termination handling - MenuBarController: NSStatusItem stub with SF Symbol icon (slider.vertical.3) This establishes the foundation for the NSPanel-based volume control UI. --- Sources/AppFaders/AppDelegate.swift | 35 +++++++++++++++++++++++ Sources/AppFaders/AppFadersApp.swift | 12 ++++++++ Sources/AppFaders/MenuBarController.swift | 31 ++++++++++++++++++++ Sources/AppFaders/main.swift | 25 ---------------- 4 files changed, 78 insertions(+), 25 deletions(-) create mode 100644 Sources/AppFaders/AppDelegate.swift create mode 100644 Sources/AppFaders/AppFadersApp.swift create mode 100644 Sources/AppFaders/MenuBarController.swift delete mode 100644 Sources/AppFaders/main.swift diff --git a/Sources/AppFaders/AppDelegate.swift b/Sources/AppFaders/AppDelegate.swift new file mode 100644 index 0000000..898d399 --- /dev/null +++ b/Sources/AppFaders/AppDelegate.swift @@ -0,0 +1,35 @@ +import AppFadersCore +import AppKit +import os.log + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "AppDelegate") + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private var menuBarController: MenuBarController? + private let orchestrator = AudioOrchestrator() + private var orchestratorTask: Task? + + func applicationDidFinishLaunching(_ notification: Notification) { + os_log(.info, log: log, "AppFaders launching") + + // Menu bar only - no dock icon + NSApp.setActivationPolicy(.accessory) + + // Create menu bar controller (panel toggle placeholder for now) + menuBarController = MenuBarController() + + // Start orchestrator in background task + orchestratorTask = Task { + await orchestrator.start() + } + + os_log(.info, log: log, "AppFaders initialization complete") + } + + func applicationWillTerminate(_ notification: Notification) { + os_log(.info, log: log, "AppFaders terminating") + orchestratorTask?.cancel() + orchestrator.stop() + } +} diff --git a/Sources/AppFaders/AppFadersApp.swift b/Sources/AppFaders/AppFadersApp.swift new file mode 100644 index 0000000..c653f82 --- /dev/null +++ b/Sources/AppFaders/AppFadersApp.swift @@ -0,0 +1,12 @@ +import AppKit + +@main +struct AppFadersApp { + static func main() { + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + // run() blocks until app terminates, keeping delegate alive + app.run() + } +} diff --git a/Sources/AppFaders/MenuBarController.swift b/Sources/AppFaders/MenuBarController.swift new file mode 100644 index 0000000..419573d --- /dev/null +++ b/Sources/AppFaders/MenuBarController.swift @@ -0,0 +1,31 @@ +import AppKit +import os.log + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "MenuBarController") + +@MainActor +final class MenuBarController: NSObject { + private var statusItem: NSStatusItem? + + override init() { + super.init() + setupStatusItem() + } + + private func setupStatusItem() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + + guard let button = statusItem?.button else { + os_log(.error, log: log, "Failed to get status item button") + return + } + + // SF Symbol for menu bar icon + if let image = NSImage(systemSymbolName: "slider.vertical.3", accessibilityDescription: "AppFaders") { + image.isTemplate = true + button.image = image + } + + os_log(.info, log: log, "Menu bar controller initialized") + } +} diff --git a/Sources/AppFaders/main.swift b/Sources/AppFaders/main.swift deleted file mode 100644 index fce839b..0000000 --- a/Sources/AppFaders/main.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Dispatch -import Foundation - -// AudioOrchestrator is @MainActor, so we use a Task running on the main actor -Task { @MainActor in - print("AppFaders Host v0.2.0") - - let orchestrator = AudioOrchestrator() - print("Orchestrator initialized. Starting...") - - // Handle SIGINT for clean shutdown - let source = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) - source.setEventHandler { - print("\nReceived SIGINT. Shutting down...") - orchestrator.stop() - exit(0) - } - source.resume() - - // start loop (blocks until cancelled) - await orchestrator.start() -} - -// keep the main thread alive - allows the Task to run -dispatchMain() From 16e43be75ebbd7e7f09ab822559151388be6b592 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:32:28 -1000 Subject: [PATCH 04/24] feat(desktop-ui): implement MenuBarController click handling Add left-click panel toggle and right-click context menu to the menu bar status item. The context menu provides "Open" and "Quit" options. Panel show/hide methods are placeholders that log actions until the panel UI is implemented in subsequent tasks. --- Sources/AppFaders/MenuBarController.swift | 66 +++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/Sources/AppFaders/MenuBarController.swift b/Sources/AppFaders/MenuBarController.swift index 419573d..6adc54a 100644 --- a/Sources/AppFaders/MenuBarController.swift +++ b/Sources/AppFaders/MenuBarController.swift @@ -6,12 +6,35 @@ private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "Menu @MainActor final class MenuBarController: NSObject { private var statusItem: NSStatusItem? + private(set) var isPanelVisible = false override init() { super.init() setupStatusItem() } + // MARK: - Panel Management (placeholder for task 5-6) + + func togglePanel() { + if isPanelVisible { + hidePanel() + } else { + showPanel() + } + } + + func showPanel() { + isPanelVisible = true + os_log(.debug, log: log, "Panel shown (placeholder)") + } + + func hidePanel() { + isPanelVisible = false + os_log(.debug, log: log, "Panel hidden (placeholder)") + } + + // MARK: - Status Item Setup + private func setupStatusItem() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) @@ -24,8 +47,51 @@ final class MenuBarController: NSObject { if let image = NSImage(systemSymbolName: "slider.vertical.3", accessibilityDescription: "AppFaders") { image.isTemplate = true button.image = image + } else { + os_log(.error, log: log, "Failed to load SF Symbol 'slider.vertical.3'") } + // Handle both left and right clicks + button.target = self + button.action = #selector(statusItemClicked(_:)) + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + os_log(.info, log: log, "Menu bar controller initialized") } + + @objc private func statusItemClicked(_ sender: NSStatusBarButton) { + guard let event = NSApp.currentEvent else { return } + + if event.type == .rightMouseUp { + showContextMenu(for: sender) + } else { + togglePanel() + } + } + + private func showContextMenu(for button: NSStatusBarButton) { + let menu = NSMenu() + + let openItem = NSMenuItem(title: "Open", action: #selector(openMenuItemClicked), keyEquivalent: "") + openItem.target = self + menu.addItem(openItem) + + menu.addItem(NSMenuItem.separator()) + + let quitItem = NSMenuItem(title: "Quit", action: #selector(quitMenuItemClicked), keyEquivalent: "q") + quitItem.target = self + menu.addItem(quitItem) + + statusItem?.menu = menu + button.performClick(nil) + statusItem?.menu = nil + } + + @objc private func openMenuItemClicked() { + showPanel() + } + + @objc private func quitMenuItemClicked() { + NSApp.terminate(nil) + } } From 47fa35b3d0f76d5aab0def0f43463b51fc285b51 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:37:57 -1000 Subject: [PATCH 05/24] fix(formats): fix formatting --- Sources/AppFaders/MenuBarController.swift | 17 ++++++++++++--- Sources/AppFadersCore/HostProtocol.swift | 2 +- Sources/AppFadersCore/TrackedApp.swift | 4 +++- Sources/AppFadersDriver/DriverEntry.swift | 6 +++--- .../AppFadersDriver/PassthroughEngine.swift | 2 +- Sources/AppFadersDriver/VirtualDevice.swift | 21 ++++++++++++------- Sources/AppFadersDriver/VirtualStream.swift | 2 +- .../AppFadersDriverTests.swift | 2 +- Tests/AppFadersTests/AppFadersTests.swift | 2 +- 9 files changed, 38 insertions(+), 20 deletions(-) diff --git a/Sources/AppFaders/MenuBarController.swift b/Sources/AppFaders/MenuBarController.swift index 6adc54a..4d64c1c 100644 --- a/Sources/AppFaders/MenuBarController.swift +++ b/Sources/AppFaders/MenuBarController.swift @@ -44,7 +44,10 @@ final class MenuBarController: NSObject { } // SF Symbol for menu bar icon - if let image = NSImage(systemSymbolName: "slider.vertical.3", accessibilityDescription: "AppFaders") { + if let image = NSImage( + systemSymbolName: "slider.vertical.3", + accessibilityDescription: "AppFaders" + ) { image.isTemplate = true button.image = image } else { @@ -72,13 +75,21 @@ final class MenuBarController: NSObject { private func showContextMenu(for button: NSStatusBarButton) { let menu = NSMenu() - let openItem = NSMenuItem(title: "Open", action: #selector(openMenuItemClicked), keyEquivalent: "") + let openItem = NSMenuItem( + title: "Open", + action: #selector(openMenuItemClicked), + keyEquivalent: "" + ) openItem.target = self menu.addItem(openItem) menu.addItem(NSMenuItem.separator()) - let quitItem = NSMenuItem(title: "Quit", action: #selector(quitMenuItemClicked), keyEquivalent: "q") + let quitItem = NSMenuItem( + title: "Quit", + action: #selector(quitMenuItemClicked), + keyEquivalent: "q" + ) quitItem.target = self menu.addItem(quitItem) diff --git a/Sources/AppFadersCore/HostProtocol.swift b/Sources/AppFadersCore/HostProtocol.swift index 9d2e0cf..ae209f2 100644 --- a/Sources/AppFadersCore/HostProtocol.swift +++ b/Sources/AppFadersCore/HostProtocol.swift @@ -1,6 +1,6 @@ import Foundation -// NOTE: Must match AppFadersHelper/XPCProtocols.swift exactly +/// NOTE: Must match AppFadersHelper/XPCProtocols.swift exactly /// Protocol for host app connections (read-write) @objc public protocol AppFadersHostProtocol { func setVolume(bundleID: String, volume: Float, reply: @escaping (NSError?) -> Void) diff --git a/Sources/AppFadersCore/TrackedApp.swift b/Sources/AppFadersCore/TrackedApp.swift index 22305af..f58bd5e 100644 --- a/Sources/AppFadersCore/TrackedApp.swift +++ b/Sources/AppFadersCore/TrackedApp.swift @@ -3,7 +3,9 @@ import Foundation /// Application tracked by the host orchestrator public struct TrackedApp: Identifiable, Sendable, Hashable { - public var id: String { bundleID } + public var id: String { + bundleID + } public let bundleID: String public let localizedName: String diff --git a/Sources/AppFadersDriver/DriverEntry.swift b/Sources/AppFadersDriver/DriverEntry.swift index 1e8526e..bbe0164 100644 --- a/Sources/AppFadersDriver/DriverEntry.swift +++ b/Sources/AppFadersDriver/DriverEntry.swift @@ -101,13 +101,13 @@ final class DriverEntry: @unchecked Sendable { // MARK: - C Interface Exports -// called from PlugInInterface.c Initialize() +/// called from PlugInInterface.c Initialize() @_cdecl("AppFadersDriver_Initialize") public func driverInitialize(host: AudioServerPlugInHostRef) -> OSStatus { DriverEntry.shared.initialize(host: host) } -// called from PlugInInterface.c CreateDevice() +/// called from PlugInInterface.c CreateDevice() @_cdecl("AppFadersDriver_CreateDevice") public func driverCreateDevice( description: CFDictionary?, @@ -122,7 +122,7 @@ public func driverCreateDevice( return status } -// called from PlugInInterface.c DestroyDevice() +/// called from PlugInInterface.c DestroyDevice() @_cdecl("AppFadersDriver_DestroyDevice") public func driverDestroyDevice(deviceID: AudioObjectID) -> OSStatus { DriverEntry.shared.destroyDevice(deviceID: deviceID) diff --git a/Sources/AppFadersDriver/PassthroughEngine.swift b/Sources/AppFadersDriver/PassthroughEngine.swift index 30f5c61..d12e40d 100644 --- a/Sources/AppFadersDriver/PassthroughEngine.swift +++ b/Sources/AppFadersDriver/PassthroughEngine.swift @@ -13,7 +13,7 @@ private let log = OSLog( // MARK: - Missing CoreAudio Constants -// HAL plug-in IO operation type - not bridged to Swift +/// HAL plug-in IO operation type - not bridged to Swift private let kAudioServerPlugInIOOperationWriteMix: UInt32 = 2 // MARK: - Ring Buffer diff --git a/Sources/AppFadersDriver/VirtualDevice.swift b/Sources/AppFadersDriver/VirtualDevice.swift index 0913a3c..d014128 100644 --- a/Sources/AppFadersDriver/VirtualDevice.swift +++ b/Sources/AppFadersDriver/VirtualDevice.swift @@ -4,8 +4,8 @@ import os.log // MARK: - Object IDs -// static object IDs for our audio object hierarchy -// these must be unique within the driver and stable across sessions +/// static object IDs for our audio object hierarchy +/// these must be unique within the driver and stable across sessions public enum ObjectID { static let plugIn: AudioObjectID = 1 static let device: AudioObjectID = 2 @@ -15,17 +15,22 @@ public enum ObjectID { // MARK: - Missing CoreAudio Constants -// these HAL-specific constants aren't bridged to Swift +/// these HAL-specific constants aren't bridged to Swift private let kAudioPlugInPropertyResourceBundle = AudioObjectPropertySelector( - fourCharCode("rsrc")) + fourCharCode("rsrc") +) private let kAudioDevicePropertyZeroTimeStampPeriod = AudioObjectPropertySelector( - fourCharCode("ring")) + fourCharCode("ring") +) private let kAudioObjectPropertyCustomPropertyInfoList = AudioObjectPropertySelector( - fourCharCode("cust")) + fourCharCode("cust") +) private let kAudioDevicePropertyControlList = AudioObjectPropertySelector( - fourCharCode("ctrl")) + fourCharCode("ctrl") +) private let kAudioClockDevicePropertyClockDomain = AudioObjectPropertySelector( - fourCharCode("clk#")) + fourCharCode("clk#") +) private func fourCharCode(_ string: String) -> UInt32 { var result: UInt32 = 0 diff --git a/Sources/AppFadersDriver/VirtualStream.swift b/Sources/AppFadersDriver/VirtualStream.swift index 90e9ac1..1505317 100644 --- a/Sources/AppFadersDriver/VirtualStream.swift +++ b/Sources/AppFadersDriver/VirtualStream.swift @@ -26,7 +26,7 @@ final class VirtualStream: @unchecked Sendable { private var isActive: Bool = false private var sampleRate: Float64 = 48000.0 - // supported sample rates + /// supported sample rates let supportedSampleRates: [Float64] = [44100.0, 48000.0, 96000.0] private init() { diff --git a/Tests/AppFadersDriverTests/AppFadersDriverTests.swift b/Tests/AppFadersDriverTests/AppFadersDriverTests.swift index f620bdf..44b3582 100644 --- a/Tests/AppFadersDriverTests/AppFadersDriverTests.swift +++ b/Tests/AppFadersDriverTests/AppFadersDriverTests.swift @@ -2,6 +2,6 @@ import Testing /// Placeholder tests - full implementation in Task 13 -@Test func driverVersionExists() async throws { +@Test func driverVersionExists() { #expect(AppFadersDriver.version == "0.1.0") } diff --git a/Tests/AppFadersTests/AppFadersTests.swift b/Tests/AppFadersTests/AppFadersTests.swift index 26aed1e..7ad0d03 100644 --- a/Tests/AppFadersTests/AppFadersTests.swift +++ b/Tests/AppFadersTests/AppFadersTests.swift @@ -2,7 +2,7 @@ import CAAudioHardware import Testing /// Placeholder tests - full implementation in Tasks 13-14 -@Test func caAudioHardwareImports() async throws { +@Test func caAudioHardwareImports() throws { // Verify CAAudioHardware dependency is properly configured let devices = try AudioDevice.devices #expect(devices.count >= 0) From 87962d1430f2455952b88d72aa61331efdc89608 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:09:49 -1000 Subject: [PATCH 06/24] feat(ui): add NSPanel with placeholder content for menu bar popover - Configure NSPanel as floating, non-activating panel with borderless style - Add PlaceholderPanelView with basic styling and dark/light mode support - Wire panel show/hide to MenuBarController toggle methods --- Sources/AppFaders/MenuBarController.swift | 40 +++++++++++++++++-- .../Views/PlaceholderPanelView.swift | 38 ++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 Sources/AppFaders/Views/PlaceholderPanelView.swift diff --git a/Sources/AppFaders/MenuBarController.swift b/Sources/AppFaders/MenuBarController.swift index 4d64c1c..4f91b31 100644 --- a/Sources/AppFaders/MenuBarController.swift +++ b/Sources/AppFaders/MenuBarController.swift @@ -1,19 +1,22 @@ import AppKit import os.log +import SwiftUI private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "MenuBarController") @MainActor final class MenuBarController: NSObject { private var statusItem: NSStatusItem? + private var panel: NSPanel? private(set) var isPanelVisible = false override init() { super.init() setupStatusItem() + setupPanel() } - // MARK: - Panel Management (placeholder for task 5-6) + // MARK: - Panel Management func togglePanel() { if isPanelVisible { @@ -24,13 +27,44 @@ final class MenuBarController: NSObject { } func showPanel() { + guard let panel else { return } + panel.makeKeyAndOrderFront(nil) isPanelVisible = true - os_log(.debug, log: log, "Panel shown (placeholder)") + os_log(.debug, log: log, "Panel shown") } func hidePanel() { + guard let panel else { return } + panel.orderOut(nil) isPanelVisible = false - os_log(.debug, log: log, "Panel hidden (placeholder)") + os_log(.debug, log: log, "Panel hidden") + } + + // MARK: - Panel Setup + + private func setupPanel() { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 400), + styleMask: [.nonactivatingPanel, .fullSizeContentView, .borderless], + backing: .buffered, + defer: false + ) + + panel.isFloatingPanel = true + panel.level = .floating + panel.hidesOnDeactivate = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = true + panel.titlebarAppearsTransparent = true + panel.titleVisibility = .hidden + + let hostingView = NSHostingView(rootView: PlaceholderPanelView()) + panel.contentView = hostingView + + self.panel = panel + os_log(.info, log: log, "Panel created") } // MARK: - Status Item Setup diff --git a/Sources/AppFaders/Views/PlaceholderPanelView.swift b/Sources/AppFaders/Views/PlaceholderPanelView.swift new file mode 100644 index 0000000..cf448f9 --- /dev/null +++ b/Sources/AppFaders/Views/PlaceholderPanelView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// Placeholder panel view for task 5 - will be replaced by PanelView in later tasks +struct PlaceholderPanelView: View { + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(spacing: 16) { + Text("AppFaders") + .font(.system(size: 20, weight: .semibold)) + + Text("Panel placeholder") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + + Text("Click outside or press Esc to dismiss") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + } + .frame(width: 380) + .padding(20) + .background(panelBackground) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + + private var panelBackground: Color { + colorScheme == .dark ? Color(hex: 0x1E1E1E) : Color(hex: 0xF5F5F5) + } +} + +private extension Color { + init(hex: UInt32) { + let r = Double((hex >> 16) & 0xFF) / 255.0 + let g = Double((hex >> 8) & 0xFF) / 255.0 + let b = Double(hex & 0xFF) / 255.0 + self.init(red: r, green: g, blue: b) + } +} From 6ee9d33a231924e586593d95b05291e6a9d1662d Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:12:00 -1000 Subject: [PATCH 07/24] feat(ui): add panel positioning and dismiss behavior Position panel below menu bar status item with screen bounds checking. Add event monitors to dismiss panel on click outside or Escape key press. --- Sources/AppFaders/MenuBarController.swift | 93 +++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/Sources/AppFaders/MenuBarController.swift b/Sources/AppFaders/MenuBarController.swift index 4f91b31..2209480 100644 --- a/Sources/AppFaders/MenuBarController.swift +++ b/Sources/AppFaders/MenuBarController.swift @@ -10,6 +10,9 @@ final class MenuBarController: NSObject { private var panel: NSPanel? private(set) var isPanelVisible = false + private var clickOutsideMonitor: Any? + private var escapeKeyMonitor: Any? + override init() { super.init() setupStatusItem() @@ -28,18 +31,108 @@ final class MenuBarController: NSObject { func showPanel() { guard let panel else { return } + + positionPanelBelowStatusItem() panel.makeKeyAndOrderFront(nil) isPanelVisible = true + addEventMonitors() os_log(.debug, log: log, "Panel shown") } func hidePanel() { guard let panel else { return } + + removeEventMonitors() panel.orderOut(nil) isPanelVisible = false os_log(.debug, log: log, "Panel hidden") } + // MARK: - Panel Positioning + + private func positionPanelBelowStatusItem() { + guard let panel, + let button = statusItem?.button, + let buttonWindow = button.window + else { return } + + let buttonFrame = buttonWindow.frame + let panelSize = panel.frame.size + + // Center panel horizontally below the status item button + let panelX = buttonFrame.midX - (panelSize.width / 2) + // Position panel just below the menu bar + let panelY = buttonFrame.minY - panelSize.height + + // Ensure panel stays on screen + if let screen = buttonWindow.screen { + let screenFrame = screen.visibleFrame + let adjustedX = max(screenFrame.minX, min(panelX, screenFrame.maxX - panelSize.width)) + panel.setFrameOrigin(NSPoint(x: adjustedX, y: panelY)) + } else { + panel.setFrameOrigin(NSPoint(x: panelX, y: panelY)) + } + } + + // MARK: - Event Monitors + + private func addEventMonitors() { + // Click outside to dismiss + clickOutsideMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown) { + [weak self] event in + guard let self else { return } + Task { @MainActor in + self.handleClickOutside(event) + } + } + + // Escape key to dismiss + escapeKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { + [weak self] event in + guard let self else { return event } + if event.keyCode == 53 { // Escape key + Task { @MainActor in + self.hidePanel() + } + return nil // Consume the event + } + return event + } + } + + private func removeEventMonitors() { + if let monitor = clickOutsideMonitor { + NSEvent.removeMonitor(monitor) + clickOutsideMonitor = nil + } + if let monitor = escapeKeyMonitor { + NSEvent.removeMonitor(monitor) + escapeKeyMonitor = nil + } + } + + private func handleClickOutside(_ event: NSEvent) { + guard let panel, isPanelVisible else { return } + + // For global events, locationInWindow is screen coordinates + let clickLocation = event.locationInWindow + + // Ignore clicks on the status item - let togglePanel handle those + if let button = statusItem?.button, + let buttonWindow = button.window { + let buttonFrame = buttonWindow.frame + if buttonFrame.contains(clickLocation) { + return + } + } + + // Check if click is outside the panel + let panelFrame = panel.frame + if !panelFrame.contains(clickLocation) { + hidePanel() + } + } + // MARK: - Panel Setup private func setupPanel() { From 2cb04210d0e0e9a2a719037b34649931fdc901d7 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 17:45:59 -0800 Subject: [PATCH 08/24] feat(state): add AppState with system volume control via CAAudioHardware --- Sources/AppFaders/AppState.swift | 164 ++++++++++++++++++++++++++ Sources/AppFaders/DeviceManager.swift | 62 ++++++++++ 2 files changed, 226 insertions(+) create mode 100644 Sources/AppFaders/AppState.swift diff --git a/Sources/AppFaders/AppState.swift b/Sources/AppFaders/AppState.swift new file mode 100644 index 0000000..84eb1f6 --- /dev/null +++ b/Sources/AppFaders/AppState.swift @@ -0,0 +1,164 @@ +import AppFadersCore +import AppKit +import Foundation +import Observation +import os.log + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "AppState") + +// MARK: - AppVolumeState + +/// Represents the volume state for a single application +/// Note: @unchecked Sendable because NSImage isn't Sendable, but this struct +/// is only used within @MainActor context (AppState) +struct AppVolumeState: Identifiable, @unchecked Sendable { + let id: String // bundleID + let name: String + let icon: NSImage? + var volume: Float // 0.0-1.0 + var isMuted: Bool + var previousVolume: Float // for restore on unmute + + var displayPercentage: String { + isMuted ? "Muted" : "\(Int(volume * 100))%" + } + + init(from trackedApp: TrackedApp, volume: Float, isMuted: Bool = false) { + id = trackedApp.bundleID + name = trackedApp.localizedName + icon = trackedApp.icon + self.volume = volume + self.isMuted = isMuted + previousVolume = volume + } +} + +// MARK: - AppState + +/// Central state container driving SwiftUI updates +@MainActor +@Observable +final class AppState { + private(set) var apps: [AppVolumeState] = [] + var masterVolume: Float = 1.0 + var masterMuted: Bool = false + var isPanelVisible: Bool = false + var connectionError: String? + + private let orchestrator: AudioOrchestrator + private let deviceManager: DeviceManager + + init(orchestrator: AudioOrchestrator, deviceManager: DeviceManager) { + self.orchestrator = orchestrator + self.deviceManager = deviceManager + + // Initialize master volume from system + masterVolume = deviceManager.getSystemVolume() + masterMuted = deviceManager.getSystemMute() + + os_log(.info, log: log, "AppState initialized") + } + + // MARK: - Per-App Volume Control + + /// Sets the volume for a specific application + func setVolume(for bundleID: String, volume: Float) async { + guard let index = apps.firstIndex(where: { $0.id == bundleID }) else { return } + + let clamped = max(0.0, min(1.0, volume)) + apps[index].volume = clamped + + // If setting volume while muted, unmute + if apps[index].isMuted, clamped > 0 { + apps[index].isMuted = false + } + + await orchestrator.setVolume(for: bundleID, volume: clamped) + } + + /// Toggles mute state for a specific application + func toggleMute(for bundleID: String) async { + guard let index = apps.firstIndex(where: { $0.id == bundleID }) else { return } + + if apps[index].isMuted { + // Unmute: restore previous volume + apps[index].isMuted = false + apps[index].volume = apps[index].previousVolume + await orchestrator.setVolume(for: bundleID, volume: apps[index].previousVolume) + } else { + // Mute: store current volume, set to 0 + apps[index].previousVolume = apps[index].volume + apps[index].isMuted = true + apps[index].volume = 0 + await orchestrator.setVolume(for: bundleID, volume: 0) + } + } + + // MARK: - Master Volume Control + + /// Sets the master (system) volume + func setMasterVolume(_ volume: Float) { + let clamped = max(0.0, min(1.0, volume)) + masterVolume = clamped + + // If setting volume while muted, unmute + if masterMuted, clamped > 0 { + masterMuted = false + deviceManager.setSystemMute(false) + } + + deviceManager.setSystemVolume(clamped) + } + + /// Toggles master (system) mute + func toggleMasterMute() { + masterMuted.toggle() + deviceManager.setSystemMute(masterMuted) + } + + // MARK: - Sync from Orchestrator + + /// Syncs app list from AudioOrchestrator's tracked apps and volumes + func syncFromOrchestrator() { + let trackedApps = orchestrator.trackedApps + let volumes = orchestrator.appVolumes + + // Build new app states, preserving mute state for existing apps + var newApps: [AppVolumeState] = [] + for trackedApp in trackedApps { + let volume = volumes[trackedApp.bundleID] ?? 1.0 + + // Check if we have existing state for this app (preserve mute) + if let existing = apps.first(where: { $0.id == trackedApp.bundleID }) { + var updated = AppVolumeState(from: trackedApp, volume: volume, isMuted: existing.isMuted) + updated.previousVolume = existing.previousVolume + // If muted, keep showing 0 volume + if existing.isMuted { + updated.volume = 0 + } + newApps.append(updated) + } else { + newApps.append(AppVolumeState(from: trackedApp, volume: volume)) + } + } + + apps = newApps + + // Update connection error status + connectionError = orchestrator.isDriverConnected ? nil : "Helper service not connected" + } + + /// Refreshes master volume from system (call when panel opens) + func refreshMasterVolume() { + masterVolume = deviceManager.getSystemVolume() + masterMuted = deviceManager.getSystemMute() + } + + // MARK: - App Lifecycle + + /// Terminates the application + func quit() { + os_log(.info, log: log, "Quit requested via AppState") + NSApp.terminate(nil) + } +} diff --git a/Sources/AppFaders/DeviceManager.swift b/Sources/AppFaders/DeviceManager.swift index 6cada98..13aed7e 100644 --- a/Sources/AppFaders/DeviceManager.swift +++ b/Sources/AppFaders/DeviceManager.swift @@ -55,6 +55,68 @@ final class DeviceManager: Sendable { } } + /// returns the default system output device + var defaultOutputDevice: AudioDevice? { + try? AudioDevice.defaultOutputDevice + } + + // MARK: - System Volume Control + + /// gets the current system output volume (0.0-1.0) + func getSystemVolume() -> Float { + guard let device = defaultOutputDevice else { + os_log(.error, log: log, "No default output device for getSystemVolume") + return 1.0 + } + do { + return try device.volumeScalar(inScope: .output) + } catch { + os_log(.error, log: log, "Failed to get system volume: %@", error as CVarArg) + return 1.0 + } + } + + /// sets the system output volume (0.0-1.0) + func setSystemVolume(_ volume: Float) { + guard let device = defaultOutputDevice else { + os_log(.error, log: log, "No default output device for setSystemVolume") + return + } + let clamped = max(0.0, min(1.0, volume)) + do { + try device.setVolumeScalar(clamped, inScope: .output) + } catch { + os_log(.error, log: log, "Failed to set system volume: %@", error as CVarArg) + } + } + + /// gets the current system mute state + func getSystemMute() -> Bool { + guard let device = defaultOutputDevice else { + os_log(.error, log: log, "No default output device for getSystemMute") + return false + } + do { + return try device.mute(inScope: .output) + } catch { + os_log(.error, log: log, "Failed to get system mute: %@", error as CVarArg) + return false + } + } + + /// sets the system mute state + func setSystemMute(_ muted: Bool) { + guard let device = defaultOutputDevice else { + os_log(.error, log: log, "No default output device for setSystemMute") + return + } + do { + try device.setMute(muted, inScope: .output) + } catch { + os_log(.error, log: log, "Failed to set system mute: %@", error as CVarArg) + } + } + init() { os_log(.info, log: log, "DeviceManager initialized") } From dfe1109d890739895922d40f09ffb27bddfd91f9 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 17:48:46 -0800 Subject: [PATCH 09/24] chore: cleanup comments and formats --- Sources/AppFaders/AppDelegate.swift | 5 ----- Sources/AppFaders/AppFadersApp.swift | 1 + Sources/AppFaders/MenuBarController.swift | 9 ++++----- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/Sources/AppFaders/AppDelegate.swift b/Sources/AppFaders/AppDelegate.swift index 898d399..cfc6282 100644 --- a/Sources/AppFaders/AppDelegate.swift +++ b/Sources/AppFaders/AppDelegate.swift @@ -13,13 +13,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { os_log(.info, log: log, "AppFaders launching") - // Menu bar only - no dock icon NSApp.setActivationPolicy(.accessory) - - // Create menu bar controller (panel toggle placeholder for now) menuBarController = MenuBarController() - - // Start orchestrator in background task orchestratorTask = Task { await orchestrator.start() } diff --git a/Sources/AppFaders/AppFadersApp.swift b/Sources/AppFaders/AppFadersApp.swift index c653f82..18c9cf6 100644 --- a/Sources/AppFaders/AppFadersApp.swift +++ b/Sources/AppFaders/AppFadersApp.swift @@ -6,6 +6,7 @@ struct AppFadersApp { let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate + // run() blocks until app terminates, keeping delegate alive app.run() } diff --git a/Sources/AppFaders/MenuBarController.swift b/Sources/AppFaders/MenuBarController.swift index 2209480..dabecd4 100644 --- a/Sources/AppFaders/MenuBarController.swift +++ b/Sources/AppFaders/MenuBarController.swift @@ -90,11 +90,11 @@ final class MenuBarController: NSObject { escapeKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self else { return event } - if event.keyCode == 53 { // Escape key + if event.keyCode == 53 { // Escape key Task { @MainActor in self.hidePanel() } - return nil // Consume the event + return nil // Consume the event } return event } @@ -119,7 +119,8 @@ final class MenuBarController: NSObject { // Ignore clicks on the status item - let togglePanel handle those if let button = statusItem?.button, - let buttonWindow = button.window { + let buttonWindow = button.window + { let buttonFrame = buttonWindow.frame if buttonFrame.contains(clickLocation) { return @@ -170,7 +171,6 @@ final class MenuBarController: NSObject { return } - // SF Symbol for menu bar icon if let image = NSImage( systemSymbolName: "slider.vertical.3", accessibilityDescription: "AppFaders" @@ -181,7 +181,6 @@ final class MenuBarController: NSObject { os_log(.error, log: log, "Failed to load SF Symbol 'slider.vertical.3'") } - // Handle both left and right clicks button.target = self button.action = #selector(statusItemClicked(_:)) button.sendAction(on: [.leftMouseUp, .rightMouseUp]) From 6a778fc225e903306d016bf992e4c33284ea164f Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:18:04 -0800 Subject: [PATCH 10/24] feat(desktop-ui): add VolumeSlider component with asset catalog colors - Custom SwiftUI slider matching Pencil design specs. - Large (300px) and small (200px) variants with DragGesture control. - Asset catalog provides light/dark mode colors for all UI components. --- Package.swift | 3 + .../AppFaders/Components/VolumeSlider.swift | 96 +++++++++++++++++++ .../Accent.colorset/Contents.json | 20 ++++ .../Resources/Colors.xcassets/Contents.json | 6 ++ .../Divider.colorset/Contents.json | 44 +++++++++ .../PanelBackground.colorset/Contents.json | 44 +++++++++ .../PrimaryText.colorset/Contents.json | 44 +++++++++ .../SecondaryText.colorset/Contents.json | 44 +++++++++ .../SliderThumb.colorset/Contents.json | 44 +++++++++ .../SliderTrack.colorset/Contents.json | 44 +++++++++ .../TertiaryText.colorset/Contents.json | 44 +++++++++ 11 files changed, 433 insertions(+) create mode 100644 Sources/AppFaders/Components/VolumeSlider.swift create mode 100644 Sources/AppFaders/Resources/Colors.xcassets/Accent.colorset/Contents.json create mode 100644 Sources/AppFaders/Resources/Colors.xcassets/Contents.json create mode 100644 Sources/AppFaders/Resources/Colors.xcassets/Divider.colorset/Contents.json create mode 100644 Sources/AppFaders/Resources/Colors.xcassets/PanelBackground.colorset/Contents.json create mode 100644 Sources/AppFaders/Resources/Colors.xcassets/PrimaryText.colorset/Contents.json create mode 100644 Sources/AppFaders/Resources/Colors.xcassets/SecondaryText.colorset/Contents.json create mode 100644 Sources/AppFaders/Resources/Colors.xcassets/SliderThumb.colorset/Contents.json create mode 100644 Sources/AppFaders/Resources/Colors.xcassets/SliderTrack.colorset/Contents.json create mode 100644 Sources/AppFaders/Resources/Colors.xcassets/TertiaryText.colorset/Contents.json diff --git a/Package.swift b/Package.swift index a4f531f..9cd3a3e 100644 --- a/Package.swift +++ b/Package.swift @@ -27,6 +27,9 @@ let package = Package( dependencies: [ "AppFadersCore", .product(name: "CAAudioHardware", package: "CAAudioHardware") + ], + resources: [ + .process("Resources") ] ), .executableTarget( diff --git a/Sources/AppFaders/Components/VolumeSlider.swift b/Sources/AppFaders/Components/VolumeSlider.swift new file mode 100644 index 0000000..2635921 --- /dev/null +++ b/Sources/AppFaders/Components/VolumeSlider.swift @@ -0,0 +1,96 @@ +import SwiftUI + +/// Slider size variants per Row +enum SliderSize { + case large // Master volume + case small // App rows + + /// Track width in points + var trackWidth: CGFloat { + switch self { + case .large: 300 + case .small: 200 + } + } + + /// Track height in points + var trackHeight: CGFloat { + 4 + } + + /// Thumb circle diameter in points + var thumbDiameter: CGFloat { + switch self { + case .large: 20 + case .small: 16 + } + } + + /// Total frame height (includes vertical padding) + var frameHeight: CGFloat { + switch self { + case .large: 24 + case .small: 16 + } + } +} + +/// Custom volume slider matching Pencil design specs +struct VolumeSlider: View { + @Binding var value: Float + var size: SliderSize = .large + + private let trackColor = Color("SliderTrack", bundle: .module) + private let thumbColor = Color("SliderThumb", bundle: .module) + + var body: some View { + GeometryReader { geometry in + let trackWidth = min(geometry.size.width, size.trackWidth) + let thumbRadius = size.thumbDiameter / 2 + let usableWidth = trackWidth - size.thumbDiameter + let thumbX = thumbRadius + CGFloat(value) * usableWidth + + ZStack(alignment: .leading) { + // Track + RoundedRectangle(cornerRadius: 2) + .fill(trackColor) + .frame(width: trackWidth, height: size.trackHeight) + + // Thumb + Circle() + .fill(thumbColor) + .frame(width: size.thumbDiameter, height: size.thumbDiameter) + .offset(x: thumbX - thumbRadius) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { gesture in + let newValue = (gesture.location.x - thumbRadius) / usableWidth + value = Float(max(0, min(1, newValue))) + } + ) + } + .frame(width: trackWidth, height: size.frameHeight) + } + .frame(width: size.trackWidth, height: size.frameHeight) + } +} + +// MARK: - Previews + +#Preview("Large Slider - Light") { + VolumeSlider(value: .constant(0.7), size: .large) + .padding() + .preferredColorScheme(.light) +} + +#Preview("Large Slider - Dark") { + VolumeSlider(value: .constant(0.7), size: .large) + .padding() + .preferredColorScheme(.dark) +} + +#Preview("Small Slider - Dark") { + VolumeSlider(value: .constant(0.5), size: .small) + .padding() + .preferredColorScheme(.dark) +} diff --git a/Sources/AppFaders/Resources/Colors.xcassets/Accent.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/Accent.colorset/Contents.json new file mode 100644 index 0000000..77db0ea --- /dev/null +++ b/Sources/AppFaders/Resources/Colors.xcassets/Accent.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.898", + "green": "0.224", + "blue": "0.208", + "alpha": "1.000" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/Sources/AppFaders/Resources/Colors.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/Divider.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/Divider.colorset/Contents.json new file mode 100644 index 0000000..df1d2af --- /dev/null +++ b/Sources/AppFaders/Resources/Colors.xcassets/Divider.colorset/Contents.json @@ -0,0 +1,44 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.867", + "green": "0.867", + "blue": "0.867", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.200", + "green": "0.200", + "blue": "0.200", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/PanelBackground.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/PanelBackground.colorset/Contents.json new file mode 100644 index 0000000..9162cc2 --- /dev/null +++ b/Sources/AppFaders/Resources/Colors.xcassets/PanelBackground.colorset/Contents.json @@ -0,0 +1,44 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.961", + "green": "0.961", + "blue": "0.961", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.118", + "green": "0.118", + "blue": "0.118", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/PrimaryText.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/PrimaryText.colorset/Contents.json new file mode 100644 index 0000000..ea82934 --- /dev/null +++ b/Sources/AppFaders/Resources/Colors.xcassets/PrimaryText.colorset/Contents.json @@ -0,0 +1,44 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.118", + "green": "0.118", + "blue": "0.118", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "color": { + "color-space": "srgb", + "components": { + "red": "1.000", + "green": "1.000", + "blue": "1.000", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/SecondaryText.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/SecondaryText.colorset/Contents.json new file mode 100644 index 0000000..f1a4832 --- /dev/null +++ b/Sources/AppFaders/Resources/Colors.xcassets/SecondaryText.colorset/Contents.json @@ -0,0 +1,44 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.400", + "green": "0.400", + "blue": "0.400", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.533", + "green": "0.533", + "blue": "0.533", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/SliderThumb.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/SliderThumb.colorset/Contents.json new file mode 100644 index 0000000..ea82934 --- /dev/null +++ b/Sources/AppFaders/Resources/Colors.xcassets/SliderThumb.colorset/Contents.json @@ -0,0 +1,44 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.118", + "green": "0.118", + "blue": "0.118", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "color": { + "color-space": "srgb", + "components": { + "red": "1.000", + "green": "1.000", + "blue": "1.000", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/SliderTrack.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/SliderTrack.colorset/Contents.json new file mode 100644 index 0000000..e39b089 --- /dev/null +++ b/Sources/AppFaders/Resources/Colors.xcassets/SliderTrack.colorset/Contents.json @@ -0,0 +1,44 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.800", + "green": "0.800", + "blue": "0.800", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.290", + "green": "0.290", + "blue": "0.290", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/TertiaryText.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/TertiaryText.colorset/Contents.json new file mode 100644 index 0000000..d2f76c0 --- /dev/null +++ b/Sources/AppFaders/Resources/Colors.xcassets/TertiaryText.colorset/Contents.json @@ -0,0 +1,44 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.600", + "green": "0.600", + "blue": "0.600", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ] + }, + { + "color": { + "color-space": "srgb", + "components": { + "red": "0.400", + "green": "0.400", + "blue": "0.400", + "alpha": "1.000" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file From 87521698e060a846ee3a09550c5392408aed82d4 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:27:55 -0800 Subject: [PATCH 11/24] feat(desktop-ui): add MuteButton component with speaker toggle icons - SF Symbol icons: speaker.wave.2.fill / speaker.slash.fill - asset catalog colors for automatic light/dark mode adaptation. --- Sources/AppFaders/Components/MuteButton.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 Sources/AppFaders/Components/MuteButton.swift diff --git a/Sources/AppFaders/Components/MuteButton.swift b/Sources/AppFaders/Components/MuteButton.swift new file mode 100644 index 0000000..ffd7f40 --- /dev/null +++ b/Sources/AppFaders/Components/MuteButton.swift @@ -0,0 +1,47 @@ +import SwiftUI + +/// Clickable speaker/muted icon toggle matching Pencil design (tLYj9, u847K) +struct MuteButton: View { + let isMuted: Bool + let onToggle: () -> Void + + private let speakerColor = Color("SliderThumb", bundle: .module) + private let mutedColor = Color("Accent", bundle: .module) + + var body: some View { + Button(action: onToggle) { + Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") + .font(.system(size: 18)) + .foregroundStyle(isMuted ? mutedColor : speakerColor) + .frame(width: 23, height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +// MARK: - Previews + +#Preview("Unmuted - Light") { + MuteButton(isMuted: false, onToggle: {}) + .padding() + .preferredColorScheme(.light) +} + +#Preview("Muted - Light") { + MuteButton(isMuted: true, onToggle: {}) + .padding() + .preferredColorScheme(.light) +} + +#Preview("Unmuted - Dark") { + MuteButton(isMuted: false, onToggle: {}) + .padding() + .preferredColorScheme(.dark) +} + +#Preview("Muted - Dark") { + MuteButton(isMuted: true, onToggle: {}) + .padding() + .preferredColorScheme(.dark) +} From 12db8a326881d2736319727c0c23dd12941a2150 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:28:54 -0800 Subject: [PATCH 12/24] feat(desktop-ui): add HeaderView and FooterView components - Panel chrome with title, settings icon placeholder, version label, and quit button. - Uses asset catalog colors for light/dark mode. --- Sources/AppFaders/Views/FooterView.swift | 43 ++++++++++++++++++++++++ Sources/AppFaders/Views/HeaderView.swift | 38 +++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 Sources/AppFaders/Views/FooterView.swift create mode 100644 Sources/AppFaders/Views/HeaderView.swift diff --git a/Sources/AppFaders/Views/FooterView.swift b/Sources/AppFaders/Views/FooterView.swift new file mode 100644 index 0000000..525443c --- /dev/null +++ b/Sources/AppFaders/Views/FooterView.swift @@ -0,0 +1,43 @@ +import SwiftUI + +/// Panel footer with version and quit button matching Pencil design (SCxSk) +struct FooterView: View { + let onQuit: () -> Void + + private let tertiaryText = Color("TertiaryText", bundle: .module) + private let accentColor = Color("Accent", bundle: .module) + + var body: some View { + HStack { + Text("V1.0.0 ALPHA") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(tertiaryText) + + Spacer() + + Button(action: onQuit) { + Text("Quit") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(accentColor) + } + .buttonStyle(.plain) + } + .frame(height: 40) + } +} + +// MARK: - Previews + +#Preview("Footer - Light") { + FooterView(onQuit: {}) + .padding() + .background(Color("PanelBackground", bundle: .module)) + .preferredColorScheme(.light) +} + +#Preview("Footer - Dark") { + FooterView(onQuit: {}) + .padding() + .background(Color("PanelBackground", bundle: .module)) + .preferredColorScheme(.dark) +} diff --git a/Sources/AppFaders/Views/HeaderView.swift b/Sources/AppFaders/Views/HeaderView.swift new file mode 100644 index 0000000..3a02e6a --- /dev/null +++ b/Sources/AppFaders/Views/HeaderView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// Panel header with title and settings icon matching Pencil design (7tlKE) +struct HeaderView: View { + private let primaryText = Color("PrimaryText", bundle: .module) + private let secondaryText = Color("SecondaryText", bundle: .module) + + var body: some View { + HStack { + Text("AppFaders") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(primaryText) + + Spacer() + + Image(systemName: "gear") + .font(.system(size: 18)) + .foregroundStyle(secondaryText) + } + .frame(height: 40) + } +} + +// MARK: - Previews + +#Preview("Header - Light") { + HeaderView() + .padding() + .background(Color("PanelBackground", bundle: .module)) + .preferredColorScheme(.light) +} + +#Preview("Header - Dark") { + HeaderView() + .padding() + .background(Color("PanelBackground", bundle: .module)) + .preferredColorScheme(.dark) +} From a8505b9f26642920fcbbcfa5d36789e62336b03c Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:36:23 -0800 Subject: [PATCH 13/24] feat(desktop-ui): add MasterVolumeView component - header row with "MASTER OUTPUT" label and percentage display. - slider row with large VolumeSlider and MuteButton. --- .../AppFaders/Views/MasterVolumeView.swift | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 Sources/AppFaders/Views/MasterVolumeView.swift diff --git a/Sources/AppFaders/Views/MasterVolumeView.swift b/Sources/AppFaders/Views/MasterVolumeView.swift new file mode 100644 index 0000000..5664e84 --- /dev/null +++ b/Sources/AppFaders/Views/MasterVolumeView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +/// Master volume control section +struct MasterVolumeView: View { + @Binding var volume: Float + let isMuted: Bool + let onMuteToggle: () -> Void + + private let secondaryText = Color("SecondaryText", bundle: .module) + + var body: some View { + VStack(spacing: 16) { + // Header row: label + percentage + HStack { + Text("MASTER OUTPUT") + .font(.system(size: 11, weight: .bold)) + .tracking(1.5) + .foregroundStyle(secondaryText) + + Spacer() + + Text(isMuted ? "Muted" : "\(Int(volume * 100))%") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(isMuted ? Color("Accent", bundle: .module) : secondaryText) + } + + // Slider row: slider + mute button + HStack(spacing: 12) { + VolumeSlider(value: $volume, size: .large) + + MuteButton(isMuted: isMuted, onToggle: onMuteToggle) + } + } + .padding(.vertical, 20) + } +} + +// MARK: - Previews + +#Preview("Master - Light") { + MasterVolumeView(volume: .constant(0.85), isMuted: false, onMuteToggle: {}) + .padding(.horizontal, 20) + .background(Color("PanelBackground", bundle: .module)) + .preferredColorScheme(.light) +} + +#Preview("Master - Dark") { + MasterVolumeView(volume: .constant(0.85), isMuted: false, onMuteToggle: {}) + .padding(.horizontal, 20) + .background(Color("PanelBackground", bundle: .module)) + .preferredColorScheme(.dark) +} + +#Preview("Master Muted - Dark") { + MasterVolumeView(volume: .constant(0.0), isMuted: true, onMuteToggle: {}) + .padding(.horizontal, 20) + .background(Color("PanelBackground", bundle: .module)) + .preferredColorScheme(.dark) +} From 0def3f9ac1963719ce0f13faf49d05d169201c1a Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:37:54 -0800 Subject: [PATCH 14/24] feat(desktop-ui): add AppRowView component - added volume control row with icon, name/percentage display, small VolumeSlider, and MuteButton. - added memberwise init for AppVolumeState to support previews. --- Sources/AppFaders/AppState.swift | 17 ++++ Sources/AppFaders/Views/AppRowView.swift | 115 +++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 Sources/AppFaders/Views/AppRowView.swift diff --git a/Sources/AppFaders/AppState.swift b/Sources/AppFaders/AppState.swift index 84eb1f6..5ea639a 100644 --- a/Sources/AppFaders/AppState.swift +++ b/Sources/AppFaders/AppState.swift @@ -31,6 +31,23 @@ struct AppVolumeState: Identifiable, @unchecked Sendable { self.isMuted = isMuted previousVolume = volume } + + /// Memberwise initializer for previews and testing + init( + id: String, + name: String, + icon: NSImage?, + volume: Float, + isMuted: Bool, + previousVolume: Float + ) { + self.id = id + self.name = name + self.icon = icon + self.volume = volume + self.isMuted = isMuted + self.previousVolume = previousVolume + } } // MARK: - AppState diff --git a/Sources/AppFaders/Views/AppRowView.swift b/Sources/AppFaders/Views/AppRowView.swift new file mode 100644 index 0000000..91c0a87 --- /dev/null +++ b/Sources/AppFaders/Views/AppRowView.swift @@ -0,0 +1,115 @@ +import AppKit +import SwiftUI + +/// Per-application volume control row matching Pencil design (SZAXz, MBi9g) +struct AppRowView: View { + let app: AppVolumeState + @Binding var volume: Float + let onMuteToggle: () -> Void + + private let primaryText = Color("PrimaryText", bundle: .module) + private let secondaryText = Color("SecondaryText", bundle: .module) + private let accentColor = Color("Accent", bundle: .module) + + var body: some View { + HStack(spacing: 16) { + // App icon + appIcon + .frame(width: 48, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + // Content: name row + slider + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(app.name) + .font(.system(size: 16)) + .foregroundStyle(primaryText) + .lineLimit(1) + + Spacer() + + Text(app.isMuted ? "Muted" : "\(Int(app.volume * 100))%") + .font(.system(size: 14)) + .foregroundStyle(app.isMuted ? accentColor : secondaryText) + } + + VolumeSlider(value: $volume, size: .small) + } + + // Mute button + MuteButton(isMuted: app.isMuted, onToggle: onMuteToggle) + } + .padding(.vertical, 12) + } + + @ViewBuilder + private var appIcon: some View { + if let nsImage = app.icon { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "app.fill") + .font(.system(size: 32)) + .foregroundStyle(secondaryText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color("Divider", bundle: .module)) + } + } +} + +// MARK: - Previews + +#Preview("App Row - Dark") { + AppRowView( + app: AppVolumeState( + id: "com.apple.music", + name: "Music", + icon: NSImage(systemSymbolName: "music.note", accessibilityDescription: nil), + volume: 0.75, + isMuted: false, + previousVolume: 0.75 + ), + volume: .constant(0.75), + onMuteToggle: {} + ) + .padding(.horizontal, 20) + .background(Color("PanelBackground", bundle: .module)) + .preferredColorScheme(.dark) +} + +#Preview("App Row Muted - Dark") { + AppRowView( + app: AppVolumeState( + id: "com.apple.music", + name: "Music", + icon: NSImage(systemSymbolName: "music.note", accessibilityDescription: nil), + volume: 0.0, + isMuted: true, + previousVolume: 0.75 + ), + volume: .constant(0.0), + onMuteToggle: {} + ) + .padding(.horizontal, 20) + .background(Color("PanelBackground", bundle: .module)) + .preferredColorScheme(.dark) +} + +#Preview("App Row No Icon - Light") { + AppRowView( + app: AppVolumeState( + id: "com.unknown.app", + name: "Unknown App", + icon: nil, + volume: 0.5, + isMuted: false, + previousVolume: 0.5 + ), + volume: .constant(0.5), + onMuteToggle: {} + ) + .padding(.horizontal, 20) + .background(Color("PanelBackground", bundle: .module)) + .preferredColorScheme(.light) +} From dd5aeac5271f288ce5316166b61b586c4e0dfafa Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:46:44 -0800 Subject: [PATCH 15/24] feat(desktop-ui): add PanelView root view - combines HeaderView, dividers, MasterVolumeView, app rows, and FooterView. - added panel styling - uses @Bindable for AppState with async volume bindings. --- Sources/AppFaders/Views/PanelView.swift | 68 +++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 Sources/AppFaders/Views/PanelView.swift diff --git a/Sources/AppFaders/Views/PanelView.swift b/Sources/AppFaders/Views/PanelView.swift new file mode 100644 index 0000000..5eebe41 --- /dev/null +++ b/Sources/AppFaders/Views/PanelView.swift @@ -0,0 +1,68 @@ +import SwiftUI + +/// Root panel +struct PanelView: View { + @Bindable var state: AppState + + private let panelBackground = Color("PanelBackground", bundle: .module) + private let dividerColor = Color("Divider", bundle: .module) + + var body: some View { + VStack(spacing: 0) { + HeaderView() + + Rectangle() + .fill(dividerColor) + .frame(height: 1) + + MasterVolumeView( + volume: $state.masterVolume, + isMuted: state.masterMuted, + onMuteToggle: { state.toggleMasterMute() } + ) + + ForEach(state.apps) { app in + AppRowView( + app: app, + volume: volumeBinding(for: app.id), + onMuteToggle: { Task { await state.toggleMute(for: app.id) } } + ) + } + + Rectangle() + .fill(dividerColor) + .frame(height: 1) + + FooterView(onQuit: { state.quit() }) + } + .padding(20) + .frame(width: 380) + .background(panelBackground) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + + private func volumeBinding(for bundleID: String) -> Binding { + Binding( + get: { state.apps.first { $0.id == bundleID }?.volume ?? 0 }, + set: { newValue in Task { await state.setVolume(for: bundleID, volume: newValue) } } + ) + } +} + +// MARK: - Previews + +#Preview("Panel - Dark") { + let orchestrator = AudioOrchestrator() + let deviceManager = DeviceManager() + let state = AppState(orchestrator: orchestrator, deviceManager: deviceManager) + return PanelView(state: state) + .preferredColorScheme(.dark) +} + +#Preview("Panel - Light") { + let orchestrator = AudioOrchestrator() + let deviceManager = DeviceManager() + let state = AppState(orchestrator: orchestrator, deviceManager: deviceManager) + return PanelView(state: state) + .preferredColorScheme(.light) +} From 092ea8e82c3def47a096d5ef8f8fca735bd170cd Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:21:07 -0800 Subject: [PATCH 16/24] refactor: implement AppColors w mode support - asset catalog wasn't working and I don't have patience to keep debugging right now. --- Sources/AppFaders/Theme/AppColors.swift | 64 +++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 Sources/AppFaders/Theme/AppColors.swift diff --git a/Sources/AppFaders/Theme/AppColors.swift b/Sources/AppFaders/Theme/AppColors.swift new file mode 100644 index 0000000..fcc3789 --- /dev/null +++ b/Sources/AppFaders/Theme/AppColors.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// App color palette w/ light/dark mode. Gave up on asset catalog for now. +enum AppColors { + // MARK: - Panel + + static var panelBackground: Color { + Color(light: rgb(0xF5F5F5), dark: rgb(0x1E1E1E)) + } + + // MARK: - Text + + static var primaryText: Color { + Color(light: rgb(0x1E1E1E), dark: rgb(0xFFFFFF)) + } + + static var secondaryText: Color { + Color(light: rgb(0x666666), dark: rgb(0x888888)) + } + + static var tertiaryText: Color { + Color(light: rgb(0x999999), dark: rgb(0x666666)) + } + + // MARK: - Controls + + static var sliderTrack: Color { + Color(light: rgb(0xCCCCCC), dark: rgb(0x4A4A4A)) + } + + static var sliderThumb: Color { + Color(light: rgb(0x1E1E1E), dark: rgb(0xFFFFFF)) + } + + static var divider: Color { + Color(light: rgb(0xDDDDDD), dark: rgb(0x333333)) + } + + // MARK: - Accent + + static let accent = Color(rgb(0xE53935)) + + // MARK: - Helpers + + private static func rgb(_ hex: UInt32) -> Color { + Color( + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0 + ) + } +} + +// MARK: - Color Extension for Light/Dark + +extension Color { + init(light: Color, dark: Color) { + self.init(nsColor: NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + ? NSColor(dark) + : NSColor(light) + }) + } +} From bce70008182a518c5b04ac8efeed39b3c820e768 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:22:34 -0800 Subject: [PATCH 17/24] fix(desktop-ui): use AppColors and add scrollable app list to PanelView --- Sources/AppFaders/Views/PanelView.swift | 27 +++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/Sources/AppFaders/Views/PanelView.swift b/Sources/AppFaders/Views/PanelView.swift index 5eebe41..fb90721 100644 --- a/Sources/AppFaders/Views/PanelView.swift +++ b/Sources/AppFaders/Views/PanelView.swift @@ -4,8 +4,8 @@ import SwiftUI struct PanelView: View { @Bindable var state: AppState - private let panelBackground = Color("PanelBackground", bundle: .module) - private let dividerColor = Color("Divider", bundle: .module) + private let panelBackground = AppColors.panelBackground + private let dividerColor = AppColors.divider var body: some View { VStack(spacing: 0) { @@ -21,12 +21,23 @@ struct PanelView: View { onMuteToggle: { state.toggleMasterMute() } ) - ForEach(state.apps) { app in - AppRowView( - app: app, - volume: volumeBinding(for: app.id), - onMuteToggle: { Task { await state.toggleMute(for: app.id) } } - ) + if !state.apps.isEmpty { + Rectangle() + .fill(dividerColor) + .frame(height: 1) + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(state.apps) { app in + AppRowView( + app: app, + volume: volumeBinding(for: app.id), + onMuteToggle: { Task { await state.toggleMute(for: app.id) } } + ) + } + } + } + .frame(maxHeight: 400) } Rectangle() From d06e85d418931a3ec74c24c74960c301058fd27d Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:24:37 -0800 Subject: [PATCH 18/24] fix(desktop-ui): replace asset catalog with code-defined colors and fix panel visibility Asset catalogs aren't compiled by SPM's .process() directive, causing runtime "No color named" errors. Replace with AppColors enum using programmatic light/dark mode support. - Remove Colors.xcassets and Resources directory - Update all views to use AppColors instead of Color(bundle:) - Set hidesOnDeactivate=false on NSPanel (required for menu bar apps) - Wire PanelView to MenuBarController with AppState - Remove unused PlaceholderPanelView --- Package.swift | 3 -- Sources/AppFaders/AppDelegate.swift | 12 ++++- Sources/AppFaders/Components/MuteButton.swift | 4 +- .../AppFaders/Components/VolumeSlider.swift | 4 +- Sources/AppFaders/MenuBarController.swift | 35 +++++++++++---- .../Accent.colorset/Contents.json | 20 --------- .../Resources/Colors.xcassets/Contents.json | 6 --- .../Divider.colorset/Contents.json | 44 ------------------- .../PanelBackground.colorset/Contents.json | 44 ------------------- .../PrimaryText.colorset/Contents.json | 44 ------------------- .../SecondaryText.colorset/Contents.json | 44 ------------------- .../SliderThumb.colorset/Contents.json | 44 ------------------- .../SliderTrack.colorset/Contents.json | 44 ------------------- .../TertiaryText.colorset/Contents.json | 44 ------------------- Sources/AppFaders/Views/AppRowView.swift | 14 +++--- Sources/AppFaders/Views/FooterView.swift | 8 ++-- Sources/AppFaders/Views/HeaderView.swift | 8 ++-- .../AppFaders/Views/MasterVolumeView.swift | 10 ++--- .../Views/PlaceholderPanelView.swift | 38 ---------------- 19 files changed, 62 insertions(+), 408 deletions(-) delete mode 100644 Sources/AppFaders/Resources/Colors.xcassets/Accent.colorset/Contents.json delete mode 100644 Sources/AppFaders/Resources/Colors.xcassets/Contents.json delete mode 100644 Sources/AppFaders/Resources/Colors.xcassets/Divider.colorset/Contents.json delete mode 100644 Sources/AppFaders/Resources/Colors.xcassets/PanelBackground.colorset/Contents.json delete mode 100644 Sources/AppFaders/Resources/Colors.xcassets/PrimaryText.colorset/Contents.json delete mode 100644 Sources/AppFaders/Resources/Colors.xcassets/SecondaryText.colorset/Contents.json delete mode 100644 Sources/AppFaders/Resources/Colors.xcassets/SliderThumb.colorset/Contents.json delete mode 100644 Sources/AppFaders/Resources/Colors.xcassets/SliderTrack.colorset/Contents.json delete mode 100644 Sources/AppFaders/Resources/Colors.xcassets/TertiaryText.colorset/Contents.json delete mode 100644 Sources/AppFaders/Views/PlaceholderPanelView.swift diff --git a/Package.swift b/Package.swift index 9cd3a3e..a4f531f 100644 --- a/Package.swift +++ b/Package.swift @@ -27,9 +27,6 @@ let package = Package( dependencies: [ "AppFadersCore", .product(name: "CAAudioHardware", package: "CAAudioHardware") - ], - resources: [ - .process("Resources") ] ), .executableTarget( diff --git a/Sources/AppFaders/AppDelegate.swift b/Sources/AppFaders/AppDelegate.swift index cfc6282..630068d 100644 --- a/Sources/AppFaders/AppDelegate.swift +++ b/Sources/AppFaders/AppDelegate.swift @@ -8,13 +8,23 @@ private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "AppD final class AppDelegate: NSObject, NSApplicationDelegate { private var menuBarController: MenuBarController? private let orchestrator = AudioOrchestrator() + private let deviceManager = DeviceManager() + private var appState: AppState? private var orchestratorTask: Task? func applicationDidFinishLaunching(_ notification: Notification) { os_log(.info, log: log, "AppFaders launching") NSApp.setActivationPolicy(.accessory) - menuBarController = MenuBarController() + + // Create AppState with dependencies + let state = AppState(orchestrator: orchestrator, deviceManager: deviceManager) + appState = state + + // Create menu bar controller with state + menuBarController = MenuBarController(appState: state) + + // Start orchestrator orchestratorTask = Task { await orchestrator.start() } diff --git a/Sources/AppFaders/Components/MuteButton.swift b/Sources/AppFaders/Components/MuteButton.swift index ffd7f40..0857f9d 100644 --- a/Sources/AppFaders/Components/MuteButton.swift +++ b/Sources/AppFaders/Components/MuteButton.swift @@ -5,8 +5,8 @@ struct MuteButton: View { let isMuted: Bool let onToggle: () -> Void - private let speakerColor = Color("SliderThumb", bundle: .module) - private let mutedColor = Color("Accent", bundle: .module) + private let speakerColor = AppColors.sliderThumb + private let mutedColor = AppColors.accent var body: some View { Button(action: onToggle) { diff --git a/Sources/AppFaders/Components/VolumeSlider.swift b/Sources/AppFaders/Components/VolumeSlider.swift index 2635921..e4305fa 100644 --- a/Sources/AppFaders/Components/VolumeSlider.swift +++ b/Sources/AppFaders/Components/VolumeSlider.swift @@ -40,8 +40,8 @@ struct VolumeSlider: View { @Binding var value: Float var size: SliderSize = .large - private let trackColor = Color("SliderTrack", bundle: .module) - private let thumbColor = Color("SliderThumb", bundle: .module) + private let trackColor = AppColors.sliderTrack + private let thumbColor = AppColors.sliderThumb var body: some View { GeometryReader { geometry in diff --git a/Sources/AppFaders/MenuBarController.swift b/Sources/AppFaders/MenuBarController.swift index dabecd4..f0b9202 100644 --- a/Sources/AppFaders/MenuBarController.swift +++ b/Sources/AppFaders/MenuBarController.swift @@ -13,7 +13,10 @@ final class MenuBarController: NSObject { private var clickOutsideMonitor: Any? private var escapeKeyMonitor: Any? - override init() { + private let appState: AppState + + init(appState: AppState) { + self.appState = appState super.init() setupStatusItem() setupPanel() @@ -30,13 +33,27 @@ final class MenuBarController: NSObject { } func showPanel() { - guard let panel else { return } + guard let panel else { + os_log(.error, log: log, "showPanel: panel is nil") + return + } + + // Sync state before showing + appState.syncFromOrchestrator() + appState.refreshMasterVolume() + + // Resize panel to fit content + if let contentView = panel.contentView { + let fittingSize = contentView.fittingSize + if fittingSize.width > 0 && fittingSize.height > 0 { + panel.setContentSize(fittingSize) + } + } positionPanelBelowStatusItem() panel.makeKeyAndOrderFront(nil) isPanelVisible = true addEventMonitors() - os_log(.debug, log: log, "Panel shown") } func hidePanel() { @@ -138,7 +155,7 @@ final class MenuBarController: NSObject { private func setupPanel() { let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 400), + contentRect: NSRect(x: 0, y: 0, width: 380, height: 500), styleMask: [.nonactivatingPanel, .fullSizeContentView, .borderless], backing: .buffered, defer: false @@ -146,7 +163,7 @@ final class MenuBarController: NSObject { panel.isFloatingPanel = true panel.level = .floating - panel.hidesOnDeactivate = true + panel.hidesOnDeactivate = false // Must be false for menu bar apps panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.backgroundColor = .clear panel.isOpaque = false @@ -154,11 +171,13 @@ final class MenuBarController: NSObject { panel.titlebarAppearsTransparent = true panel.titleVisibility = .hidden - let hostingView = NSHostingView(rootView: PlaceholderPanelView()) + let panelView = PanelView(state: appState) + let hostingView = NSHostingView(rootView: panelView) + hostingView.autoresizingMask = [.width, .height] panel.contentView = hostingView self.panel = panel - os_log(.info, log: log, "Panel created") + os_log(.info, log: log, "Panel created with PanelView") } // MARK: - Status Item Setup @@ -183,7 +202,7 @@ final class MenuBarController: NSObject { button.target = self button.action = #selector(statusItemClicked(_:)) - button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + button.sendAction(on: [.leftMouseDown, .rightMouseUp]) os_log(.info, log: log, "Menu bar controller initialized") } diff --git a/Sources/AppFaders/Resources/Colors.xcassets/Accent.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/Accent.colorset/Contents.json deleted file mode 100644 index 77db0ea..0000000 --- a/Sources/AppFaders/Resources/Colors.xcassets/Accent.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors": [ - { - "color": { - "color-space": "srgb", - "components": { - "red": "0.898", - "green": "0.224", - "blue": "0.208", - "alpha": "1.000" - } - }, - "idiom": "universal" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/Contents.json deleted file mode 100644 index c47b5f2..0000000 --- a/Sources/AppFaders/Resources/Colors.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/Divider.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/Divider.colorset/Contents.json deleted file mode 100644 index df1d2af..0000000 --- a/Sources/AppFaders/Resources/Colors.xcassets/Divider.colorset/Contents.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "colors": [ - { - "color": { - "color-space": "srgb", - "components": { - "red": "0.867", - "green": "0.867", - "blue": "0.867", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ] - }, - { - "color": { - "color-space": "srgb", - "components": { - "red": "0.200", - "green": "0.200", - "blue": "0.200", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ] - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/PanelBackground.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/PanelBackground.colorset/Contents.json deleted file mode 100644 index 9162cc2..0000000 --- a/Sources/AppFaders/Resources/Colors.xcassets/PanelBackground.colorset/Contents.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "colors": [ - { - "color": { - "color-space": "srgb", - "components": { - "red": "0.961", - "green": "0.961", - "blue": "0.961", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ] - }, - { - "color": { - "color-space": "srgb", - "components": { - "red": "0.118", - "green": "0.118", - "blue": "0.118", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ] - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/PrimaryText.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/PrimaryText.colorset/Contents.json deleted file mode 100644 index ea82934..0000000 --- a/Sources/AppFaders/Resources/Colors.xcassets/PrimaryText.colorset/Contents.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "colors": [ - { - "color": { - "color-space": "srgb", - "components": { - "red": "0.118", - "green": "0.118", - "blue": "0.118", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ] - }, - { - "color": { - "color-space": "srgb", - "components": { - "red": "1.000", - "green": "1.000", - "blue": "1.000", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ] - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/SecondaryText.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/SecondaryText.colorset/Contents.json deleted file mode 100644 index f1a4832..0000000 --- a/Sources/AppFaders/Resources/Colors.xcassets/SecondaryText.colorset/Contents.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "colors": [ - { - "color": { - "color-space": "srgb", - "components": { - "red": "0.400", - "green": "0.400", - "blue": "0.400", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ] - }, - { - "color": { - "color-space": "srgb", - "components": { - "red": "0.533", - "green": "0.533", - "blue": "0.533", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ] - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/SliderThumb.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/SliderThumb.colorset/Contents.json deleted file mode 100644 index ea82934..0000000 --- a/Sources/AppFaders/Resources/Colors.xcassets/SliderThumb.colorset/Contents.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "colors": [ - { - "color": { - "color-space": "srgb", - "components": { - "red": "0.118", - "green": "0.118", - "blue": "0.118", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ] - }, - { - "color": { - "color-space": "srgb", - "components": { - "red": "1.000", - "green": "1.000", - "blue": "1.000", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ] - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/SliderTrack.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/SliderTrack.colorset/Contents.json deleted file mode 100644 index e39b089..0000000 --- a/Sources/AppFaders/Resources/Colors.xcassets/SliderTrack.colorset/Contents.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "colors": [ - { - "color": { - "color-space": "srgb", - "components": { - "red": "0.800", - "green": "0.800", - "blue": "0.800", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ] - }, - { - "color": { - "color-space": "srgb", - "components": { - "red": "0.290", - "green": "0.290", - "blue": "0.290", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ] - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/Sources/AppFaders/Resources/Colors.xcassets/TertiaryText.colorset/Contents.json b/Sources/AppFaders/Resources/Colors.xcassets/TertiaryText.colorset/Contents.json deleted file mode 100644 index d2f76c0..0000000 --- a/Sources/AppFaders/Resources/Colors.xcassets/TertiaryText.colorset/Contents.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "colors": [ - { - "color": { - "color-space": "srgb", - "components": { - "red": "0.600", - "green": "0.600", - "blue": "0.600", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ] - }, - { - "color": { - "color-space": "srgb", - "components": { - "red": "0.400", - "green": "0.400", - "blue": "0.400", - "alpha": "1.000" - } - }, - "idiom": "universal", - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ] - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/Sources/AppFaders/Views/AppRowView.swift b/Sources/AppFaders/Views/AppRowView.swift index 91c0a87..ac56cf5 100644 --- a/Sources/AppFaders/Views/AppRowView.swift +++ b/Sources/AppFaders/Views/AppRowView.swift @@ -7,9 +7,9 @@ struct AppRowView: View { @Binding var volume: Float let onMuteToggle: () -> Void - private let primaryText = Color("PrimaryText", bundle: .module) - private let secondaryText = Color("SecondaryText", bundle: .module) - private let accentColor = Color("Accent", bundle: .module) + private let primaryText = AppColors.primaryText + private let secondaryText = AppColors.secondaryText + private let accentColor = AppColors.accent var body: some View { HStack(spacing: 16) { @@ -53,7 +53,7 @@ struct AppRowView: View { .font(.system(size: 32)) .foregroundStyle(secondaryText) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color("Divider", bundle: .module)) + .background(AppColors.divider) } } } @@ -74,7 +74,7 @@ struct AppRowView: View { onMuteToggle: {} ) .padding(.horizontal, 20) - .background(Color("PanelBackground", bundle: .module)) + .background(AppColors.panelBackground) .preferredColorScheme(.dark) } @@ -92,7 +92,7 @@ struct AppRowView: View { onMuteToggle: {} ) .padding(.horizontal, 20) - .background(Color("PanelBackground", bundle: .module)) + .background(AppColors.panelBackground) .preferredColorScheme(.dark) } @@ -110,6 +110,6 @@ struct AppRowView: View { onMuteToggle: {} ) .padding(.horizontal, 20) - .background(Color("PanelBackground", bundle: .module)) + .background(AppColors.panelBackground) .preferredColorScheme(.light) } diff --git a/Sources/AppFaders/Views/FooterView.swift b/Sources/AppFaders/Views/FooterView.swift index 525443c..8bceed0 100644 --- a/Sources/AppFaders/Views/FooterView.swift +++ b/Sources/AppFaders/Views/FooterView.swift @@ -4,8 +4,8 @@ import SwiftUI struct FooterView: View { let onQuit: () -> Void - private let tertiaryText = Color("TertiaryText", bundle: .module) - private let accentColor = Color("Accent", bundle: .module) + private let tertiaryText = AppColors.tertiaryText + private let accentColor = AppColors.accent var body: some View { HStack { @@ -31,13 +31,13 @@ struct FooterView: View { #Preview("Footer - Light") { FooterView(onQuit: {}) .padding() - .background(Color("PanelBackground", bundle: .module)) + .background(AppColors.panelBackground) .preferredColorScheme(.light) } #Preview("Footer - Dark") { FooterView(onQuit: {}) .padding() - .background(Color("PanelBackground", bundle: .module)) + .background(AppColors.panelBackground) .preferredColorScheme(.dark) } diff --git a/Sources/AppFaders/Views/HeaderView.swift b/Sources/AppFaders/Views/HeaderView.swift index 3a02e6a..df3e1c3 100644 --- a/Sources/AppFaders/Views/HeaderView.swift +++ b/Sources/AppFaders/Views/HeaderView.swift @@ -2,8 +2,8 @@ import SwiftUI /// Panel header with title and settings icon matching Pencil design (7tlKE) struct HeaderView: View { - private let primaryText = Color("PrimaryText", bundle: .module) - private let secondaryText = Color("SecondaryText", bundle: .module) + private let primaryText = AppColors.primaryText + private let secondaryText = AppColors.secondaryText var body: some View { HStack { @@ -26,13 +26,13 @@ struct HeaderView: View { #Preview("Header - Light") { HeaderView() .padding() - .background(Color("PanelBackground", bundle: .module)) + .background(AppColors.panelBackground) .preferredColorScheme(.light) } #Preview("Header - Dark") { HeaderView() .padding() - .background(Color("PanelBackground", bundle: .module)) + .background(AppColors.panelBackground) .preferredColorScheme(.dark) } diff --git a/Sources/AppFaders/Views/MasterVolumeView.swift b/Sources/AppFaders/Views/MasterVolumeView.swift index 5664e84..2119e5f 100644 --- a/Sources/AppFaders/Views/MasterVolumeView.swift +++ b/Sources/AppFaders/Views/MasterVolumeView.swift @@ -6,7 +6,7 @@ struct MasterVolumeView: View { let isMuted: Bool let onMuteToggle: () -> Void - private let secondaryText = Color("SecondaryText", bundle: .module) + private let secondaryText = AppColors.secondaryText var body: some View { VStack(spacing: 16) { @@ -21,7 +21,7 @@ struct MasterVolumeView: View { Text(isMuted ? "Muted" : "\(Int(volume * 100))%") .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(isMuted ? Color("Accent", bundle: .module) : secondaryText) + .foregroundStyle(isMuted ? AppColors.accent : secondaryText) } // Slider row: slider + mute button @@ -40,20 +40,20 @@ struct MasterVolumeView: View { #Preview("Master - Light") { MasterVolumeView(volume: .constant(0.85), isMuted: false, onMuteToggle: {}) .padding(.horizontal, 20) - .background(Color("PanelBackground", bundle: .module)) + .background(AppColors.panelBackground) .preferredColorScheme(.light) } #Preview("Master - Dark") { MasterVolumeView(volume: .constant(0.85), isMuted: false, onMuteToggle: {}) .padding(.horizontal, 20) - .background(Color("PanelBackground", bundle: .module)) + .background(AppColors.panelBackground) .preferredColorScheme(.dark) } #Preview("Master Muted - Dark") { MasterVolumeView(volume: .constant(0.0), isMuted: true, onMuteToggle: {}) .padding(.horizontal, 20) - .background(Color("PanelBackground", bundle: .module)) + .background(AppColors.panelBackground) .preferredColorScheme(.dark) } diff --git a/Sources/AppFaders/Views/PlaceholderPanelView.swift b/Sources/AppFaders/Views/PlaceholderPanelView.swift deleted file mode 100644 index cf448f9..0000000 --- a/Sources/AppFaders/Views/PlaceholderPanelView.swift +++ /dev/null @@ -1,38 +0,0 @@ -import SwiftUI - -/// Placeholder panel view for task 5 - will be replaced by PanelView in later tasks -struct PlaceholderPanelView: View { - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - VStack(spacing: 16) { - Text("AppFaders") - .font(.system(size: 20, weight: .semibold)) - - Text("Panel placeholder") - .font(.system(size: 14)) - .foregroundStyle(.secondary) - - Text("Click outside or press Esc to dismiss") - .font(.system(size: 12)) - .foregroundStyle(.tertiary) - } - .frame(width: 380) - .padding(20) - .background(panelBackground) - .clipShape(RoundedRectangle(cornerRadius: 20)) - } - - private var panelBackground: Color { - colorScheme == .dark ? Color(hex: 0x1E1E1E) : Color(hex: 0xF5F5F5) - } -} - -private extension Color { - init(hex: UInt32) { - let r = Double((hex >> 16) & 0xFF) / 255.0 - let g = Double((hex >> 8) & 0xFF) / 255.0 - let b = Double(hex & 0xFF) / 255.0 - self.init(red: r, green: g, blue: b) - } -} From 6174e49b00f0e801c5c58070b7b8b281729f8d4f Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:24:47 -0800 Subject: [PATCH 19/24] fix: formats --- Sources/AppFaders/MenuBarController.swift | 4 ++-- Tests/AppFadersTests/DriverBridgeTests.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/AppFaders/MenuBarController.swift b/Sources/AppFaders/MenuBarController.swift index f0b9202..6b84605 100644 --- a/Sources/AppFaders/MenuBarController.swift +++ b/Sources/AppFaders/MenuBarController.swift @@ -45,7 +45,7 @@ final class MenuBarController: NSObject { // Resize panel to fit content if let contentView = panel.contentView { let fittingSize = contentView.fittingSize - if fittingSize.width > 0 && fittingSize.height > 0 { + if fittingSize.width > 0, fittingSize.height > 0 { panel.setContentSize(fittingSize) } } @@ -163,7 +163,7 @@ final class MenuBarController: NSObject { panel.isFloatingPanel = true panel.level = .floating - panel.hidesOnDeactivate = false // Must be false for menu bar apps + panel.hidesOnDeactivate = false // Must be false for menu bar apps panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.backgroundColor = .clear panel.isOpaque = false diff --git a/Tests/AppFadersTests/DriverBridgeTests.swift b/Tests/AppFadersTests/DriverBridgeTests.swift index faa88a6..4a85281 100644 --- a/Tests/AppFadersTests/DriverBridgeTests.swift +++ b/Tests/AppFadersTests/DriverBridgeTests.swift @@ -33,8 +33,8 @@ struct DriverBridgeTests { func validateValidVolume() async { let bridge = DriverBridge() - // Valid volumes should pass validation and fail at XPC (helper not running) - // We check that invalidVolumeRange is NOT thrown + /// 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 await bridge.setAppVolume(bundleID: "com.test.app", volume: volume) From afa02e0871160f27b1a995428b1dce853a6e2607 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:55:37 -0800 Subject: [PATCH 20/24] feat(core): filter app list to regular windowed apps only - added filtering for AppAudioMonitor with .regular to make list sensible. --- Sources/AppFadersCore/AppAudioMonitor.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Sources/AppFadersCore/AppAudioMonitor.swift b/Sources/AppFadersCore/AppAudioMonitor.swift index 9aa5476..9904740 100644 --- a/Sources/AppFadersCore/AppAudioMonitor.swift +++ b/Sources/AppFadersCore/AppAudioMonitor.swift @@ -62,15 +62,23 @@ public final class AppAudioMonitor: @unchecked Sendable { /// starts monitoring and populates initial state public func start() { - // initial snapshot - let currentApps = workspace.runningApplications + // initial snapshot - only include regular (windowed) apps + let allApps = workspace.runningApplications + let currentApps = allApps + .filter { $0.activationPolicy == .regular } .compactMap { TrackedApp(from: $0) } lock.lock() _runningApps = currentApps lock.unlock() - os_log(.info, log: log, "Started monitoring with %d initial apps", currentApps.count) + os_log( + .info, + log: log, + "Started monitoring with %d apps (filtered from %d total)", + currentApps.count, + allApps.count + ) } private func handleAppLaunch( @@ -79,6 +87,7 @@ public final class AppAudioMonitor: @unchecked Sendable { ) { guard let app = notification .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.activationPolicy == .regular, let trackedApp = TrackedApp(from: app) else { return } From 6b65bc659acc42ebf2fe1abc2e70dc7f4e8b3064 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:57:25 -0800 Subject: [PATCH 21/24] fix(orchestrator): populate apps synchronously in init() - moved list population from async start() to init() so list pops faster. --- Sources/AppFaders/AudioOrchestrator.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Sources/AppFaders/AudioOrchestrator.swift b/Sources/AppFaders/AudioOrchestrator.swift index e26a2bf..27b69aa 100644 --- a/Sources/AppFaders/AudioOrchestrator.swift +++ b/Sources/AppFaders/AudioOrchestrator.swift @@ -22,7 +22,15 @@ final class AudioOrchestrator { deviceManager = DeviceManager() appAudioMonitor = AppAudioMonitor() driverBridge = DriverBridge() - os_log(.info, log: log, "AudioOrchestrator initialized") + + // Populate apps immediately so they're available before start() runs + appAudioMonitor.start() + for app in appAudioMonitor.runningApps { + trackedApps.append(app) + appVolumes[app.bundleID] = 1.0 + } + + os_log(.info, log: log, "AudioOrchestrator initialized with %d apps", trackedApps.count) } // MARK: - Lifecycle @@ -37,12 +45,7 @@ final class AudioOrchestrator { let deviceUpdates = deviceManager.deviceListUpdates let appEvents = appAudioMonitor.events - appAudioMonitor.start() - - for app in appAudioMonitor.runningApps { - trackApp(app) - } - + // Apps already populated in init(), just connect to helper await connectToHelper() await withTaskGroup(of: Void.self) { group in From a3d315b410bed78f2fe98613c47a50bd9f8e5326 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:05:57 -0800 Subject: [PATCH 22/24] feat(desktop-ui): polish layout --- Sources/AppFaders/Components/VolumeSlider.swift | 6 ++++-- Sources/AppFaders/Views/PanelView.swift | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/AppFaders/Components/VolumeSlider.swift b/Sources/AppFaders/Components/VolumeSlider.swift index e4305fa..3620fd5 100644 --- a/Sources/AppFaders/Components/VolumeSlider.swift +++ b/Sources/AppFaders/Components/VolumeSlider.swift @@ -51,12 +51,14 @@ struct VolumeSlider: View { let thumbX = thumbRadius + CGFloat(value) * usableWidth ZStack(alignment: .leading) { - // Track RoundedRectangle(cornerRadius: 2) .fill(trackColor) .frame(width: trackWidth, height: size.trackHeight) - // Thumb + RoundedRectangle(cornerRadius: 2) + .fill(thumbColor.opacity(0.5)) + .frame(width: thumbX, height: size.trackHeight) + Circle() .fill(thumbColor) .frame(width: size.thumbDiameter, height: size.thumbDiameter) diff --git a/Sources/AppFaders/Views/PanelView.swift b/Sources/AppFaders/Views/PanelView.swift index fb90721..b6c55d5 100644 --- a/Sources/AppFaders/Views/PanelView.swift +++ b/Sources/AppFaders/Views/PanelView.swift @@ -37,6 +37,7 @@ struct PanelView: View { } } } + .scrollIndicators(.automatic) .frame(maxHeight: 400) } From e72403d5f0b3aadc6c67c61a0756adb879cf1cdb Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:14:09 -0800 Subject: [PATCH 23/24] test(desktop-ui): add AppVolumeState unit tests --- Tests/AppFadersTests/AppStateTests.swift | 90 ++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 Tests/AppFadersTests/AppStateTests.swift diff --git a/Tests/AppFadersTests/AppStateTests.swift b/Tests/AppFadersTests/AppStateTests.swift new file mode 100644 index 0000000..0158e26 --- /dev/null +++ b/Tests/AppFadersTests/AppStateTests.swift @@ -0,0 +1,90 @@ +@testable import AppFaders +import AppKit +import Testing + +@Suite("AppVolumeState") +struct AppVolumeStateTests { + @Test("displayPercentage shows percentage when not muted") + func displayPercentageNormal() { + let state = AppVolumeState( + id: "com.test.app", + name: "Test App", + icon: nil, + volume: 0.75, + isMuted: false, + previousVolume: 0.75 + ) + + #expect(state.displayPercentage == "75%") + } + + @Test("displayPercentage shows 'Muted' when muted") + func displayPercentageMuted() { + let state = AppVolumeState( + id: "com.test.app", + name: "Test App", + icon: nil, + volume: 0.0, + isMuted: true, + previousVolume: 0.75 + ) + + #expect(state.displayPercentage == "Muted") + } + + @Test("displayPercentage rounds to integer") + func displayPercentageRounding() { + let state = AppVolumeState( + id: "com.test.app", + name: "Test App", + icon: nil, + volume: 0.333, + isMuted: false, + previousVolume: 0.333 + ) + + #expect(state.displayPercentage == "33%") + } + + @Test("volume at 0% shows 0%") + func displayPercentageZero() { + let state = AppVolumeState( + id: "com.test.app", + name: "Test App", + icon: nil, + volume: 0.0, + isMuted: false, + previousVolume: 0.5 + ) + + #expect(state.displayPercentage == "0%") + } + + @Test("volume at 100% shows 100%") + func displayPercentageFull() { + let state = AppVolumeState( + id: "com.test.app", + name: "Test App", + icon: nil, + volume: 1.0, + isMuted: false, + previousVolume: 1.0 + ) + + #expect(state.displayPercentage == "100%") + } + + @Test("id matches bundleID") + func identifiable() { + let state = AppVolumeState( + id: "com.test.app", + name: "Test App", + icon: nil, + volume: 0.5, + isMuted: false, + previousVolume: 0.5 + ) + + #expect(state.id == "com.test.app") + } +} From edfa53e18a44fc09ec401293b6d76472a9268f21 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:16:05 -0800 Subject: [PATCH 24/24] docs: claude review of integration tests we should do --- docs/future-integration-tests.md | 176 +++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/future-integration-tests.md diff --git a/docs/future-integration-tests.md b/docs/future-integration-tests.md new file mode 100644 index 0000000..37bcacd --- /dev/null +++ b/docs/future-integration-tests.md @@ -0,0 +1,176 @@ +# Future Integration Test Recommendations + +## Current State + +As of Phase 3 (desktop-ui) completion, the following components have unit test coverage: + +| Component | Coverage | Notes | +|-----------|----------|-------| +| `AppVolumeState` | ✓ | Struct, no dependencies | +| `TrackedApp` | ✓ | Struct, equality/hashing | +| `AppAudioMonitor` | ✓ | Initial enumeration, stream mechanics, concurrency | +| `DriverBridge` | ✓ | Validation (volume range, bundle ID length, connection state) | +| `AppState` class | ✗ | Requires real AudioOrchestrator + DeviceManager | +| UI Views | ✗ | SwiftUI previews serve as visual tests | + +## Testing Gap: AppState Class + +The `AppState` class contains business logic that should be tested but currently isn't due to hard dependencies on: + +1. **AudioOrchestrator** - requires XPC connection to helper service +2. **DeviceManager** - requires real audio hardware (CAAudioHardware) + +### Untested Methods + +```swift +// Per-app volume control +func setVolume(for bundleID: String, volume: Float) async +func toggleMute(for bundleID: String) async + +// Master volume control +func setMasterVolume(_ volume: Float) +func toggleMasterMute() + +// State sync +func syncFromOrchestrator() +func refreshMasterVolume() +``` + +### Testable Logic Within These Methods + +1. **Volume clamping** - `max(0.0, min(1.0, volume))` ensures 0-1 range +2. **Auto-unmute on volume change** - setting volume > 0 while muted should unmute +3. **Mute toggle state machine** - stores previousVolume, restores on unmute +4. **Sync preserves mute state** - existing muted apps stay muted after sync + +## Recommended Approach: Protocol-Based Dependencies + +### Step 1: Define Protocols + +```swift +// AudioOrchestratorProtocol.swift +@MainActor +protocol AudioOrchestratorProtocol { + var trackedApps: [TrackedApp] { get } + var appVolumes: [String: Float] { get } + var isDriverConnected: Bool { get } + func setVolume(for bundleID: String, volume: Float) async +} + +// DeviceManagerProtocol.swift +protocol DeviceManagerProtocol { + func getSystemVolume() -> Float + func setSystemVolume(_ volume: Float) + func getSystemMute() -> Bool + func setSystemMute(_ muted: Bool) +} +``` + +### Step 2: Create Mock Implementations + +```swift +// MockAudioOrchestrator.swift (in Tests/) +@MainActor +final class MockAudioOrchestrator: AudioOrchestratorProtocol { + var trackedApps: [TrackedApp] = [] + var appVolumes: [String: Float] = [:] + var isDriverConnected: Bool = true + + var setVolumeCalls: [(bundleID: String, volume: Float)] = [] + + func setVolume(for bundleID: String, volume: Float) async { + setVolumeCalls.append((bundleID, volume)) + appVolumes[bundleID] = volume + } +} + +// MockDeviceManager.swift (in Tests/) +final class MockDeviceManager: DeviceManagerProtocol { + var systemVolume: Float = 1.0 + var systemMuted: Bool = false + + func getSystemVolume() -> Float { systemVolume } + func setSystemVolume(_ volume: Float) { systemVolume = volume } + func getSystemMute() -> Bool { systemMuted } + func setSystemMute(_ muted: Bool) { systemMuted = muted } +} +``` + +### Step 3: Update AppState Init + +```swift +// Allow protocol-based injection +init(orchestrator: any AudioOrchestratorProtocol, deviceManager: any DeviceManagerProtocol) { + self.orchestrator = orchestrator + self.deviceManager = deviceManager + // ... +} +``` + +## Priority Test Cases + +### High Priority + +1. **Volume clamping** + - `setVolume(volume: -0.5)` → clamped to 0.0 + - `setVolume(volume: 1.5)` → clamped to 1.0 + - `setMasterVolume(-0.5)` → clamped to 0.0 + +2. **Mute toggle state machine** + - Mute stores previousVolume, sets volume to 0 + - Unmute restores previousVolume + - Mute → change previousVolume externally → unmute restores correct value + +3. **Auto-unmute on volume change** + - App is muted, `setVolume(volume: 0.5)` → unmutes and sets volume + - Master is muted, `setMasterVolume(0.5)` → unmutes and sets volume + +### Medium Priority + +1. **syncFromOrchestrator preserves mute state** + - Muted app stays muted after sync + - Volume shows 0 for muted apps even if orchestrator has different value + +2. **Connection error state** + - `isDriverConnected = false` → `connectionError` is set + - `isDriverConnected = true` → `connectionError` is nil + +### Lower Priority + +1. **refreshMasterVolume reads from device manager** +2. **setVolume for non-existent bundleID is no-op** +3. **toggleMute for non-existent bundleID is no-op** + +## Integration Test Considerations + +For full end-to-end testing with real XPC and audio hardware: + +1. **Requires helper service running** - use `Scripts/install-driver.sh` first +2. **Requires audio device** - may need to mock or use virtual device +3. **Consider CI environment** - GitHub Actions runners may not have audio hardware + +### Suggested Integration Test Setup + +```swift +@Suite("AppState Integration", .disabled("Requires helper service")) +struct AppStateIntegrationTests { + @Test func realVolumeChangeReflectsInHelper() async { + // Only run when helper is available + // ... + } +} +``` + +## Implementation Timeline + +| Phase | Scope | Effort | +|-------|-------|--------| +| Phase 4 (system-delivery) | Consider adding protocols during installer work | Low | +| Post-MVP | Full protocol extraction + mock tests | Medium | +| CI Enhancement | Integration tests with helper service | High | + +## References + +- BackgroundMusic uses similar architecture with HAL driver + helper +- Apple's XPC testing documentation recommends mock services for unit tests +- Swift Testing framework supports `.disabled()` trait for conditional tests