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 } // --diag reports where the status item landed. On a notched Mac a // brand-new item can be placed under the notch and be unclickable, // which locks the user out once the panel is hidden. if CommandLine.arguments.contains("--diag") { setupStatusItem() installMenuItems() DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.writeDiagnostics() 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() promptForAccessibilityIfNeeded() LoginItem.autoRegisterOnce() // Let SwiftUI finish installing its main menu, then augment it. DispatchQueue.main.async { self.installMenuItems() } // 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() } } /// Without Accessibility the Shift-Shift gestures fail silently, so the /// panel can be hidden with no obvious way back. Say so on launch and /// point at the shortcut that works without the permission. private func promptForAccessibilityIfNeeded() { guard !AXIsProcessTrusted() else { return } let alert = NSAlert() alert.messageText = "Prompt needs Accessibility access" alert.informativeText = """ Capturing with Shift-Shift (and showing the panel with right Shift twice) \ only works with Accessibility access. Until it is granted, use Cmd+Shift+Space to show or hide the panel and \ Cmd+Shift+C to capture, or click the menu bar icon. """ alert.addButton(withTitle: "Open Settings") alert.addButton(withTitle: "Later") if alert.runModal() == .alertFirstButtonReturn { let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary _ = AXIsProcessTrustedWithOptions(options) } } /// 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 } /// Adds "Hide Panel" to the menu SwiftUI already installed. Do NOT replace /// `NSApp.mainMenu`: SwiftUI's menu carries the Edit items that AppKit /// routes Cmd+C/V/X/A/Z through, so replacing it breaks text editing in the /// composer. This only inserts one item and drops SwiftUI's Settings item, /// whose empty scene would otherwise shadow the in-panel Cmd+, sync panel. private func installMenuItems() { guard let appMenu = NSApp.mainMenu?.items.first?.submenu else { return } if let settings = appMenu.items.firstIndex(where: { $0.keyEquivalent == "," && $0.keyEquivalentModifierMask == [.command] }) { appMenu.removeItem(at: settings) } guard !appMenu.items.contains(where: { $0.action == #selector(hidePanel) }) else { return } let hide = NSMenuItem(title: "Hide Panel", action: #selector(hidePanel), keyEquivalent: "w") hide.target = self let quitIndex = appMenu.items.firstIndex { $0.action == #selector(NSApplication.terminate(_:)) } ?? appMenu.items.count appMenu.insertItem(.separator(), at: quitIndex) appMenu.insertItem(hide, at: quitIndex + 1) } @MainActor @objc private func hidePanel() { PanelController.shared.hide() } @MainActor private func writeDiagnostics() { var lines = ["Prompt diagnostics", "accessibility: \(AXIsProcessTrusted())"] lines.append("login item status: \(SMAppService.mainApp.status.rawValue)") if let window = statusItem?.button?.window { lines.append("status item frame: \(window.frame)") if let screen = window.screen ?? NSScreen.main { lines.append("screen: \(screen.frame) visible: \(screen.visibleFrame)") lines.append("notch inset: \(screen.safeAreaInsets)") let underNotch = window.frame.maxX > screen.frame.maxX - screen.safeAreaInsets.right lines.append("under the notch: \(underNotch)") } } else { lines.append("status item has no window (not in the menu bar)") } lines.append("preferred position: \(UserDefaults.standard.object(forKey: "NSStatusItem Preferred Position Prompt") ?? "unset")") lines.append("main menu:") for item in NSApp.mainMenu?.items ?? [] { lines.append(" \(item.submenu?.title ?? item.title)") for entry in item.submenu?.items ?? [] { guard !entry.isSeparatorItem else { continue } let action = entry.action.map(NSStringFromSelector) ?? "-" lines.append(" \(entry.title) [\(entry.keyEquivalentModifierMask.rawValue)] \(action)") } } try? lines.joined(separator: "\n").write( to: URL(fileURLWithPath: "/tmp/prompt-diag.txt"), atomically: true, encoding: .utf8 ) } 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 } /// Registers the app as a login item on first launch so Prompt opens on /// startup without the user hunting for the toggle. Runs once (tracked in /// UserDefaults) so a user who disables it is never overridden, and skips /// `swift run`, where there is no bundle to register. static func autoRegisterOnce() { let key = "loginItem.autoRegistered" guard Bundle.main.bundleIdentifier != nil, !UserDefaults.standard.bool(forKey: key) else { return } // A never-registered bundle reports `.notFound` on current macOS // (BTM has no record), not `.notRegistered`, so only skip when there // is nothing left to do. switch SMAppService.mainApp.status { case .enabled, .requiresApproval: UserDefaults.standard.set(true, forKey: key) default: do { try SMAppService.mainApp.register() UserDefaults.standard.set(true, forKey: key) NSLog("Prompt: registered as a login item (%d)", SMAppService.mainApp.status.rawValue) } catch { NSLog("Prompt: login item registration failed: %@", error.localizedDescription) } } } 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 } } }