diff --git a/Speaky/AppDelegate.swift b/Speaky/AppDelegate.swift index 2e0b3eb..496f180 100644 --- a/Speaky/AppDelegate.swift +++ b/Speaky/AppDelegate.swift @@ -1,12 +1,42 @@ import AppKit +enum DockVisibilityPolicy { + static func activationPolicy( + showInDock: Bool, + hasOpenWindow: Bool + ) -> NSApplication.ActivationPolicy { + showInDock || hasOpenWindow ? .regular : .accessory + } +} + +@MainActor final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { - var appState: AppState? + var appState: AppState? { + didSet { + updateActivationPolicy() + } + } func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.regular) + let notificationCenter = NotificationCenter.default + for name in [ + NSWindow.didBecomeMainNotification, + NSWindow.didMiniaturizeNotification, + NSWindow.didDeminiaturizeNotification, + NSWindow.willCloseNotification + ] { + notificationCenter.addObserver( + self, + selector: #selector(windowVisibilityDidChange), + name: name, + object: nil + ) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.updateActivationPolicy() NSApp.activate(ignoringOtherApps: true) } @@ -18,13 +48,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { false } - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - if !flag { - for window in NSApp.windows where window.canBecomeMain { + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows _: Bool) -> Bool { + let hasVisibleUserFacingWindow = NSApp.windows.contains { + isUserFacing($0) && ($0.isVisible || $0.isMiniaturized) + } + + if !hasVisibleUserFacingWindow { + NSApp.setActivationPolicy(.regular) + for window in NSApp.windows where isUserFacing(window) { window.makeKeyAndOrderFront(nil) } NSApp.activate(ignoringOtherApps: true) } return true } + + @objc private func windowVisibilityDidChange(_ notification: Notification) { + DispatchQueue.main.async { [weak self] in + self?.updateActivationPolicy() + } + } + + private func updateActivationPolicy() { + let showInDock = appState?.settings.showInDock + ?? (UserDefaults.standard.object(forKey: AppSettings.showInDockDefaultsKey) as? Bool ?? false) + let hasOpenWindow = NSApp.windows.contains { + isUserFacing($0) && ($0.isVisible || $0.isMiniaturized) + } + let policy = DockVisibilityPolicy.activationPolicy( + showInDock: showInDock, + hasOpenWindow: hasOpenWindow + ) + + guard NSApp.activationPolicy() != policy else { return } + NSApp.setActivationPolicy(policy) + } + + private func isUserFacing(_ window: NSWindow) -> Bool { + window.canBecomeMain && window.styleMask.contains(.titled) + } } diff --git a/Speaky/Models/Settings.swift b/Speaky/Models/Settings.swift index 92005ac..961df9e 100644 --- a/Speaky/Models/Settings.swift +++ b/Speaky/Models/Settings.swift @@ -77,6 +77,8 @@ enum BackgroundAudioMode: String, CaseIterable { @Observable final class AppSettings { + static let showInDockDefaultsKey = "showInDock" + var selectedModelID: String { didSet { UserDefaults.standard.set(selectedModelID, forKey: "selectedModelID") } } @@ -110,6 +112,9 @@ final class AppSettings { var checkForUpdates: Bool { didSet { UserDefaults.standard.set(checkForUpdates, forKey: "checkForUpdates") } } + var showInDock: Bool { + didSet { UserDefaults.standard.set(showInDock, forKey: Self.showInDockDefaultsKey) } + } var cleanupInterval: String { didSet { UserDefaults.standard.set(cleanupInterval, forKey: "cleanupInterval") } } @@ -174,6 +179,7 @@ final class AppSettings { } self.soundEffectsEnabled = UserDefaults.standard.object(forKey: "soundEffectsEnabled") as? Bool ?? true self.checkForUpdates = UserDefaults.standard.object(forKey: "checkForUpdates") as? Bool ?? true + self.showInDock = UserDefaults.standard.object(forKey: Self.showInDockDefaultsKey) as? Bool ?? false self.cleanupInterval = UserDefaults.standard.string(forKey: "cleanupInterval") ?? "Never" let deviceVal = UserDefaults.standard.object(forKey: "selectedAudioDevice") as? UInt32 self.selectedAudioDevice = deviceVal diff --git a/Speaky/Views/MainWindow/SettingsView.swift b/Speaky/Views/MainWindow/SettingsView.swift index cb21174..4a2edf6 100644 --- a/Speaky/Views/MainWindow/SettingsView.swift +++ b/Speaky/Views/MainWindow/SettingsView.swift @@ -38,6 +38,18 @@ struct SettingsView: View { )) } + // Application + Section("Application") { + Toggle("Show in Dock", isOn: Binding( + get: { settings.showInDock }, + set: { settings.showInDock = $0 } + )) + + Text("When disabled, Speaky only appears in the Dock while its window is open.") + .font(.caption) + .foregroundStyle(Theme.textTertiary) + } + // Behavior Section("Behavior") { Toggle("Auto-paste after transcription", isOn: Binding( diff --git a/SpeakyTests/AppSettingsTests.swift b/SpeakyTests/AppSettingsTests.swift new file mode 100644 index 0000000..7a6f85a --- /dev/null +++ b/SpeakyTests/AppSettingsTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +@testable import Speaky + +@Suite("AppSettings", .serialized) +struct AppSettingsTests { + + @Test("Show in Dock is disabled by default") + func showInDockDefault() { + withRestoredShowInDockPreference { + UserDefaults.standard.removeObject(forKey: AppSettings.showInDockDefaultsKey) + + #expect(AppSettings().showInDock == false) + } + } + + @Test("Show in Dock is persisted") + func showInDockPersistence() { + withRestoredShowInDockPreference { + let settings = AppSettings() + + settings.showInDock = true + + #expect(AppSettings().showInDock) + } + } + + private func withRestoredShowInDockPreference(_ body: () -> Void) { + let defaults = UserDefaults.standard + let savedValue = defaults.object(forKey: AppSettings.showInDockDefaultsKey) + defer { + if let savedValue { + defaults.set(savedValue, forKey: AppSettings.showInDockDefaultsKey) + } else { + defaults.removeObject(forKey: AppSettings.showInDockDefaultsKey) + } + } + + body() + } +} diff --git a/SpeakyTests/DockVisibilityPolicyTests.swift b/SpeakyTests/DockVisibilityPolicyTests.swift new file mode 100644 index 0000000..9cacf0e --- /dev/null +++ b/SpeakyTests/DockVisibilityPolicyTests.swift @@ -0,0 +1,37 @@ +import AppKit +import Testing +@testable import Speaky + +@Suite("Dock visibility policy") +struct DockVisibilityPolicyTests { + + @Test("hides from Dock when disabled and no window is open") + func hiddenWithoutWindow() { + let policy = DockVisibilityPolicy.activationPolicy( + showInDock: false, + hasOpenWindow: false + ) + + #expect(policy == .accessory) + } + + @Test("shows in Dock while a window is open") + func shownWithOpenWindow() { + let policy = DockVisibilityPolicy.activationPolicy( + showInDock: false, + hasOpenWindow: true + ) + + #expect(policy == .regular) + } + + @Test("stays in Dock when enabled") + func shownWhenEnabled() { + let policy = DockVisibilityPolicy.activationPolicy( + showInDock: true, + hasOpenWindow: false + ) + + #expect(policy == .regular) + } +}