187 lines
5.9 KiB
Swift
Executable File
187 lines
5.9 KiB
Swift
Executable File
import ComposableArchitecture
|
|
import HexCore
|
|
import SwiftUI
|
|
|
|
private let appLogger = HexLog.app
|
|
private let cacheLogger = HexLog.caches
|
|
|
|
class HexAppDelegate: NSObject, NSApplicationDelegate {
|
|
var invisibleWindow: InvisibleWindow?
|
|
var settingsWindow: NSWindow?
|
|
var statusItem: NSStatusItem!
|
|
private var launchedAtLogin = false
|
|
|
|
@Dependency(\.soundEffects) var soundEffect
|
|
@Dependency(\.recording) var recording
|
|
@Shared(.hexSettings) var hexSettings: HexSettings
|
|
|
|
func applicationDidFinishLaunching(_: Notification) {
|
|
DiagnosticsLogging.bootstrapIfNeeded()
|
|
// Ensure Parakeet/FluidAudio caches live under Application Support, not ~/.cache
|
|
configureLocalCaches()
|
|
if isTesting {
|
|
appLogger.debug("Running in testing mode")
|
|
return
|
|
}
|
|
|
|
Task {
|
|
await soundEffect.preloadSounds()
|
|
await soundEffect.setEnabled(hexSettings.soundEffectsEnabled)
|
|
}
|
|
launchedAtLogin = wasLaunchedAtLogin()
|
|
appLogger.info("Application did finish launching")
|
|
appLogger.notice("launchedAtLogin = \(self.launchedAtLogin)")
|
|
|
|
// Set activation policy first
|
|
updateAppMode()
|
|
|
|
// Add notification observer
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleAppModeUpdate),
|
|
name: .updateAppMode,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handlePresentSettingsWindow),
|
|
name: .presentSettingsWindow,
|
|
object: nil
|
|
)
|
|
|
|
// Start long-running app effects (global hotkeys, permissions, etc.)
|
|
startLifecycleTasksIfNeeded()
|
|
|
|
// Then present main views
|
|
presentMainView()
|
|
|
|
guard shouldOpenForegroundUIOnLaunch else {
|
|
appLogger.notice("Suppressing foreground windows for login launch")
|
|
return
|
|
}
|
|
|
|
presentSettingsView()
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
|
|
private var shouldOpenForegroundUIOnLaunch: Bool {
|
|
// When Hex launches at login, stay quietly in the menu bar regardless of
|
|
// the dock-icon preference. Users who enabled "Open on Login" expect a
|
|
// background launch; the Settings window can be opened later from the
|
|
// menu bar item or ⌘, when needed.
|
|
!launchedAtLogin
|
|
}
|
|
|
|
private func wasLaunchedAtLogin() -> Bool {
|
|
guard let event = NSAppleEventManager.shared().currentAppleEvent else {
|
|
return false
|
|
}
|
|
|
|
return event.eventID == AEEventID(kAEOpenApplication)
|
|
&& event.paramDescriptor(forKeyword: AEKeyword(keyAEPropData))?.enumCodeValue == AEEventClass(keyAELaunchedAsLogInItem)
|
|
}
|
|
|
|
private func startLifecycleTasksIfNeeded() {
|
|
Task { @MainActor in
|
|
await HexApp.appStore.send(.task).finish()
|
|
}
|
|
}
|
|
|
|
/// Sets XDG_CACHE_HOME so FluidAudio stores models under our app's
|
|
/// Application Support folder, keeping everything in one place.
|
|
private func configureLocalCaches() {
|
|
do {
|
|
let cache = try URL.hexApplicationSupport.appendingPathComponent("cache", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: cache, withIntermediateDirectories: true)
|
|
setenv("XDG_CACHE_HOME", cache.path, 1)
|
|
cacheLogger.info("XDG_CACHE_HOME set to \(cache.path)")
|
|
} catch {
|
|
cacheLogger.error("Failed to configure local caches: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
func presentMainView() {
|
|
guard invisibleWindow == nil else {
|
|
return
|
|
}
|
|
let transcriptionStore = HexApp.appStore.scope(state: \.transcription, action: \.transcription)
|
|
let transcriptionView = TranscriptionView(store: transcriptionStore).padding().padding(.top).padding(.top)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
invisibleWindow = InvisibleWindow.fromView(transcriptionView)
|
|
invisibleWindow?.orderFrontRegardless()
|
|
}
|
|
|
|
func presentSettingsView() {
|
|
if let settingsWindow = settingsWindow {
|
|
settingsWindow.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
return
|
|
}
|
|
|
|
let settingsView = AppView(store: HexApp.appStore)
|
|
let settingsWindow = NSWindow(
|
|
contentRect: .init(x: 0, y: 0, width: 700, height: 700),
|
|
styleMask: [.titled, .fullSizeContentView, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
settingsWindow.titleVisibility = .visible
|
|
settingsWindow.contentView = NSHostingView(rootView: settingsView)
|
|
settingsWindow.isReleasedWhenClosed = false
|
|
settingsWindow.minSize = .init(width: 620, height: 560)
|
|
settingsWindow.setFrameAutosaveName("Settings")
|
|
settingsWindow.center()
|
|
settingsWindow.toolbarStyle = NSWindow.ToolbarStyle.unified
|
|
settingsWindow.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
self.settingsWindow = settingsWindow
|
|
}
|
|
|
|
@objc private func handleAppModeUpdate() {
|
|
Task {
|
|
await updateAppMode()
|
|
}
|
|
}
|
|
|
|
@objc private func handlePresentSettingsWindow() {
|
|
presentSettingsView()
|
|
}
|
|
|
|
@MainActor
|
|
private func updateAppMode() {
|
|
appLogger.debug("showDockIcon = \(self.hexSettings.showDockIcon)")
|
|
if self.hexSettings.showDockIcon {
|
|
NSApp.setActivationPolicy(.regular)
|
|
} else {
|
|
NSApp.setActivationPolicy(.accessory)
|
|
}
|
|
}
|
|
|
|
func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool {
|
|
presentSettingsView()
|
|
return true
|
|
}
|
|
|
|
func applicationWillTerminate(_: Notification) {
|
|
// Wait for audio teardown before the process exits: a fire-and-forget Task here
|
|
// raced process exit, crashing inside AVAudioEngine teardown while tap callbacks
|
|
// were still in flight (#245). Pump the main run loop while waiting instead of
|
|
// blocking outright - cleanup() hops to the main actor/main queue (media-key
|
|
// resume, Core Audio listener removal), which a blocked main thread would deadlock.
|
|
let recording = recording
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
Task.detached {
|
|
await recording.cleanup()
|
|
semaphore.signal()
|
|
}
|
|
let deadline = Date().addingTimeInterval(3)
|
|
while semaphore.wait(timeout: .now()) == .timedOut {
|
|
guard Date() < deadline else {
|
|
appLogger.error("Recording cleanup timed out during app termination")
|
|
return
|
|
}
|
|
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
|
|
}
|
|
}
|
|
}
|