Files
prompt/Sources/Prompt/App.swift
T
TZGyn c074be3378 Initial commit: a Copper-style panel for prompts and photos
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
2026-10-07 20:54:49 +08:00

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
}
}
}