Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 5 additions & 5 deletions src/app/EqualiserApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ final class AppCleanupDelegate: NSObject, NSApplicationDelegate {
@main
struct EqualiserMain: App {
@StateObject private var store = EqualiserStore()
@StateObject private var windowActivation = WindowActivationController()
@NSApplicationDelegateAdaptor(AppCleanupDelegate.self) var appDelegate

init() {
Expand All @@ -24,11 +25,6 @@ struct EqualiserMain: App {
// Accessing it in init() causes SwiftUI to create two instances.
// Wire appDelegate.setStore(store) in body using .onAppear instead.

// Hide dock icon permanently - this is a menu bar app
// Defer until NSApp is available (it's nil during init)
DispatchQueue.main.async {
NSApp.setActivationPolicy(.accessory)
}
// Note: Microphone permission is NOT requested here.
// It's only requested when needed (HAL input capture mode or manual mode).
// Shared memory capture (default) does NOT require microphone permission.
Expand All @@ -39,6 +35,7 @@ struct EqualiserMain: App {
Window("Equaliser", id: "equaliser") {
EQWindowView()
.environmentObject(store)
.environmentObject(windowActivation)
}
.defaultPosition(.center)
.defaultSize(width: 1060, height: 530)
Expand All @@ -62,7 +59,9 @@ struct EqualiserMain: App {
MenuBarExtra("Equaliser", systemImage: "slider.vertical.3") {
MenuBarContentView()
.environmentObject(store)
.environmentObject(windowActivation)
.onAppear {
windowActivation.launchAsMenuBarApp()
// Wire up appDelegate reference after @StateObject is initialized.
// MenuBarExtra is always visible, so this will always fire.
appDelegate.setStore(store)
Expand All @@ -74,6 +73,7 @@ struct EqualiserMain: App {
Settings {
SettingsView()
.environmentObject(store)
.environmentObject(windowActivation)
}
}
}
Expand Down
57 changes: 57 additions & 0 deletions src/app/WindowActivationController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import AppKit

@MainActor
protocol ActivationPolicyApplying {
func apply(_ policy: NSApplication.ActivationPolicy)
}

@MainActor
struct NSApplicationActivationPolicyApplier: ActivationPolicyApplying {
func apply(_ policy: NSApplication.ActivationPolicy) {
NSApp.setActivationPolicy(policy)
}
}

@MainActor
final class WindowActivationController: ObservableObject {
enum WindowRole: Hashable {
case equaliser
case settings
}

private let policyApplier: ActivationPolicyApplying
private var visibleWindows: Set<WindowRole> = []
private var currentPolicy: NSApplication.ActivationPolicy?

init(policyApplier: ActivationPolicyApplying = NSApplicationActivationPolicyApplier()) {
self.policyApplier = policyApplier
}

func launchAsMenuBarApp() {
guard visibleWindows.isEmpty else { return }
apply(.accessory)
}

func prepareToShowWindow() {
apply(.regular)
}

func windowBecameVisible(_ role: WindowRole) {
visibleWindows.insert(role)
apply(.regular)
}

func windowBecameHidden(_ role: WindowRole) {
visibleWindows.remove(role)

if visibleWindows.isEmpty {
apply(.accessory)
}
}

private func apply(_ policy: NSApplication.ActivationPolicy) {
guard currentPolicy != policy else { return }
currentPolicy = policy
policyApplier.apply(policy)
}
}
4 changes: 4 additions & 0 deletions src/ui/views/main/EQWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Combine
struct EQWindowView: View {
@Environment(\.openSettings) private var openSettings
@EnvironmentObject var store: EqualiserStore
@EnvironmentObject var windowActivation: WindowActivationController
@StateObject private var driverManager = DriverManager.shared
@State private var showCompareHelp = false
@State private var metersEnabledUI = false
Expand Down Expand Up @@ -242,9 +243,11 @@ struct EQWindowView: View {
}
)
.onAppear {
windowActivation.windowBecameVisible(.equaliser)
store.meterStore.windowBecameVisible()
}
.onDisappear {
windowActivation.windowBecameHidden(.equaliser)
store.meterStore.windowBecameHidden()
}
.sheet(isPresented: $showDriverSheet) {
Expand Down Expand Up @@ -333,4 +336,5 @@ struct SystemEQToggleView: View {
#Preview("EQ Window") {
EQWindowView()
.environmentObject(EqualiserStore())
.environmentObject(WindowActivationController())
}
2 changes: 2 additions & 0 deletions src/ui/views/main/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SwiftUI
/// Designed with compact controls: each control (label + picker) in its own row.
struct MenuBarContentView: View {
@EnvironmentObject var store: EqualiserStore
@EnvironmentObject var windowActivation: WindowActivationController
@Environment(\.openWindow) private var openWindow
@Environment(\.dismiss) private var dismiss

Expand Down Expand Up @@ -92,6 +93,7 @@ struct MenuBarContentView: View {
VStack(spacing: 0) {
// Open Equaliser button - full width
Button {
windowActivation.prepareToShowWindow()
openWindow(id: "equaliser")
NSApp.activate(ignoringOtherApps: true)
dismiss()
Expand Down
6 changes: 6 additions & 0 deletions src/ui/views/main/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ enum SettingsTab: String {

struct SettingsView: View {
@EnvironmentObject var store: EqualiserStore
@EnvironmentObject var windowActivation: WindowActivationController
@State private var selectedTab: SettingsTab = .display

/// Allows programmatic selection of tab (e.g., to show Driver tab when update required).
Expand Down Expand Up @@ -41,13 +42,18 @@ struct SettingsView: View {
}
.frame(width: 450, height: 400)
.onAppear {
windowActivation.windowBecameVisible(.settings)

// Auto-select Driver tab if update required
if let initialTab = initialTab {
selectedTab = initialTab
// Clear the flag so user doesn't get forced back on subsequent opens
store.clearDriverUpdateRequired()
}
}
.onDisappear {
windowActivation.windowBecameHidden(.settings)
}
}
}

Expand Down
84 changes: 84 additions & 0 deletions tests/app/WindowActivationControllerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import AppKit
import XCTest
@testable import Equaliser

@MainActor
final class WindowActivationControllerTests: XCTestCase {
func testPrepareToShowWindow_requestsRegularActivation() {
let policyApplier = RecordingActivationPolicyApplier()
let controller = WindowActivationController(policyApplier: policyApplier)

controller.prepareToShowWindow()

XCTAssertEqual(policyApplier.policies, [.regular])
}

func testWindowBecameVisible_requestsRegularActivation() {
let policyApplier = RecordingActivationPolicyApplier()
let controller = WindowActivationController(policyApplier: policyApplier)

controller.windowBecameVisible(.equaliser)

XCTAssertEqual(policyApplier.policies, [.regular])
}

func testWindowBecameHidden_requestsAccessoryWhenLastWindowCloses() {
let policyApplier = RecordingActivationPolicyApplier()
let controller = WindowActivationController(policyApplier: policyApplier)

controller.windowBecameVisible(.equaliser)
controller.windowBecameHidden(.equaliser)

XCTAssertEqual(policyApplier.policies, [.regular, .accessory])
}

func testWindowBecameHidden_keepsRegularWhenAnotherWindowIsVisible() {
let policyApplier = RecordingActivationPolicyApplier()
let controller = WindowActivationController(policyApplier: policyApplier)

controller.windowBecameVisible(.equaliser)
controller.windowBecameVisible(.settings)
controller.windowBecameHidden(.equaliser)

XCTAssertEqual(policyApplier.policies, [.regular])
}

func testWindowVisibilityChanges_areIdempotent() {
let policyApplier = RecordingActivationPolicyApplier()
let controller = WindowActivationController(policyApplier: policyApplier)

controller.windowBecameVisible(.equaliser)
controller.windowBecameVisible(.equaliser)
controller.windowBecameHidden(.equaliser)
controller.windowBecameHidden(.equaliser)

XCTAssertEqual(policyApplier.policies, [.regular, .accessory])
}

func testLaunchAsMenuBarApp_requestsAccessoryActivation() {
let policyApplier = RecordingActivationPolicyApplier()
let controller = WindowActivationController(policyApplier: policyApplier)

controller.launchAsMenuBarApp()

XCTAssertEqual(policyApplier.policies, [.accessory])
}

func testLaunchAsMenuBarApp_keepsRegularWhenWindowIsVisible() {
let policyApplier = RecordingActivationPolicyApplier()
let controller = WindowActivationController(policyApplier: policyApplier)

controller.windowBecameVisible(.equaliser)
controller.launchAsMenuBarApp()

XCTAssertEqual(policyApplier.policies, [.regular])
}
}

private final class RecordingActivationPolicyApplier: ActivationPolicyApplying {
private(set) var policies: [NSApplication.ActivationPolicy] = []

func apply(_ policy: NSApplication.ActivationPolicy) {
policies.append(policy)
}
}