Keeps text prompts and photos in a floating always-on-top panel and
copies them back to the clipboard on click. Double-Shift captures the
selection from any app; sections, search, copy-as-list, merge, and
local JSON persistence mirror shadcn's Copper workflow.
💘 Generated with Crush
Assisted-by: Crush:kimi-k3
158 lines
5.3 KiB
Swift
158 lines
5.3 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)
|
|
|
|
// 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()
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|