Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8aa11f1
feat(core): add AppFadersCore library with shared code for UI reuse
cheefbird Jan 30, 2026
3e9435d
refactor(app): extract shared types to AppFadersCore
cheefbird Jan 30, 2026
d9e7e28
feat(app): implement menu bar app lifecycle with NSStatusItem
cheefbird Jan 30, 2026
16e43be
feat(desktop-ui): implement MenuBarController click handling
cheefbird Jan 30, 2026
47fa35b
fix(formats): fix formatting
cheefbird Jan 30, 2026
87962d1
feat(ui): add NSPanel with placeholder content for menu bar popover
cheefbird Jan 30, 2026
6ee9d33
feat(ui): add panel positioning and dismiss behavior
cheefbird Jan 30, 2026
2cb0421
feat(state): add AppState with system volume control via CAAudioHardware
cheefbird Feb 2, 2026
dfe1109
chore: cleanup comments and formats
cheefbird Feb 2, 2026
6a778fc
feat(desktop-ui): add VolumeSlider component with asset catalog colors
cheefbird Feb 2, 2026
8752169
feat(desktop-ui): add MuteButton component with speaker toggle icons
cheefbird Feb 2, 2026
12db8a3
feat(desktop-ui): add HeaderView and FooterView components
cheefbird Feb 2, 2026
a8505b9
feat(desktop-ui): add MasterVolumeView component
cheefbird Feb 2, 2026
0def3f9
feat(desktop-ui): add AppRowView component
cheefbird Feb 2, 2026
dd5aeac
feat(desktop-ui): add PanelView root view
cheefbird Feb 2, 2026
092ea8e
refactor: implement AppColors w mode support
cheefbird Feb 2, 2026
bce7000
fix(desktop-ui): use AppColors and add scrollable app list to PanelView
cheefbird Feb 2, 2026
d06e85d
fix(desktop-ui): replace asset catalog with code-defined colors and f…
cheefbird Feb 2, 2026
6174e49
fix: formats
cheefbird Feb 2, 2026
afa02e0
feat(core): filter app list to regular windowed apps only
cheefbird Feb 2, 2026
6b65bc6
fix(orchestrator): populate apps synchronously in init()
cheefbird Feb 2, 2026
a3d315b
feat(desktop-ui): polish layout
cheefbird Feb 2, 2026
e72403d
test(desktop-ui): add AppVolumeState unit tests
cheefbird Feb 2, 2026
edfa53e
docs: claude review of integration tests we should do
cheefbird Feb 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
]
),
Expand Down Expand Up @@ -61,7 +67,7 @@ let package = Package(
),
.testTarget(
name: "AppFadersTests",
dependencies: ["AppFaders"]
dependencies: ["AppFaders", "AppFadersCore"]
)
]
)
40 changes: 40 additions & 0 deletions Sources/AppFaders/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

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()
}
}
13 changes: 13 additions & 0 deletions Sources/AppFaders/AppFadersApp.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
181 changes: 181 additions & 0 deletions Sources/AppFaders/AppState.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
18 changes: 11 additions & 7 deletions Sources/AppFaders/AudioOrchestrator.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppFadersCore
import CAAudioHardware
import Foundation
import Observation
Expand All @@ -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
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions Sources/AppFaders/Components/MuteButton.swift
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading