diff --git a/Package.swift b/Package.swift index 0681b5b..a4f531f 100644 --- a/Package.swift +++ b/Package.swift @@ -11,15 +11,21 @@ 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: [ + "AppFadersCore", .product(name: "CAAudioHardware", package: "CAAudioHardware") ] ), @@ -61,7 +67,7 @@ let package = Package( ), .testTarget( name: "AppFadersTests", - dependencies: ["AppFaders"] + dependencies: ["AppFaders", "AppFadersCore"] ) ] ) diff --git a/Sources/AppFaders/AppDelegate.swift b/Sources/AppFaders/AppDelegate.swift new file mode 100644 index 0000000..630068d --- /dev/null +++ b/Sources/AppFaders/AppDelegate.swift @@ -0,0 +1,40 @@ +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 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) + + // 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() + } + + 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..18c9cf6 --- /dev/null +++ b/Sources/AppFaders/AppFadersApp.swift @@ -0,0 +1,13 @@ +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/AppState.swift b/Sources/AppFaders/AppState.swift new file mode 100644 index 0000000..5ea639a --- /dev/null +++ b/Sources/AppFaders/AppState.swift @@ -0,0 +1,181 @@ +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 + } + + /// 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 + +/// 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/AudioOrchestrator.swift b/Sources/AppFaders/AudioOrchestrator.swift index 2716d02..27b69aa 100644 --- a/Sources/AppFaders/AudioOrchestrator.swift +++ b/Sources/AppFaders/AudioOrchestrator.swift @@ -1,3 +1,4 @@ +import AppFadersCore import CAAudioHardware import Foundation import Observation @@ -21,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 @@ -36,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 diff --git a/Sources/AppFaders/Components/MuteButton.swift b/Sources/AppFaders/Components/MuteButton.swift new file mode 100644 index 0000000..0857f9d --- /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 = AppColors.sliderThumb + private let mutedColor = AppColors.accent + + 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) +} diff --git a/Sources/AppFaders/Components/VolumeSlider.swift b/Sources/AppFaders/Components/VolumeSlider.swift new file mode 100644 index 0000000..3620fd5 --- /dev/null +++ b/Sources/AppFaders/Components/VolumeSlider.swift @@ -0,0 +1,98 @@ +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 = AppColors.sliderTrack + private let thumbColor = AppColors.sliderThumb + + 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) { + RoundedRectangle(cornerRadius: 2) + .fill(trackColor) + .frame(width: trackWidth, height: size.trackHeight) + + RoundedRectangle(cornerRadius: 2) + .fill(thumbColor.opacity(0.5)) + .frame(width: thumbX, height: size.trackHeight) + + 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/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") } diff --git a/Sources/AppFaders/MenuBarController.swift b/Sources/AppFaders/MenuBarController.swift new file mode 100644 index 0000000..6b84605 --- /dev/null +++ b/Sources/AppFaders/MenuBarController.swift @@ -0,0 +1,253 @@ +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 + + private var clickOutsideMonitor: Any? + private var escapeKeyMonitor: Any? + + private let appState: AppState + + init(appState: AppState) { + self.appState = appState + super.init() + setupStatusItem() + setupPanel() + } + + // MARK: - Panel Management + + func togglePanel() { + if isPanelVisible { + hidePanel() + } else { + showPanel() + } + } + + func showPanel() { + 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() + } + + 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() { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 380, height: 500), + styleMask: [.nonactivatingPanel, .fullSizeContentView, .borderless], + backing: .buffered, + defer: false + ) + + panel.isFloatingPanel = true + panel.level = .floating + panel.hidesOnDeactivate = false // Must be false for menu bar apps + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = true + panel.titlebarAppearsTransparent = true + panel.titleVisibility = .hidden + + 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 with PanelView") + } + + // MARK: - Status Item Setup + + 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 + } + + 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'") + } + + button.target = self + button.action = #selector(statusItemClicked(_:)) + button.sendAction(on: [.leftMouseDown, .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) + } +} 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) + }) + } +} diff --git a/Sources/AppFaders/Views/AppRowView.swift b/Sources/AppFaders/Views/AppRowView.swift new file mode 100644 index 0000000..ac56cf5 --- /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 = AppColors.primaryText + private let secondaryText = AppColors.secondaryText + private let accentColor = AppColors.accent + + 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(AppColors.divider) + } + } +} + +// 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(AppColors.panelBackground) + .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(AppColors.panelBackground) + .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(AppColors.panelBackground) + .preferredColorScheme(.light) +} diff --git a/Sources/AppFaders/Views/FooterView.swift b/Sources/AppFaders/Views/FooterView.swift new file mode 100644 index 0000000..8bceed0 --- /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 = AppColors.tertiaryText + private let accentColor = AppColors.accent + + 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(AppColors.panelBackground) + .preferredColorScheme(.light) +} + +#Preview("Footer - Dark") { + FooterView(onQuit: {}) + .padding() + .background(AppColors.panelBackground) + .preferredColorScheme(.dark) +} diff --git a/Sources/AppFaders/Views/HeaderView.swift b/Sources/AppFaders/Views/HeaderView.swift new file mode 100644 index 0000000..df3e1c3 --- /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 = AppColors.primaryText + private let secondaryText = AppColors.secondaryText + + 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(AppColors.panelBackground) + .preferredColorScheme(.light) +} + +#Preview("Header - Dark") { + HeaderView() + .padding() + .background(AppColors.panelBackground) + .preferredColorScheme(.dark) +} diff --git a/Sources/AppFaders/Views/MasterVolumeView.swift b/Sources/AppFaders/Views/MasterVolumeView.swift new file mode 100644 index 0000000..2119e5f --- /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 = AppColors.secondaryText + + 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 ? AppColors.accent : 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(AppColors.panelBackground) + .preferredColorScheme(.light) +} + +#Preview("Master - Dark") { + MasterVolumeView(volume: .constant(0.85), isMuted: false, onMuteToggle: {}) + .padding(.horizontal, 20) + .background(AppColors.panelBackground) + .preferredColorScheme(.dark) +} + +#Preview("Master Muted - Dark") { + MasterVolumeView(volume: .constant(0.0), isMuted: true, onMuteToggle: {}) + .padding(.horizontal, 20) + .background(AppColors.panelBackground) + .preferredColorScheme(.dark) +} diff --git a/Sources/AppFaders/Views/PanelView.swift b/Sources/AppFaders/Views/PanelView.swift new file mode 100644 index 0000000..b6c55d5 --- /dev/null +++ b/Sources/AppFaders/Views/PanelView.swift @@ -0,0 +1,80 @@ +import SwiftUI + +/// Root panel +struct PanelView: View { + @Bindable var state: AppState + + private let panelBackground = AppColors.panelBackground + private let dividerColor = AppColors.divider + + var body: some View { + VStack(spacing: 0) { + HeaderView() + + Rectangle() + .fill(dividerColor) + .frame(height: 1) + + MasterVolumeView( + volume: $state.masterVolume, + isMuted: state.masterMuted, + onMuteToggle: { state.toggleMasterMute() } + ) + + 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) } } + ) + } + } + } + .scrollIndicators(.automatic) + .frame(maxHeight: 400) + } + + 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) +} 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() diff --git a/Sources/AppFaders/AppAudioMonitor.swift b/Sources/AppFadersCore/AppAudioMonitor.swift similarity index 83% rename from Sources/AppFaders/AppAudioMonitor.swift rename to Sources/AppFadersCore/AppAudioMonitor.swift index 088aa52..9904740 100644 --- a/Sources/AppFaders/AppAudioMonitor.swift +++ b/Sources/AppFadersCore/AppAudioMonitor.swift @@ -5,26 +5,26 @@ import os.log private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "AppAudioMonitor") /// lifecycle events for tracked applications -enum AppLifecycleEvent: Sendable { +public enum AppLifecycleEvent: Sendable { case didLaunch(TrackedApp) case didTerminate(String) // bundleID } /// monitors running applications using NSWorkspace -final class AppAudioMonitor: @unchecked Sendable { +public 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] { + public var runningApps: [TrackedApp] { lock.lock() defer { lock.unlock() } return _runningApps } /// async stream of app lifecycle events - var events: AsyncStream { + public var events: AsyncStream { AsyncStream { continuation in let task = Task { [weak self] in guard let self else { return } @@ -56,21 +56,29 @@ final class AppAudioMonitor: @unchecked Sendable { } } - init() { + public init() { os_log(.info, log: log, "AppAudioMonitor initialized") } /// starts monitoring and populates initial state - func start() { - // initial snapshot - let currentApps = workspace.runningApplications + public func start() { + // 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 @@ final class AppAudioMonitor: @unchecked Sendable { ) { guard let app = notification .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.activationPolicy == .regular, let trackedApp = TrackedApp(from: app) else { return } diff --git a/Sources/AppFaders/DriverBridge.swift b/Sources/AppFadersCore/DriverBridge.swift similarity index 92% rename from Sources/AppFaders/DriverBridge.swift rename to Sources/AppFadersCore/DriverBridge.swift index c065b05..a8802ef 100644 --- a/Sources/AppFaders/DriverBridge.swift +++ b/Sources/AppFadersCore/DriverBridge.swift @@ -5,19 +5,21 @@ private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "Driv private let machServiceName = "com.fbreidenbach.appfaders.helper" /// handles communication with the AppFaders helper service via XPC -final class DriverBridge: @unchecked Sendable { +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 - var isConnected: Bool { + public var isConnected: Bool { lock.withLock { connection != nil } } // MARK: - Connection Management /// establishes XPC connection to the helper service - func connect() async throws { + public func connect() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in lock.lock() @@ -52,7 +54,7 @@ final class DriverBridge: @unchecked Sendable { } /// disconnects from the helper service - func disconnect() { + public func disconnect() { lock.lock() defer { lock.unlock() } @@ -74,7 +76,7 @@ final class DriverBridge: @unchecked Sendable { /// - 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 { + public func setAppVolume(bundleID: String, volume: Float) async throws { guard volume >= 0.0, volume <= 1.0 else { throw DriverError.invalidVolumeRange(volume) } @@ -100,7 +102,7 @@ final class DriverBridge: @unchecked Sendable { /// - 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 { + public func getAppVolume(bundleID: String) async throws -> Float { guard bundleID.utf8.count <= 255 else { throw DriverError.bundleIDTooLong(bundleID.utf8.count) } diff --git a/Sources/AppFaders/DriverError.swift b/Sources/AppFadersCore/DriverError.swift similarity index 92% rename from Sources/AppFaders/DriverError.swift rename to Sources/AppFadersCore/DriverError.swift index 961b1ab..6a2b174 100644 --- a/Sources/AppFaders/DriverError.swift +++ b/Sources/AppFadersCore/DriverError.swift @@ -1,7 +1,7 @@ import Foundation /// errors related to driver communication and management -enum DriverError: Error, LocalizedError, Equatable { +public enum DriverError: Error, LocalizedError, Equatable, Sendable { case deviceNotFound case propertyReadFailed(OSStatus) case propertyWriteFailed(OSStatus) @@ -15,7 +15,7 @@ enum DriverError: Error, LocalizedError, Equatable { case connectionInterrupted case remoteError(String) - var errorDescription: String? { + public var errorDescription: String? { switch self { case .deviceNotFound: "AppFaders Virtual Device not found. Please ensure the driver is installed." diff --git a/Sources/AppFaders/HelperProtocol.swift b/Sources/AppFadersCore/HostProtocol.swift similarity index 73% rename from Sources/AppFaders/HelperProtocol.swift rename to Sources/AppFadersCore/HostProtocol.swift index 548cb52..ae209f2 100644 --- a/Sources/AppFaders/HelperProtocol.swift +++ b/Sources/AppFadersCore/HostProtocol.swift @@ -1,8 +1,8 @@ import Foundation -// NOTE: Must match AppFadersHelper/XPCProtocols.swift exactly +/// NOTE: Must match AppFadersHelper/XPCProtocols.swift exactly /// Protocol for host app connections (read-write) -@objc protocol AppFadersHostProtocol { +@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/AppFaders/TrackedApp.swift b/Sources/AppFadersCore/TrackedApp.swift similarity index 55% rename from Sources/AppFaders/TrackedApp.swift rename to Sources/AppFadersCore/TrackedApp.swift index 19d4ef1..f58bd5e 100644 --- a/Sources/AppFaders/TrackedApp.swift +++ b/Sources/AppFadersCore/TrackedApp.swift @@ -2,15 +2,17 @@ import AppKit import Foundation /// Application tracked by the host orchestrator -struct TrackedApp: Identifiable, Sendable, Hashable { - var id: String { bundleID } +public struct TrackedApp: Identifiable, Sendable, Hashable { + public var id: String { + bundleID + } - let bundleID: String - let localizedName: String - let icon: NSImage? - let launchDate: Date + public let bundleID: String + public let localizedName: String + public let icon: NSImage? + public let launchDate: Date - init?(from runningApp: NSRunningApplication) { + public init?(from runningApp: NSRunningApplication) { guard let bundleID = runningApp.bundleIdentifier else { return nil } @@ -21,18 +23,18 @@ struct TrackedApp: Identifiable, Sendable, Hashable { launchDate = runningApp.launchDate ?? .distantPast } - init(bundleID: String, localizedName: String, icon: NSImage?, launchDate: Date) { + public 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 { + public static func == (lhs: TrackedApp, rhs: TrackedApp) -> Bool { lhs.bundleID == rhs.bundleID && lhs.launchDate == rhs.launchDate } - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(bundleID) hasher.combine(launchDate) } 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/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/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) 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") + } +} diff --git a/Tests/AppFadersTests/DriverBridgeTests.swift b/Tests/AppFadersTests/DriverBridgeTests.swift index 900ddb2..4a85281 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 @@ -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) 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