| | |
| | | |
| | | final class AppDelegate: NSObject, NSApplicationDelegate { |
| | | private static let showDockIconKey = "showDockIcon" |
| | | private static let statusItemAutosaveName = "com.apptag.launcher.statusItem" |
| | | private static let statusItemButtonIdentifier = NSUserInterfaceItemIdentifier("TagLauncherStatusItemButton") |
| | | private static let statusItemAccessibilityLabel = "TagLauncher" |
| | | private static let showAppListMenuItemIdentifier = NSUserInterfaceItemIdentifier("TagLauncherShowAppListMenuItem") |
| | | private static let showAppListShortcutGlyphs = "⌥⇧␣" |
| | | private static let overlayDefaultLevel = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow))) |
| | | private static let overlayTextInputLevel = NSWindow.Level.modalPanel |
| | | |
| | | private var statusItem: NSStatusItem? |
| | | private var overlayWindow: NSWindow? |
| | | private var overlayKeyMonitor: Any? |
| | | private var quickSearchExternalMouseMonitor: Any? |
| | | private var settingsWindow: NSWindow? // Track Settings window to keep it above overlay |
| | | private var hotkeyRef: EventHotKeyRef? |
| | | private var mainHotkeyRef: EventHotKeyRef? |
| | | private var quickSearchHotkeyRef: EventHotKeyRef? |
| | | private var hotkeyEventHandlerInstalled = false |
| | | private var isQuickSearchOpen = false |
| | | private var isModalInteractionActive = false |
| | | private var isInEditMode = false // Suppress auto-dismiss during editing |
| | | private var isEditingAppNote = false |
| | | private var isConfiguringApplicationMenu = false |
| | |
| | | migrateDefaultGroupName() |
| | | TagDatabase.seedDefaultTags() |
| | | syncChromeSettings(force: true) |
| | | registerHotkey() |
| | | observeHotkeyStatusChanges() |
| | | registerConfiguredHotkeys() |
| | | observeOtherWindows() |
| | | observeSettingsClose() |
| | | observeEditMode() |
| | | observeAppNoteEditing() |
| | | observeQuickSearch() |
| | | observePreferencesRequests() |
| | | observeApplicationMenuChanges() |
| | | observeChromeSettings() |
| | |
| | | |
| | | func applicationWillTerminate(_ notification: Notification) { |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | | removeQuickSearchExternalMouseMonitor() |
| | | } |
| | | |
| | | /// Ensure defaultGroupName is always the language-neutral key "Other". |
| | |
| | | // MARK: - Menu Bar |
| | | |
| | | private func setupMenuBar() { |
| | | removeMenuBarItem() |
| | | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) |
| | | if statusItem == nil { |
| | | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) |
| | | } else { |
| | | statusItem?.length = NSStatusItem.squareLength |
| | | } |
| | | guard let statusItem else { return } |
| | | statusItem.autosaveName = Self.statusItemAutosaveName |
| | | statusItem.isVisible = true |
| | | |
| | | if let button = statusItem.button { |
| | | button.identifier = Self.statusItemButtonIdentifier |
| | | button.setAccessibilityIdentifier(Self.statusItemAutosaveName) |
| | | button.setAccessibilityLabel(Self.statusItemAccessibilityLabel) |
| | | button.image = makeMenuBarIcon() |
| | | button.imageScaling = .scaleProportionallyDown |
| | | button.imagePosition = .imageOnly |
| | |
| | | } |
| | | |
| | | private var showAppListMenuTitle: String { |
| | | "\(tr("menu.showAppList")) \(Self.showAppListShortcutGlyphs)" |
| | | if LauncherHotkeyRegistrationStore.state(for: .main) == .failed { |
| | | return tr("menu.showShortcutUnavailable") |
| | | } |
| | | return "\(tr("menu.showAppList")) \(LauncherHotkey.main.displayString)" |
| | | } |
| | | |
| | | private func observeApplicationMenuChanges() { |
| | |
| | | } |
| | | } |
| | | |
| | | private func showOverlay() { |
| | | private func showOverlay(initialQuickSearchSource: String? = nil) { |
| | | // Use the screen under the mouse cursor — works in fullscreen spaces |
| | | let mousePoint = NSEvent.mouseLocation |
| | | guard let screen = NSScreen.screens.first(where: { |
| | | NSMouseInRect(mousePoint, $0.frame, false) |
| | | }) ?? NSScreen.main ?? NSScreen.screens.first else { return } |
| | | |
| | | overlayWindow?.orderOut(nil) |
| | | overlayWindow = makeOverlayWindow(on: screen) |
| | | overlayWindow?.setFrame(screen.frame, display: true) |
| | | overlayWindow?.level = isEditingAppNote ? Self.overlayTextInputLevel : Self.overlayDefaultLevel |
| | | let window: NSWindow |
| | | if let existingWindow = overlayWindow { |
| | | window = existingWindow |
| | | } else { |
| | | window = makeOverlayWindow(on: screen, initialQuickSearchSource: initialQuickSearchSource) |
| | | overlayWindow = window |
| | | } |
| | | |
| | | window.setFrame(screen.frame, display: true) |
| | | window.level = isEditingAppNote ? Self.overlayTextInputLevel : Self.overlayDefaultLevel |
| | | |
| | | installOverlayKeyMonitor() |
| | | |
| | | overlayWindow?.makeKeyAndOrderFront(nil) |
| | | overlayWindow?.orderFrontRegardless() |
| | | if let initialQuickSearchSource { |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherQuickSearchRequested, |
| | | object: nil, |
| | | userInfo: ["source": initialQuickSearchSource] |
| | | ) |
| | | } |
| | | |
| | | window.makeKeyAndOrderFront(nil) |
| | | window.orderFrontRegardless() |
| | | NotificationCenter.default.post(name: .tagLauncherOverlayDidShow, object: nil) |
| | | } |
| | | |
| | | private func installOverlayKeyMonitor() { |
| | | guard overlayKeyMonitor == nil else { return } |
| | | overlayKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in |
| | | guard let self else { return event } |
| | | if event.keyCode == 53 { // Escape |
| | | self?.hideOverlay() |
| | | if self.isQuickSearchOpen { |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | return nil |
| | | } |
| | | self.hideOverlay() |
| | | return nil |
| | | } |
| | | if self.shouldOpenQuickSearch(for: event) { |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherQuickSearchRequested, |
| | | object: nil, |
| | | userInfo: ["source": QuickSearchOpenSource.mainOverlay] |
| | | ) |
| | | return nil |
| | | } |
| | | return event |
| | | } |
| | | } |
| | | |
| | | private func shouldOpenQuickSearch(for event: NSEvent) -> Bool { |
| | | guard event.keyCode == UInt16(kVK_Space), |
| | | !event.isARepeat, |
| | | event.modifierFlags.intersection(.deviceIndependentFlagsMask).isEmpty, |
| | | overlayWindow?.isVisible == true, |
| | | !isQuickSearchOpen, |
| | | !isInEditMode, |
| | | !isEditingAppNote, |
| | | !isModalInteractionActive |
| | | else { return false } |
| | | |
| | | guard let firstResponder = overlayWindow?.firstResponder else { return true } |
| | | if firstResponder is NSText || firstResponder is NSTextField { |
| | | return false |
| | | } |
| | | if let responderView = firstResponder as? NSView, |
| | | viewOrAncestorHandlesSpace(responderView) { |
| | | return false |
| | | } |
| | | return true |
| | | } |
| | | |
| | | private func viewOrAncestorHandlesSpace(_ view: NSView) -> Bool { |
| | | var current: NSView? = view |
| | | while let candidate = current { |
| | | if candidate is NSButton || candidate is NSSegmentedControl || candidate is NSSlider { |
| | | return true |
| | | } |
| | | current = candidate.superview |
| | | } |
| | | return false |
| | | } |
| | | |
| | | private func removeOverlayKeyMonitor() { |
| | |
| | | } |
| | | } |
| | | |
| | | private func makeOverlayWindow(on screen: NSScreen) -> NSWindow { |
| | | private func makeOverlayWindow(on screen: NSScreen, initialQuickSearchSource: String? = nil) -> NSWindow { |
| | | let panel = OverlayPanel( |
| | | contentRect: screen.frame, |
| | | styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel], |
| | |
| | | panel.titleVisibility = .hidden |
| | | panel.isReleasedWhenClosed = false |
| | | panel.contentView = DismissibleHostingView( |
| | | rootView: ContentView(hideOverlay: { [weak self] in |
| | | self?.hideOverlay(force: true) |
| | | }), |
| | | rootView: ContentView( |
| | | hideOverlay: { [weak self] in |
| | | self?.hideOverlay(force: true) |
| | | }, |
| | | initialQuickSearchSource: initialQuickSearchSource |
| | | ), |
| | | onBackdropTap: { [weak self] in |
| | | self?.hideOverlay() |
| | | } |
| | |
| | | } |
| | | overlayWindow?.orderOut(nil) |
| | | removeOverlayKeyMonitor() |
| | | removeQuickSearchExternalMouseMonitor() |
| | | NotificationCenter.default.post(name: .tagLauncherOverlayDidHide, object: nil) |
| | | if force { |
| | | overlayWindow = nil |
| | | } |
| | | } |
| | | |
| | | // MARK: - Global Hotkey (Shift+Option+Space) |
| | | // MARK: - Global Hotkeys |
| | | |
| | | /// Carbon RegisterEventHotKey. If it fails (sandbox, etc.), falls back to menu bar only. |
| | | private func registerHotkey() { |
| | | private func registerConfiguredHotkeys() { |
| | | installHotkeyEventHandlerIfNeeded() |
| | | registerFixedHotkey(.main) |
| | | registerFixedHotkey(.quickSearch) |
| | | } |
| | | |
| | | private func registerFixedHotkey(_ kind: LauncherHotkeyKind) { |
| | | unregisterHotkey(for: kind) |
| | | let hotkey = kind.hotkey |
| | | |
| | | var hotkeyID = EventHotKeyID() |
| | | hotkeyID.signature = OSType(0x41505447) // 'APTG' |
| | | hotkeyID.id = 1 |
| | | hotkeyID.id = kind.eventID |
| | | |
| | | let modifiers = UInt32(shiftKey | optionKey) |
| | | |
| | | var ref: EventHotKeyRef? |
| | | var newRef: EventHotKeyRef? |
| | | let status = RegisterEventHotKey( |
| | | UInt32(kVK_Space), |
| | | modifiers, |
| | | hotkey.keyCode, |
| | | hotkey.modifiers, |
| | | hotkeyID, |
| | | GetApplicationEventTarget(), |
| | | 0, |
| | | &ref |
| | | &newRef |
| | | ) |
| | | hotkeyRef = ref |
| | | |
| | | if status != noErr { |
| | | print("[TagLauncher] Hotkey registration failed: \(status). Falling back to menu bar only.") |
| | | return |
| | | if status == noErr, let newRef { |
| | | setHotkeyRef(newRef, for: kind) |
| | | LauncherHotkeyRegistrationStore.setActive(for: kind) |
| | | } else { |
| | | setHotkeyRef(nil, for: kind) |
| | | LauncherHotkeyRegistrationStore.setFailed(status, for: kind) |
| | | print("[TagLauncher] Fixed hotkey registration failed for \(kind.rawValue): \(status)") |
| | | } |
| | | } |
| | | |
| | | private func observeHotkeyStatusChanges() { |
| | | NotificationCenter.default.addObserver( |
| | | forName: .tagLauncherHotkeyRegistrationChanged, |
| | | object: nil, |
| | | queue: .main |
| | | ) { [weak self] _ in |
| | | self?.setupMenuBar() |
| | | self?.configureApplicationMenuWhenAvailable(retries: 2) |
| | | } |
| | | } |
| | | |
| | | private func installHotkeyEventHandlerIfNeeded() { |
| | | guard !hotkeyEventHandlerInstalled else { return } |
| | | hotkeyEventHandlerInstalled = true |
| | | |
| | | var eventSpec = EventTypeSpec( |
| | | eventClass: OSType(kEventClassKeyboard), |
| | | eventKind: UInt32(kEventHotKeyPressed) |
| | | ) |
| | | |
| | | let selfPtr = Unmanaged.passUnretained(self).toOpaque() |
| | | |
| | | InstallEventHandler( |
| | | GetApplicationEventTarget(), |
| | | { (_, _, userData) -> OSStatus in |
| | | guard let userData else { return noErr } |
| | | { (_, event, userData) -> OSStatus in |
| | | guard let event, let userData else { return noErr } |
| | | var hotkeyID = EventHotKeyID() |
| | | let status = GetEventParameter( |
| | | event, |
| | | EventParamName(kEventParamDirectObject), |
| | | EventParamType(typeEventHotKeyID), |
| | | nil, |
| | | MemoryLayout<EventHotKeyID>.size, |
| | | nil, |
| | | &hotkeyID |
| | | ) |
| | | guard status == noErr else { return noErr } |
| | | |
| | | let delegate = Unmanaged<AppDelegate> |
| | | .fromOpaque(userData) |
| | | .takeUnretainedValue() |
| | | DispatchQueue.main.async { |
| | | delegate.toggleOverlay() |
| | | delegate.handleHotkeyEvent(id: hotkeyID.id) |
| | | } |
| | | return noErr |
| | | }, |
| | |
| | | selfPtr, |
| | | nil |
| | | ) |
| | | } |
| | | |
| | | private func handleHotkeyEvent(id: UInt32) { |
| | | if id == LauncherHotkeyKind.quickSearch.eventID { |
| | | showQuickSearchFromGlobalHotkey() |
| | | } else { |
| | | toggleOverlay() |
| | | } |
| | | } |
| | | |
| | | private func showQuickSearchFromGlobalHotkey() { |
| | | guard overlayWindow?.isVisible != true else { return } |
| | | showOverlay(initialQuickSearchSource: QuickSearchOpenSource.globalHidden) |
| | | } |
| | | |
| | | private func hotkeyRef(for kind: LauncherHotkeyKind) -> EventHotKeyRef? { |
| | | switch kind { |
| | | case .main: return mainHotkeyRef |
| | | case .quickSearch: return quickSearchHotkeyRef |
| | | } |
| | | } |
| | | |
| | | private func setHotkeyRef(_ ref: EventHotKeyRef?, for kind: LauncherHotkeyKind) { |
| | | switch kind { |
| | | case .main: mainHotkeyRef = ref |
| | | case .quickSearch: quickSearchHotkeyRef = ref |
| | | } |
| | | } |
| | | |
| | | private func unregisterHotkey(for kind: LauncherHotkeyKind) { |
| | | if let ref = hotkeyRef(for: kind) { |
| | | UnregisterEventHotKey(ref) |
| | | setHotkeyRef(nil, for: kind) |
| | | } |
| | | } |
| | | |
| | | // MARK: - Preferences |
| | |
| | | } |
| | | } |
| | | |
| | | private func observeQuickSearch() { |
| | | NotificationCenter.default.addObserver( |
| | | forName: .tagLauncherQuickSearchVisibilityChanged, |
| | | object: nil, |
| | | queue: .main |
| | | ) { [weak self] notification in |
| | | guard let self else { return } |
| | | self.isQuickSearchOpen = (notification.userInfo?["active"] as? Bool) ?? false |
| | | if self.isQuickSearchOpen { |
| | | self.installQuickSearchExternalMouseMonitor() |
| | | } else { |
| | | self.removeQuickSearchExternalMouseMonitor() |
| | | } |
| | | } |
| | | |
| | | NotificationCenter.default.addObserver( |
| | | forName: .tagLauncherModalInteractionChanged, |
| | | object: nil, |
| | | queue: .main |
| | | ) { [weak self] notification in |
| | | self?.isModalInteractionActive = (notification.userInfo?["active"] as? Bool) ?? false |
| | | } |
| | | |
| | | NotificationCenter.default.addObserver( |
| | | forName: NSApplication.didResignActiveNotification, |
| | | object: NSApp, |
| | | queue: .main |
| | | ) { [weak self] _ in |
| | | guard self?.isQuickSearchOpen == true else { return } |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | } |
| | | } |
| | | |
| | | private func installQuickSearchExternalMouseMonitor() { |
| | | guard quickSearchExternalMouseMonitor == nil else { return } |
| | | quickSearchExternalMouseMonitor = NSEvent.addGlobalMonitorForEvents( |
| | | matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] |
| | | ) { [weak self] _ in |
| | | DispatchQueue.main.async { |
| | | guard self?.isQuickSearchOpen == true else { return } |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func removeQuickSearchExternalMouseMonitor() { |
| | | if let quickSearchExternalMouseMonitor { |
| | | NSEvent.removeMonitor(quickSearchExternalMouseMonitor) |
| | | self.quickSearchExternalMouseMonitor = nil |
| | | } |
| | | } |
| | | |
| | | private func updateOverlayLevelForTextInput() { |
| | | guard let overlayWindow else { return } |
| | | overlayWindow.level = isEditingAppNote ? Self.overlayTextInputLevel : Self.overlayDefaultLevel |
| | |
| | | |
| | | final class DismissibleHostingView<Content: View>: NSHostingView<Content> { |
| | | private let onBackdropTap: () -> Void |
| | | private var suppressBackdropDismiss = false |
| | | private var modalInteractionObserver: NSObjectProtocol? |
| | | |
| | | @MainActor required init(rootView: Content) { |
| | | self.onBackdropTap = {} |
| | | super.init(rootView: rootView) |
| | | observeModalInteractionChanges() |
| | | } |
| | | |
| | | init(rootView: Content, onBackdropTap: @escaping () -> Void) { |
| | | self.onBackdropTap = onBackdropTap |
| | | super.init(rootView: rootView) |
| | | observeModalInteractionChanges() |
| | | } |
| | | |
| | | deinit { |
| | | if let modalInteractionObserver { |
| | | NotificationCenter.default.removeObserver(modalInteractionObserver) |
| | | } |
| | | } |
| | | |
| | | @available(*, unavailable) |
| | |
| | | return |
| | | } |
| | | if hit == self { |
| | | if suppressBackdropDismiss { |
| | | super.mouseDown(with: event) |
| | | return |
| | | } |
| | | onBackdropTap() |
| | | return |
| | | } |
| | |
| | | super.mouseDown(with: event) |
| | | } |
| | | |
| | | private func observeModalInteractionChanges() { |
| | | modalInteractionObserver = NotificationCenter.default.addObserver( |
| | | forName: .tagLauncherModalInteractionChanged, |
| | | object: nil, |
| | | queue: .main |
| | | ) { [weak self] notification in |
| | | self?.suppressBackdropDismiss = (notification.userInfo?["active"] as? Bool) ?? false |
| | | } |
| | | } |
| | | |
| | | private func findTextFieldContainer(in view: NSView) -> TextFieldContainer? { |
| | | if let container = view as? TextFieldContainer { return container } |
| | | for sub in view.subviews { |