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
68 changes: 64 additions & 4 deletions Speaky/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -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)
}

Expand All @@ -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)
}
}
6 changes: 6 additions & 0 deletions Speaky/Models/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
}
Expand Down Expand Up @@ -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") }
}
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions Speaky/Views/MainWindow/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
41 changes: 41 additions & 0 deletions SpeakyTests/AppSettingsTests.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
37 changes: 37 additions & 0 deletions SpeakyTests/DockVisibilityPolicyTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}