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.
275 lines
10 KiB
Swift
275 lines
10 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()
|
|
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.
|
|
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"
|
|
// 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
|
|
}
|
|
}
|
|
}
|