diff --git a/src/app/EqualiserApp.swift b/src/app/EqualiserApp.swift index b2de5d6..702f728 100644 --- a/src/app/EqualiserApp.swift +++ b/src/app/EqualiserApp.swift @@ -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() { @@ -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. @@ -39,6 +35,7 @@ struct EqualiserMain: App { Window("Equaliser", id: "equaliser") { EQWindowView() .environmentObject(store) + .environmentObject(windowActivation) } .defaultPosition(.center) .defaultSize(width: 1060, height: 530) @@ -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) @@ -74,6 +73,7 @@ struct EqualiserMain: App { Settings { SettingsView() .environmentObject(store) + .environmentObject(windowActivation) } } } diff --git a/src/app/WindowActivationController.swift b/src/app/WindowActivationController.swift new file mode 100644 index 0000000..9c24b05 --- /dev/null +++ b/src/app/WindowActivationController.swift @@ -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 = [] + 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) + } +} diff --git a/src/ui/views/main/EQWindowView.swift b/src/ui/views/main/EQWindowView.swift index 8405a66..e672ea6 100644 --- a/src/ui/views/main/EQWindowView.swift +++ b/src/ui/views/main/EQWindowView.swift @@ -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 @@ -242,9 +243,11 @@ struct EQWindowView: View { } ) .onAppear { + windowActivation.windowBecameVisible(.equaliser) store.meterStore.windowBecameVisible() } .onDisappear { + windowActivation.windowBecameHidden(.equaliser) store.meterStore.windowBecameHidden() } .sheet(isPresented: $showDriverSheet) { @@ -333,4 +336,5 @@ struct SystemEQToggleView: View { #Preview("EQ Window") { EQWindowView() .environmentObject(EqualiserStore()) + .environmentObject(WindowActivationController()) } diff --git a/src/ui/views/main/MenuBarView.swift b/src/ui/views/main/MenuBarView.swift index 508c752..4f1c436 100644 --- a/src/ui/views/main/MenuBarView.swift +++ b/src/ui/views/main/MenuBarView.swift @@ -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 @@ -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() diff --git a/src/ui/views/main/SettingsView.swift b/src/ui/views/main/SettingsView.swift index 6d0f74c..48c61ea 100644 --- a/src/ui/views/main/SettingsView.swift +++ b/src/ui/views/main/SettingsView.swift @@ -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). @@ -41,6 +42,8 @@ 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 @@ -48,6 +51,9 @@ struct SettingsView: View { store.clearDriverUpdateRequired() } } + .onDisappear { + windowActivation.windowBecameHidden(.settings) + } } } diff --git a/tests/app/WindowActivationControllerTests.swift b/tests/app/WindowActivationControllerTests.swift new file mode 100644 index 0000000..9490453 --- /dev/null +++ b/tests/app/WindowActivationControllerTests.swift @@ -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) + } +}