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 } } }