Files
prompt/Sources/Prompt/App.swift
T
TZGyn 962c205d1d 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.
2026-10-08 10:43:42 +08:00

220 lines
7.4 KiB
Swift

import SwiftUI
import AppKit
import ServiceManagement
@main
struct PromptApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
// The app lives in a floating panel + status item, so there are no
// real windows; a bare Settings scene keeps SwiftUI happy.
Settings { EmptyView() }
}
}
final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem?
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
if CommandLine.arguments.contains("--sync-check") {
Task { @MainActor in
await SyncService.shared.runDiagnostic()
NSApp.terminate(nil)
}
return
}
// Single instance: stale copies keep their hotkeys alive after an
// update and eat the events meant for the new instance.
let myPid = ProcessInfo.processInfo.processIdentifier
let others = NSRunningApplication.runningApplications(withBundleIdentifier: "dev.tzgyn.prompt")
.filter { $0.processIdentifier != myPid }
for other in others {
other.forceTerminate()
}
HotkeyManager.shared.onCapture = {
DispatchQueue.global(qos: .userInitiated).async {
Capture.captureSelectionIntoStore()
}
}
HotkeyManager.shared.onTogglePanel = {
Task { @MainActor in PanelController.shared.toggle() }
}
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).
Task { @MainActor in
SyncService.shared.start()
PanelController.shared.show()
}
}
/// 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"
// Seed a visible preferred position on first run so a new item does
// not land under the notch on a full menu bar; user drags win after.
if UserDefaults.standard.object(forKey: "NSStatusItem Preferred Position Prompt") == nil {
UserDefaults.standard.set(100.0, forKey: "NSStatusItem Preferred Position Prompt")
}
if let button = item.button {
button.image = NSImage(systemSymbolName: "text.bubble", accessibilityDescription: "Prompt")
button.action = #selector(statusItemClicked(_:))
button.target = self
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
}
statusItem = item
}
@MainActor @objc private func statusItemClicked(_ sender: NSStatusBarButton) {
guard let event = NSApp.currentEvent else { return }
if event.type == .rightMouseUp {
let menu = buildMenu()
statusItem?.menu = menu
statusItem?.button?.performClick(nil)
statusItem?.menu = nil
} else {
PanelController.shared.toggle()
}
}
private func buildMenu() -> NSMenu {
let menu = NSMenu()
let toggle = NSMenuItem(title: "Show/Hide Prompt", action: #selector(togglePanel), keyEquivalent: "")
toggle.target = self
menu.addItem(toggle)
menu.addItem(.separator())
if AXIsProcessTrusted() {
let granted = NSMenuItem(title: "Accessibility: Granted", action: nil, keyEquivalent: "")
granted.isEnabled = false
menu.addItem(granted)
} else {
let grant = NSMenuItem(title: "Grant Accessibility Access…", action: #selector(requestAccessibility), keyEquivalent: "")
grant.target = self
menu.addItem(grant)
}
let loginTitle = LoginItem.isEnabled ? "Disable Launch at Login" : "Launch at Login"
let login = NSMenuItem(title: loginTitle, action: #selector(toggleLoginItem), keyEquivalent: "")
login.target = self
menu.addItem(login)
let folder = NSMenuItem(title: "Open Data Folder", action: #selector(openDataFolder), keyEquivalent: "")
folder.target = self
menu.addItem(folder)
menu.addItem(.separator())
let quit = NSMenuItem(title: "Quit Prompt", action: #selector(quit), keyEquivalent: "q")
quit.target = self
menu.addItem(quit)
return menu
}
@MainActor @objc private func togglePanel() {
PanelController.shared.toggle()
}
@objc private func requestAccessibility() {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
@objc private func toggleLoginItem() {
if !LoginItem.toggle() {
let alert = NSAlert()
alert.messageText = "Could not update Launch at Login"
alert.informativeText = "Launch at Login requires a signed app bundle in /Applications. It is not available when running via `swift run`."
alert.alertStyle = .warning
alert.addButton(withTitle: "OK")
alert.runModal()
}
}
@MainActor @objc private func openDataFolder() {
NSWorkspace.shared.open(Store.shared.directory)
}
@objc private func quit() {
NSApp.terminate(nil)
}
}
enum LoginItem {
static var isEnabled: Bool {
SMAppService.mainApp.status == .enabled
}
static func toggle() -> Bool {
let service = SMAppService.mainApp
do {
if service.status == .enabled {
try service.unregister()
} else {
try service.register()
}
return true
} catch {
return false
}
}
}