From 962c205d1d647188fb7f322fee2c4188f91bac93 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Thu, 8 Oct 2026 10:43:42 +0800 Subject: [PATCH] Make the app reachable and quittable after the panel closes Closing the panel only hid it, and launching the app again did nothing because the running instance ignored the reopen, so it looked like the app refused to exit. Reopening now shows the panel, and the main menu makes Cmd+Q and Cmd+W work while the panel has focus. Termination also tears down the event tap, hotkeys and sync timers instead of leaving them live. --- Sources/Prompt/App.swift | 47 ++++++++++++++++++++++++++++ Sources/Prompt/HotkeyManager.swift | 16 ++++++++++ Sources/Prompt/PanelController.swift | 2 ++ Sources/Prompt/PanelView.swift | 3 +- Sources/Prompt/SyncService.swift | 8 +++++ 5 files changed, 75 insertions(+), 1 deletion(-) diff --git a/Sources/Prompt/App.swift b/Sources/Prompt/App.swift index 389b133..415a934 100644 --- a/Sources/Prompt/App.swift +++ b/Sources/Prompt/App.swift @@ -47,6 +47,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { HotkeyManager.shared.start() setupStatusItem() + setupMainMenu() // Show the panel on launch so the app is discoverable (it has no Dock // icon and the menu bar icon can be hard to spot on a full bar). @@ -56,6 +57,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + /// A launched-again app must reveal itself. Without this, `open` (or + /// clicking it in Finder/Spotlight) hits the running instance, which does + /// nothing, so the app looks like it never started. + @MainActor + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + PanelController.shared.show() + return true + } + + @MainActor + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + // Stop the poll timer, any queued push, the event tap and the Carbon + // hotkeys so termination cannot be held up by a live callback. + SyncService.shared.stop() + HotkeyManager.shared.stop() + return .terminateNow + } + + /// Accessory apps have no visible menu bar, but the main menu still + /// services key equivalents — this is what makes Cmd+Q and Cmd+W work + /// while the panel has focus. + private func setupMainMenu() { + let mainMenu = NSMenu() + let appMenuItem = NSMenuItem() + mainMenu.addItem(appMenuItem) + + let appMenu = NSMenu() + appMenuItem.submenu = appMenu + + let hide = NSMenuItem(title: "Hide Panel", action: #selector(hidePanel), keyEquivalent: "w") + hide.target = self + appMenu.addItem(hide) + + appMenu.addItem(.separator()) + + let quit = NSMenuItem(title: "Quit Prompt", action: #selector(quit), keyEquivalent: "q") + quit.target = self + appMenu.addItem(quit) + + NSApp.mainMenu = mainMenu + } + + @MainActor @objc private func hidePanel() { + PanelController.shared.hide() + } + private func setupStatusItem() { let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) item.autosaveName = "Prompt" diff --git a/Sources/Prompt/HotkeyManager.swift b/Sources/Prompt/HotkeyManager.swift index d608ef0..e723cfa 100644 --- a/Sources/Prompt/HotkeyManager.swift +++ b/Sources/Prompt/HotkeyManager.swift @@ -34,6 +34,22 @@ final class HotkeyManager { registerFallbackHotkeys() } + func stop() { + if let tap = eventTap { + CGEvent.tapEnable(tap: tap, enable: false) + CFMachPortInvalidate(tap) + } + if let source = runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + } + for ref in hotKeyRefs { + if let ref { UnregisterEventHotKey(ref) } + } + hotKeyRefs.removeAll() + eventTap = nil + runLoopSource = nil + } + // MARK: - Double-Shift event tap private func startEventTap() { diff --git a/Sources/Prompt/PanelController.swift b/Sources/Prompt/PanelController.swift index 451fb49..a5e4a4e 100644 --- a/Sources/Prompt/PanelController.swift +++ b/Sources/Prompt/PanelController.swift @@ -31,12 +31,14 @@ final class PanelController: NSObject { } NSApp.activate(ignoringOtherApps: true) panel.makeKeyAndOrderFront(nil) + NSLog("Prompt: panel shown") NotificationCenter.default.post(name: .promptPanelDidShow, object: nil) } func hide() { panel?.orderOut(nil) NSApp.deactivate() + NSLog("Prompt: panel hidden (app keeps running for the Shift-Shift hotkeys)") } private func makePanel() -> KeyablePanel { diff --git a/Sources/Prompt/PanelView.swift b/Sources/Prompt/PanelView.swift index 6a3ff3b..dde0ee1 100644 --- a/Sources/Prompt/PanelView.swift +++ b/Sources/Prompt/PanelView.swift @@ -76,7 +76,8 @@ struct PanelView: View { Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") } .buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos") Button { PanelController.shared.hide() } label: { Image(systemName: "xmark") } - .buttonStyle(.plain).foregroundStyle(.secondary).help("Close (Esc)") + .buttonStyle(.plain).foregroundStyle(.secondary) + .help("Hide the panel (Cmd+W) — Prompt keeps running so Shift-Shift capture still works. Quit from the menu bar icon or Cmd+Q.") } .padding(.horizontal, 12) .padding(.vertical, 10) diff --git a/Sources/Prompt/SyncService.swift b/Sources/Prompt/SyncService.swift index d06008e..50f3397 100644 --- a/Sources/Prompt/SyncService.swift +++ b/Sources/Prompt/SyncService.swift @@ -67,6 +67,14 @@ final class SyncService: ObservableObject { restartPolling() } + func stop() { + pollTimer?.invalidate() + pollTimer = nil + pushWork?.cancel() + pushWork = nil + Store.shared.onDidChange = nil + } + // MARK: - Group membership func generateCode() {