Replacing the main menu dropped the Edit menu that AppKit routes Cmd+V/C/A/Z through, so the composer could not paste at all. The menu is rebuilt with those items, which --diag now dumps so the wiring can be checked without a keyboard. Workspaces also gained an order: the Cmd+K switcher has up/down controls, creating one opens a naming modal instead of prefilling the composer, and the UI now says workspace throughout. The stored JSON keeps the original `sections` keys so existing data and sync payloads stay valid.
301 lines
12 KiB
Swift
301 lines
12 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()
|
|
setupMainMenu()
|
|
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()
|
|
setupMainMenu()
|
|
promptForAccessibilityIfNeeded()
|
|
|
|
// 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
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// The Edit menu is not decoration: AppKit routes Cmd+C/V/X/A/Z through
|
|
/// these items, so a minimal main menu silently breaks pasting and
|
|
/// selecting in the composer.
|
|
private func setupMainMenu() {
|
|
let mainMenu = NSMenu()
|
|
|
|
let appMenuItem = NSMenuItem()
|
|
mainMenu.addItem(appMenuItem)
|
|
let appMenu = NSMenu(title: "Prompt")
|
|
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)
|
|
|
|
let editMenuItem = NSMenuItem()
|
|
mainMenu.addItem(editMenuItem)
|
|
let editMenu = NSMenu(title: "Edit")
|
|
editMenuItem.submenu = editMenu
|
|
|
|
editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
|
|
let redo = editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "z")
|
|
redo.keyEquivalentModifierMask = [.command, .shift]
|
|
editMenu.addItem(.separator())
|
|
editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
|
|
editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
|
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
|
|
editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
|
|
|
NSApp.mainMenu = mainMenu
|
|
}
|
|
|
|
@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
|
|
}
|
|
}
|
|
}
|