2 Commits
Author SHA1 Message Date
TZGyn 90890186c0 Tell the user when Accessibility is missing
Without it the Shift-Shift gestures fail silently, so hiding the panel
left no way to bring it back and the app looked broken. The app now says
so on launch and points at Cmd+Shift+Space, which needs no permission,
and --diag reports the permission state alongside the status item's
placement so a lockout can be diagnosed instead of guessed at.
2026-10-08 10:45:50 +08:00
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
7 changed files with 153 additions and 9 deletions
+11 -2
View File
@@ -13,7 +13,13 @@ clicking an item copies it back to the clipboard.
`NSStatusItem Preferred Position Prompt`). Left click toggles the panel,
right click shows the menu (accessibility state, launch at login, open data
folder, quit). Contains the single-instance guard and `LoginItem`
(`SMAppService`).
(`SMAppService`). Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit
(Cmd+Q) — an accessory app shows no menu bar, but the main menu still
services key equivalents, which is the only way to quit from the keyboard
while the panel has focus. `applicationShouldHandleReopen` shows the panel so
launching an already-running app does something. Supports `--sync-check` and
`--diag` (writes the status item's frame and permission state to
`/tmp/prompt-diag.txt`, then exits).
- `Sources/Prompt/Store.swift` — `@MainActor` `ObservableObject` singleton.
Data model: `Section` → `[PromptItem]`, item content is
`.text(String)` or `.image(String)` (filename). Persists to
@@ -167,7 +173,10 @@ brew upgrade --cask prompt
the main thread (the hotkey handler already dispatches to a global queue).
- The double-Shift tap needs Accessibility; without it the event tap silently
fails and only the Carbon fallbacks work. The panel banner and the status
menu both surface the permission state.
menu both surface the permission state, and `AppDelegate` shows a launch
alert when it is missing — otherwise a user who hides the panel is locked out
(no hotkey, no Dock icon) and reports it as "the app won't come back". Check
with `Prompt --diag` (`accessibility: false` is the tell).
- `Info.plist` sets `NSAllowsArbitraryLoads` because the sync server URL is
user-supplied and is often plain HTTP (LAN address, self-hosted box). ATS
would otherwise block it with a confusing error.
+5 -4
View File
@@ -32,10 +32,11 @@ to your work. Click anything to copy it back to your clipboard.
| Close panel | Esc |
Double-Shift gestures require **Accessibility access** (Prompt reads the
selection by simulating Cmd+C, then restores your clipboard untouched). The
Cmd+Shift fallbacks work without it. Bare keys like Space and Cmd+C are
deliberately not bound inside the panel so they never conflict with typing in
the composer.
selection by simulating Cmd+C, then restores your clipboard untouched). Until
it is granted, use Cmd+Shift+Space and Cmd+Shift+C, or click the menu bar icon
— and launching the app again always brings the panel back. Bare keys like
Space and Cmd+C are deliberately not bound inside the panel so they never
conflict with typing in the composer.
## Install
+102
View File
@@ -27,6 +27,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
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()
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
@@ -47,6 +59,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
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).
@@ -56,6 +70,94 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
/// 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.
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()
}
@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")")
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"
+16
View File
@@ -34,6 +34,22 @@ final class HotkeyManager {
registerFallbackHotkeys()
}
func stop() {
if let tap = eventTap {
CGEvent.tapEnable(tap: tap, enable: false)
CFMachPortInvalidate(tap)
}
if let source = runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
for ref in hotKeyRefs {
if let ref { UnregisterEventHotKey(ref) }
}
hotKeyRefs.removeAll()
eventTap = nil
runLoopSource = nil
}
// MARK: - Double-Shift event tap
private func startEventTap() {
+2
View File
@@ -31,12 +31,14 @@ final class PanelController: NSObject {
}
NSApp.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil)
NSLog("Prompt: panel shown")
NotificationCenter.default.post(name: .promptPanelDidShow, object: nil)
}
func hide() {
panel?.orderOut(nil)
NSApp.deactivate()
NSLog("Prompt: panel hidden (app keeps running for the Shift-Shift hotkeys)")
}
private func makePanel() -> KeyablePanel {
+7 -1
View File
@@ -76,7 +76,8 @@ struct PanelView: View {
Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos")
Button { PanelController.shared.hide() } label: { Image(systemName: "xmark") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Close (Esc)")
.buttonStyle(.plain).foregroundStyle(.secondary)
.help("Hide the panel (Cmd+W) — Prompt keeps running so Shift-Shift capture still works. Quit from the menu bar icon or Cmd+Q.")
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
@@ -86,8 +87,13 @@ struct PanelView: View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
VStack(alignment: .leading, spacing: 2) {
Text("Shift-tap capture needs Accessibility access.")
.font(.caption)
Text("Meanwhile: Cmd+Shift+Space toggles this panel.")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
Button("Grant…") {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
+8
View File
@@ -67,6 +67,14 @@ final class SyncService: ObservableObject {
restartPolling()
}
func stop() {
pollTimer?.invalidate()
pollTimer = nil
pushWork?.cancel()
pushWork = nil
Store.shared.onDidChange = nil
}
// MARK: - Group membership
func generateCode() {