diff --git a/Sources/Fluid/AppDelegate.swift b/Sources/Fluid/AppDelegate.swift index b37aa3fc..942759fb 100644 --- a/Sources/Fluid/AppDelegate.swift +++ b/Sources/Fluid/AppDelegate.swift @@ -6,6 +6,7 @@ // import AppKit +import Carbon import PromiseKit import SwiftUI import UserNotifications @@ -14,11 +15,19 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele private var updateCheckTimer: Timer? private var didRevealMainWindowOnLaunch = false private var didRequestMainWindowReopen = false + private var shouldSuppressNextReopenActivation = false + private var wasLaunchedAsLoginItem = false func applicationDidFinishLaunching(_ notification: Notification) { // Bring up file logging + crash handlers immediately during launch. _ = FileLogger.shared - DebugLogger.shared.info("Application launched", source: "AppDelegate") + // Must be read during the launch callback - the current Apple Event identifies + // login-item launches (used to optionally start silently, see issue #369). + self.wasLaunchedAsLoginItem = Self.detectLoginItemLaunch() + DebugLogger.shared.info( + "Application launched [loginItemLaunch=\(self.wasLaunchedAsLoginItem)]", + source: "AppDelegate" + ) UNUserNotificationCenter.current().delegate = self // Initialize app settings (dock visibility, etc.) @@ -66,11 +75,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if self.shouldSuppressNextReopenActivation { + self.shouldSuppressNextReopenActivation = false + return true + } + // Ensure dock-icon reopen always foregrounds FluidVoice. sender.activate(ignoringOtherApps: true) - self.bringMainWindowToFrontIfPresent() - return true + return !self.bringMainWindowToFrontIfPresent() } func userNotificationCenter( @@ -97,36 +110,92 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele completionHandler() } + /// Whether this launch came from macOS Login Items. Reads the launch Apple Event, + /// which is only valid during applicationDidFinishLaunching. + /// FLUID_SIMULATE_LOGIN_LAUNCH=1 forces this on for testing, since real login-item + /// launches can only be produced by logging in. + private static func detectLoginItemLaunch() -> Bool { + if ProcessInfo.processInfo.environment["FLUID_SIMULATE_LOGIN_LAUNCH"] == "1" { + return true + } + guard let event = NSAppleEventManager.shared().currentAppleEvent else { return false } + return event.eventID == AEEventID(kAEOpenApplication) + && event.paramDescriptor(forKeyword: AEKeyword(keyAEPropData))?.enumCodeValue + == OSType(keyAELaunchedAsLogInItem) + } + private func openMainWindowOnLaunch() { NSApp.setActivationPolicy(SettingsStore.shared.showInDock ? .regular : .accessory) + // Users can opt out of showing the window for login-item launches (#369). + // The window must still be CREATED either way - ContentView's appearance + // bootstraps the menu bar and services - so the silent path realizes it + // invisibly instead of skipping it. + let revealWindow = !self.wasLaunchedAsLoginItem || SettingsStore.shared.showMainWindowAtLoginLaunch + for delay in [0.1, 0.6, 1.2, 2.5, 4.0] { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self else { return } guard self.didRevealMainWindowOnLaunch == false else { return } - NSApp.unhide(nil) - NSApp.activate(ignoringOtherApps: true) + if revealWindow { + NSApp.unhide(nil) + NSApp.activate(ignoringOtherApps: true) - if self.bringMainWindowToFrontIfPresent() { + if self.bringMainWindowToFrontIfPresent() { + self.didRevealMainWindowOnLaunch = true + return + } + } else if self.bootMainWindowHiddenIfPresent() { self.didRevealMainWindowOnLaunch = true return } DebugLogger.shared.debug("Main window not ready during launch reveal retry", source: "AppDelegate") if delay >= 0.6 { - self.requestMainWindowReopenIfNeeded() + self.requestMainWindowReopenIfNeeded(activate: revealWindow) } } } } - private func requestMainWindowReopenIfNeeded() { + /// Realize the main window invisibly so ContentView's startup runs, then order it out. + /// Used for login-item launches when "Show window when launched at login" is off. + @discardableResult + private func bootMainWindowHiddenIfPresent() -> Bool { + guard let mainWindow = NSApp.windows.first(where: self.isMainWindow) else { return false } + + let originalAlpha = mainWindow.alphaValue + mainWindow.alphaValue = 0 + mainWindow.orderFrontRegardless() + + // Give ContentView.onAppear time to finish its startup work (menu bar setup plus + // the delayed service initialization), then put the window away. Alpha is restored + // so opening it later from the menu bar shows it normally. + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak mainWindow] in + guard let mainWindow, mainWindow.alphaValue <= 0.01 else { return } + mainWindow.orderOut(nil) + mainWindow.alphaValue = originalAlpha + DebugLogger.shared.info( + "Main window booted hidden (show-at-login-launch disabled)", + source: "AppDelegate" + ) + } + return true + } + + private func requestMainWindowReopenIfNeeded(activate: Bool = true) { guard !self.didRequestMainWindowReopen else { return } self.didRequestMainWindowReopen = true let configuration = NSWorkspace.OpenConfiguration() - configuration.activates = true + configuration.activates = activate + if !activate { + self.shouldSuppressNextReopenActivation = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.shouldSuppressNextReopenActivation = false + } + } DebugLogger.shared.info("Requesting LaunchServices reopen to create SwiftUI main window", source: "AppDelegate") NSWorkspace.shared.openApplication(at: Bundle.main.bundleURL, configuration: configuration) { _, error in @@ -148,6 +217,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele @discardableResult private func bringMainWindowToFrontIfPresent() -> Bool { if let mainWindow = NSApp.windows.first(where: self.isMainWindow) { + if mainWindow.alphaValue <= 0.01 { + mainWindow.alphaValue = 1 + } mainWindow.orderFrontRegardless() mainWindow.makeKeyAndOrderFront(nil) DebugLogger.shared.debug("Brought main window to front", source: "AppDelegate") diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index b82e15ba..89fb7ee3 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -35,6 +35,7 @@ struct SettingsBackupPayload: Codable, Equatable { let cancelRecordingHotkeyShortcut: HotkeyShortcut let showThinkingTokens: Bool let hideFromDockAndAppSwitcher: Bool + let showMainWindowAtLoginLaunch: Bool? let accentColorOption: SettingsStore.AccentColorOption let transcriptionStartSound: SettingsStore.TranscriptionStartSound let transcriptionSoundVolume: Float diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 81358f83..2f9a4cd8 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1100,6 +1100,22 @@ final class SettingsStore: ObservableObject { } } + /// Show the main window when macOS launches FluidVoice at login (default: ON, matching + /// current behavior). When off, login launches boot silently in the menu bar. Manual + /// launches always show the window. Default-true semantics so existing installs keep + /// their current behavior. + var showMainWindowAtLoginLaunch: Bool { + get { + let value = self.defaults.object(forKey: Keys.showMainWindowAtLoginLaunch) + if value == nil { return true } + return self.defaults.bool(forKey: Keys.showMainWindowAtLoginLaunch) + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.showMainWindowAtLoginLaunch) + } + } + /// Anonymous analytics toggle (default: ON). Uses default-true semantics so existing installs /// upgrading to a version that includes analytics do not silently default to OFF. var shareAnonymousAnalytics: Bool { @@ -2338,6 +2354,7 @@ final class SettingsStore: ObservableObject { cancelRecordingHotkeyShortcut: self.cancelRecordingHotkeyShortcut, showThinkingTokens: self.showThinkingTokens, hideFromDockAndAppSwitcher: self.hideFromDockAndAppSwitcher, + showMainWindowAtLoginLaunch: self.showMainWindowAtLoginLaunch, accentColorOption: self.accentColorOption, transcriptionStartSound: self.transcriptionStartSound, transcriptionSoundVolume: self.transcriptionSoundVolume, @@ -2419,6 +2436,7 @@ final class SettingsStore: ObservableObject { self.cancelRecordingHotkeyShortcut = payload.cancelRecordingHotkeyShortcut self.showThinkingTokens = payload.showThinkingTokens self.hideFromDockAndAppSwitcher = payload.hideFromDockAndAppSwitcher + self.showMainWindowAtLoginLaunch = payload.showMainWindowAtLoginLaunch ?? true self.accentColorOption = payload.accentColorOption self.transcriptionStartSound = payload.transcriptionStartSound self.transcriptionSoundVolume = payload.transcriptionSoundVolume @@ -3625,6 +3643,7 @@ private extension SettingsStore { /// Keys enum Keys { static let enableAIProcessing = "EnableAIProcessing" + static let showMainWindowAtLoginLaunch = "ShowMainWindowAtLoginLaunch" static let dictationPromptOff = "DictationPromptOff" static let enableDebugLogs = "EnableDebugLogs" static let availableAIModels = "AvailableAIModels" diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index e493437b..dc59882a 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -746,6 +746,9 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { private func bringToFront(_ window: NSWindow) { // Keep ordering explicit to avoid "opened but behind other apps" behavior. + if window.alphaValue <= 0.01 { + window.alphaValue = 1 + } window.orderFrontRegardless() window.makeKeyAndOrderFront(nil) } diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 5ab7208b..d2a877c0 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -214,6 +214,17 @@ struct SettingsView: View { ) Divider().opacity(0.2) + // Show window when launched at login + self.settingsToggleRow( + title: "Show window when launched at login", + description: "When off, FluidVoice starts silently in the menu bar at login. Opening the app yourself always shows the window.", + isOn: Binding( + get: { SettingsStore.shared.showMainWindowAtLoginLaunch }, + set: { SettingsStore.shared.showMainWindowAtLoginLaunch = $0 } + ) + ) + Divider().opacity(0.2) + // Hide from Dock & App Switcher self.settingsToggleRow( title: "Hide from Dock & App Switcher",