Files
prompt/Sources/Prompt/App.swift
T
TZGyn 9f0c0f8113 Make pasting reliable and turn items into checkboxes
Pasting still failed because the panel is a nonactivating panel: it can
be key while the app is not active, and menu key equivalents are not
delivered reliably in that state. Cmd+V is now dispatched to the
responder chain directly, and the app no longer replaces SwiftUI's main
menu — it only adds Hide Panel to it, so the Edit items that AppKit
routes text shortcuts through stay intact.

Rows also gained a checkbox so a workspace works as a checklist without
turning the row into a button, which would swallow the checkbox's clicks.
2026-10-09 17:49:06 +08:00

283 lines
11 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
}
// --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()
// 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())"]
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
}
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
}
}
}