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
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user