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.
This commit is contained in:
2026-10-08 10:43:42 +08:00
parent 1bd346ce20
commit 962c205d1d
5 changed files with 75 additions and 1 deletions
+47
View File
@@ -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"
+16
View File
@@ -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() {
+2
View File
@@ -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 {
+2 -1
View File
@@ -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)
+8
View File
@@ -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() {