| | |
| | | |
| | | // MARK: - App Delegate (menubar + overlay window + hotkey) |
| | | |
| | | private enum HotkeyRegistrationAttempt { |
| | | case success(EventHotKeyRef) |
| | | case failure(OSStatus) |
| | | } |
| | | |
| | | final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { |
| | | static private(set) weak var shared: AppDelegate? |
| | | |
| | | private static let showDockIconKey = "showDockIcon" |
| | | private static let statusItemAutosaveName = AppIdentity.statusItemAutosaveName |
| | | private static let statusItemButtonIdentifier = NSUserInterfaceItemIdentifier("TagLauncherStatusItemButton") |
| | |
| | | private var quickSearchHotkeyRef: EventHotKeyRef? |
| | | private var overlaySpaceHotkeyRef: EventHotKeyRef? |
| | | private var hotkeyEventHandlerInstalled = false |
| | | private var configuredHotkeysSuspendedForRecording = false |
| | | private var isQuickSearchOpen = false |
| | | private var quickSearchShouldHideOverlayOnClose = false |
| | | private var quickSearchOnlyOverlaySession = false |
| | |
| | | private var overlayOpenedByQuickSearchOnly = false |
| | | private var didFinishLaunching = false |
| | | private var pendingShowOverlayInvocation = false |
| | | |
| | | override init() { |
| | | super.init() |
| | | Self.shared = self |
| | | } |
| | | |
| | | private lazy var overlayController = OverlayWindowController( |
| | | dependencies: OverlayWindowController.Dependencies( |
| | |
| | | observeApplicationMenuChanges() |
| | | observeChromeSettings() |
| | | observeLanguageChanges() |
| | | observeProEntitlementChanges() |
| | | setupLaunchAtLogin() |
| | | suppressReopenUntil = Date().addingTimeInterval(1.0) |
| | | didFinishLaunching = true |
| | |
| | | if LauncherHotkeyRegistrationStore.state(for: .main) == .failed { |
| | | return tr("menu.showShortcutUnavailable") |
| | | } |
| | | return "\(tr("menu.showAppList")) \(LauncherHotkey.main.displayString)" |
| | | return "\(tr("menu.showAppList")) \(LauncherHotkeySettings.effectiveHotkey(for: .main).displayString)" |
| | | } |
| | | |
| | | private func observeApplicationMenuChanges() { |
| | |
| | | // MARK: - Global Hotkeys |
| | | |
| | | private func registerConfiguredHotkeys() { |
| | | guard !configuredHotkeysSuspendedForRecording else { return } |
| | | installHotkeyEventHandlerIfNeeded() |
| | | registerFixedHotkey(.main) |
| | | registerFixedHotkey(.quickSearch) |
| | | registerEffectiveHotkey(.main) |
| | | registerEffectiveHotkey(.quickSearch) |
| | | } |
| | | |
| | | private func registerFixedHotkey(_ kind: LauncherHotkeyKind) { |
| | | func suspendConfiguredHotkeysForRecording() { |
| | | guard !configuredHotkeysSuspendedForRecording else { return } |
| | | configuredHotkeysSuspendedForRecording = true |
| | | unregisterHotkey(for: .main) |
| | | unregisterHotkey(for: .quickSearch) |
| | | } |
| | | |
| | | func resumeConfiguredHotkeysAfterRecording() { |
| | | guard configuredHotkeysSuspendedForRecording else { return } |
| | | configuredHotkeysSuspendedForRecording = false |
| | | registerConfiguredHotkeys() |
| | | } |
| | | |
| | | private func registerEffectiveHotkey(_ kind: LauncherHotkeyKind) { |
| | | unregisterHotkey(for: kind) |
| | | let hotkey = kind.hotkey |
| | | let hotkey = LauncherHotkeySettings.effectiveHotkey(for: kind) |
| | | registerAndStoreHotkey(hotkey, for: kind, markFailure: true) |
| | | } |
| | | |
| | | @discardableResult |
| | | func applyCustomHotkey(_ hotkey: LauncherHotkey, for kind: LauncherHotkeyKind) -> LauncherHotkeyCustomizationResult { |
| | | guard ProEntitlementPolicy.isUnlocked(.customHotkeys) else { |
| | | return .locked |
| | | } |
| | | if let error = LauncherHotkeySettings.validationError(for: hotkey, kind: kind) { |
| | | return .invalid(error) |
| | | } |
| | | return activateHotkey(hotkey, for: kind, persistCustom: true) |
| | | } |
| | | |
| | | @discardableResult |
| | | func restoreDefaultHotkey(for kind: LauncherHotkeyKind) -> LauncherHotkeyCustomizationResult { |
| | | guard ProEntitlementPolicy.isUnlocked(.customHotkeys) else { |
| | | return .locked |
| | | } |
| | | return activateHotkey(LauncherHotkeySettings.defaultHotkey(for: kind), for: kind, persistCustom: false) |
| | | } |
| | | |
| | | private func activateHotkey( |
| | | _ hotkey: LauncherHotkey, |
| | | for kind: LauncherHotkeyKind, |
| | | persistCustom: Bool |
| | | ) -> LauncherHotkeyCustomizationResult { |
| | | installHotkeyEventHandlerIfNeeded() |
| | | |
| | | if LauncherHotkeySettings.effectiveHotkey(for: kind) == hotkey, |
| | | hotkeyRef(for: kind) != nil { |
| | | if persistCustom { |
| | | LauncherHotkeySettings.save(hotkey, for: kind) |
| | | LauncherHotkeyRegistrationStore.setActive(for: kind) |
| | | return .saved |
| | | } |
| | | LauncherHotkeySettings.clearCustomHotkey(for: kind) |
| | | LauncherHotkeyRegistrationStore.setActive(for: kind) |
| | | return .restored |
| | | } |
| | | |
| | | switch tryRegisterHotkey(hotkey, for: kind, markFailure: false) { |
| | | case .success(let newRef): |
| | | if let oldRef = hotkeyRef(for: kind) { |
| | | UnregisterEventHotKey(oldRef) |
| | | } |
| | | setHotkeyRef(newRef, for: kind) |
| | | if persistCustom { |
| | | LauncherHotkeySettings.save(hotkey, for: kind) |
| | | LauncherHotkeyRegistrationStore.setActive(for: kind) |
| | | return .saved |
| | | } |
| | | LauncherHotkeySettings.clearCustomHotkey(for: kind) |
| | | LauncherHotkeyRegistrationStore.setActive(for: kind) |
| | | return .restored |
| | | case .failure(let status): |
| | | return .registrationFailed(status) |
| | | } |
| | | } |
| | | |
| | | private func registerAndStoreHotkey( |
| | | _ hotkey: LauncherHotkey, |
| | | for kind: LauncherHotkeyKind, |
| | | markFailure: Bool |
| | | ) { |
| | | switch tryRegisterHotkey(hotkey, for: kind, markFailure: markFailure) { |
| | | case .success(let newRef): |
| | | setHotkeyRef(newRef, for: kind) |
| | | case .failure: |
| | | setHotkeyRef(nil, for: kind) |
| | | } |
| | | } |
| | | |
| | | private func tryRegisterHotkey( |
| | | _ hotkey: LauncherHotkey, |
| | | for kind: LauncherHotkeyKind, |
| | | markFailure: Bool |
| | | ) -> HotkeyRegistrationAttempt { |
| | | |
| | | var hotkeyID = EventHotKeyID() |
| | | hotkeyID.signature = OSType(0x41505447) // 'APTG' |
| | |
| | | ) |
| | | |
| | | if status == noErr, let newRef { |
| | | setHotkeyRef(newRef, for: kind) |
| | | LauncherHotkeyRegistrationStore.setActive(for: kind) |
| | | if markFailure { |
| | | LauncherHotkeyRegistrationStore.setActive(for: kind) |
| | | } |
| | | return .success(newRef) |
| | | } else { |
| | | setHotkeyRef(nil, for: kind) |
| | | LauncherHotkeyRegistrationStore.setFailed(status, for: kind) |
| | | if markFailure { |
| | | LauncherHotkeyRegistrationStore.setFailed(status, for: kind) |
| | | } |
| | | print("[TagLauncher] Fixed hotkey registration failed for \(kind.rawValue): \(status)") |
| | | return .failure(status) |
| | | } |
| | | } |
| | | |
| | |
| | | object: nil, |
| | | queue: .main |
| | | ) { [weak self] _ in |
| | | self?.setupMenuBar() |
| | | self?.configureApplicationMenuWhenAvailable(retries: 2) |
| | | } |
| | | } |
| | | |
| | | private func observeProEntitlementChanges() { |
| | | NotificationCenter.default.addObserver( |
| | | forName: .tagLauncherProEntitlementChanged, |
| | | object: nil, |
| | | queue: .main |
| | | ) { [weak self] _ in |
| | | self?.registerConfiguredHotkeys() |
| | | self?.setupMenuBar() |
| | | self?.configureApplicationMenuWhenAvailable(retries: 2) |
| | | } |
| | |
| | | ) { [weak self] event in |
| | | guard let self, self.isQuickSearchOpen else { return event } |
| | | if event.window === self.overlayWindow { |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherQuickSearchDismissRequested, |
| | | object: nil, |
| | | userInfo: ["source": QuickSearchDismissSource.mouse] |
| | | ) |
| | | return nil |
| | | return event |
| | | } |
| | | return event |
| | | } |
| | |
| | | } |
| | | |
| | | @objc private func openProStatusFromStatusMenu(_ sender: NSMenuItem) { |
| | | openPreferences(targetTab: SettingsTabTarget.about) |
| | | openPreferences(targetTab: SettingsTabTarget.pro) |
| | | } |
| | | |
| | | private func openPreferences(targetTab: String?) { |
| | |
| | | // NSHostingView.hitTest may return a SwiftUI-internal wrapper — the |
| | | // actual AppKit subview may be nested deeper. |
| | | if let container = findTextFieldContainer(in: hit) { |
| | | container.mouseDown(with: event) |
| | | container.focusTextField() |
| | | return |
| | | } |
| | | if let tf = findNSTextField(in: hit) { |
| | | let shouldSelectAll = tf.currentEditor() == nil |
| | | tf.window?.makeFirstResponder(tf) |
| | | tf.mouseDown(with: event) |
| | | if shouldSelectAll { |
| | | tf.selectText(nil) |
| | | } |
| | | return |
| | | } |
| | | super.mouseDown(with: event) |