commit c074be3378827c51720fd271f7ac15a38e67300c Author: TZGyn Date: Wed Oct 7 20:54:49 2026 +0800 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e139222 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# SwiftPM +.build/ +.swiftpm/ + +# App bundle (build artifact, regenerate with ./build-app.sh) +Prompt.app/ + +# Secrets +*.p8 + +# macOS +.DS_Store + +# Xcode +xcuserdata/ +DerivedData/ +dist/ +notary-result.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ac83de4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,106 @@ +# AGENTS.md + +Prompt is a native macOS app written in Swift/SwiftUI. Single executable +SwiftPM package, no external dependencies (all Apple frameworks). It is a +Copper-style (shadcn) capture panel: prompts and photos live in sections, +clicking an item copies it back to the clipboard. + +## Architecture + +- `Sources/Prompt/App.swift` β€” `@main` entry using a bare + `Settings { EmptyView() }` scene; the app owns its `NSStatusItem` manually + (same reason as SimpleTopBar: placement control on notched Macs, seeded via + `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`). +- `Sources/Prompt/Store.swift` β€” `@MainActor` `ObservableObject` singleton. + Data model: `Section` β†’ `[PromptItem]`, item content is + `.text(String)` or `.image(String)` (filename). Persists to + `~/Library/Application Support/Prompt/store.json`; images are PNG files in + `Images/` next to it, cached in an `NSCache`. Owns all mutations (add, + toggle done, edit, delete, move, merge, clear done) and clipboard writes + (`copy`, `copyAsList`). Saves on every mutation. +- `Sources/Prompt/PanelView.swift` β€” the panel UI. Header (section switcher, + search, add photos, close), item list, composer. Composer `# Name` creates/ + switches sections. Click = copy; Cmd+click = multi-select; right-click = + context menu (Copy, Copy as List, Done, Edit, Open, Merge, Move To, Delete). + Drop target for images/files. +- `Sources/Prompt/PanelController.swift` β€” owns the floating `NSPanel` + (`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as + `PromptPanel`), the Cmd+V image-paste monitor, and `EditWindowController` + (separate edit window with a `TextEditor`). +- `Sources/Prompt/HotkeyManager.swift` β€” double-Shift detection via a + CGEvent tap on `flagsChanged` (left Shift Γ—2 = capture, right Shift Γ—2 = + toggle panel), plus Carbon `RegisterEventHotKey` fallbacks (Cmd+Shift+Space + toggle, Cmd+Shift+C capture) that work without Accessibility. +- `Sources/Prompt/Capture.swift` β€” selection capture: backs up every + pasteboard item, simulates Cmd+C, polls `changeCount` for ≀0.5s, reads the + string, restores the backup. Runs off the main thread (hotkey dispatches to + a global queue); only the store append hops back to main. + +## Commands + +```bash +swift build # debug build +swift run Prompt # dev run (menu bar icon appears) +./build-app.sh 0.1.0 # release build + .app bundle + icon + codesign +./release.sh 0.1.0 # build-app.sh + dist/Prompt.zip + notarize + sha256 +scripts/make-icon.sh # regenerate Prompt.app/Contents/Resources/Icon.icns +``` + +There is no test target and no linter/formatter configured. Verification = +`swift build` and manual run. + +## Bundle build & signing + +`build-app.sh` does: release build β†’ handwritten `Info.plist` (NOT generated +from `Package.swift`) β†’ `make-icon.sh` β†’ `codesign`. It resolves a +`Developer ID Application` identity from the login keychain and falls back to +ad-hoc signing (`-`) if none is present. `LSUIElement` is true β†’ no Dock icon. +Bundle id is `dev.tzgyn.prompt`. + +## Release process + +1. `./release.sh ` β€” builds, zips `Prompt.app` to `dist/Prompt.zip`, + and prints its sha256 (it does not tag or upload). Notarizes when the + `PROMPT_NOTARY_*` env vars (or the shared Kapture App Store Connect key at + `~/.config/kapture/AuthKey_K5KXMMSB28.p8`) are present; set + `PROMPT_SKIP_NOTARY=1` to skip. +2. Commit and push `main`. +3. `tea releases create --tag v --asset dist/Prompt.zip --repo TZGyn/prompt --login gitea.tzgyn.dev`. +4. Bump `version`/`sha256` in `~/.cache/homebrew-tap/Casks/prompt.rb`, push, + then `git -C /opt/homebrew/Library/Taps/tzgyn/homebrew-tap pull --ff-only`. + +**Always print the upgrade script after any release or version bump:** + +```bash +brew update +brew upgrade --cask prompt +``` + +## Gotchas + +- **Never bind bare keys as `.keyboardShortcut` in the panel** (Space, Delete, + plain Cmd+C). SwiftUI key equivalents are matched before text input and + would steal characters from the composer TextField. Only Cmd-modified + shortcuts that TextFields don't use are bound (Cmd+F/K/N, Shift+Cmd+C). + This is why Copy has no in-panel shortcut: click-to-copy is the primary + gesture, matching Copper's intent. +- The panel is a `.nonactivatingPanel`; its `canBecomeKey` override is what + lets the composer TextField take focus. Don't replace it with a plain + NSWindow config without rechecking that typing works without the app + becoming active. +- The Cmd+V paste monitor only intercepts when the pasteboard has an image + AND no string, so pasting text into the composer is never swallowed. +- CGEvent tap callbacks run off-main; `HotkeyManager` hops to main before + firing. The tap is re-enabled on `tapDisabledByTimeout`. +- `Store` is `@MainActor`; anything reaching it from monitors/callbacks must + go through `Task { @MainActor in }`. +- `captureSelection()` blocks up to 0.5s polling the pasteboard β€” keep it off + 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. +- `.gitignore` covers `.build/`, `Prompt.app/`, `dist/` β€” build artifacts + should never be committed. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..75e048b --- /dev/null +++ b/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "Prompt", + platforms: [.macOS(.v14)], + targets: [ + .executableTarget( + name: "Prompt", + path: "Sources/Prompt" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..edf68b9 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Prompt + +A native macOS app for keeping prompts and photos, in the spirit of +[Copper](https://shadcn.com/copper) by shadcn: a floating panel that sits next +to your work. Click anything to copy it back to your clipboard. + +- **Click to copy** β€” text notes go back as text, photos go back as images. +- **Capture from anywhere** β€” select text in any app and tap **Left Shift twice**. +- **Photos** β€” paste (Cmd+V), drag & drop, or add via the photo button. +- **Sections** β€” type `# Name` in the composer; Cmd+K switches sections. +- **Copy as List** β€” Cmd+click to multi-select, right-click β†’ Copy as List + (or Shift+Cmd+C) for a numbered list ready to paste into a chat. +- **Merge Notes, Mark as Done, Edit, Move To** β€” right-click any item. +- **Search** β€” Cmd+F searches across all sections. +- **Local & private** β€” everything lives in + `~/Library/Application Support/Prompt/` (`store.json` + `Images/`). No + account, no sync, no telemetry. + +## Shortcuts + +| Action | Shortcut | +| --- | --- | +| Capture current selection | Left Shift Γ—2 (or Cmd+Shift+C) | +| Show/hide panel | Right Shift Γ—2 (or Cmd+Shift+Space) | +| Switch/create section | Cmd+K | +| Search | Cmd+F | +| Focus composer | Cmd+N | +| Copy selection as numbered list | Shift+Cmd+C | +| 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. + +## Install + +```bash +brew tap TZGyn/tap https://gitea.tzgyn.dev/TZGyn/homebrew-tap.git +brew trust TZGyn/tap +brew install --cask prompt +``` + +## Build from source + +```bash +swift build # debug build +swift run Prompt # dev run (menu bar icon appears) +./build-app.sh 0.1.0 # release build + Prompt.app + icon + codesign +./release.sh 0.1.0 # build + dist/Prompt.zip (+ notarization when configured) +``` + +Requires macOS 14+. diff --git a/Sources/Prompt/App.swift b/Sources/Prompt/App.swift new file mode 100644 index 0000000..84e603c --- /dev/null +++ b/Sources/Prompt/App.swift @@ -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 + } + } +} diff --git a/Sources/Prompt/Capture.swift b/Sources/Prompt/Capture.swift new file mode 100644 index 0000000..1995d94 --- /dev/null +++ b/Sources/Prompt/Capture.swift @@ -0,0 +1,78 @@ +import AppKit + +// Capture reads the current selection by simulating Cmd+C, watching the +// pasteboard change count, then restoring the previous clipboard contents so +// the user's own clipboard is untouched. Requires Accessibility permission +// for the synthesized events to be delivered. +enum Capture { + static func captureSelectionIntoStore() { + let sourceApp = NSWorkspace.shared.frontmostApplication?.localizedName + guard let text = captureSelection() else { return } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + Task { @MainActor in + Store.shared.addText(text, sourceApp: sourceApp) + PanelController.shared.show() + } + } + + static func captureSelection() -> String? { + let pasteboard = NSPasteboard.general + let backup = backupPasteboard(pasteboard) + let oldChangeCount = pasteboard.changeCount + + simulateCopy() + + let deadline = Date().addingTimeInterval(0.5) + var result: String? + while Date() < deadline { + if pasteboard.changeCount != oldChangeCount { + result = pasteboard.string(forType: .string) + break + } + usleep(10_000) + } + + restorePasteboard(pasteboard, backup: backup) + return result + } + + private static func simulateCopy() { + let source = CGEventSource(stateID: .hidSystemState) + let keyCodeC: CGKeyCode = 8 // kVK_ANSI_C + let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCodeC, keyDown: true) + keyDown?.flags = .maskCommand + keyDown?.post(tap: .cghidEventTap) + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCodeC, keyDown: false) + keyUp?.flags = .maskCommand + keyUp?.post(tap: .cghidEventTap) + } + + private static func backupPasteboard(_ pasteboard: NSPasteboard) -> [[String: Data]] { + var backup: [[String: Data]] = [] + for item in pasteboard.pasteboardItems ?? [] { + var entry: [String: Data] = [:] + for type in item.types { + if let data = item.data(forType: type) { + entry[type.rawValue] = data + } + } + backup.append(entry) + } + return backup + } + + private static func restorePasteboard(_ pasteboard: NSPasteboard, backup: [[String: Data]]) { + pasteboard.clearContents() + let items = backup.map { entry -> NSPasteboardItem in + let item = NSPasteboardItem() + for (type, data) in entry { + item.setData(data, forType: NSPasteboard.PasteboardType(type)) + } + return item + } + if !items.isEmpty { + pasteboard.writeObjects(items) + } + } +} diff --git a/Sources/Prompt/HotkeyManager.swift b/Sources/Prompt/HotkeyManager.swift new file mode 100644 index 0000000..d608ef0 --- /dev/null +++ b/Sources/Prompt/HotkeyManager.swift @@ -0,0 +1,143 @@ +import Cocoa +import Carbon + +// Global shortcuts: +// Left Shift x2 β€” capture the current selection into the active section +// Right Shift x2 β€” show/hide the panel +// Cmd+Shift+C β€” capture (fallback, no Accessibility needed) +// Cmd+Shift+Space β€” show/hide the panel (fallback) +// +// The double-Shift gestures come from a CGEvent tap on flagsChanged, which +// needs Accessibility access. The Carbon hotkeys work without it. +final class HotkeyManager { + static let shared = HotkeyManager() + + var onCapture: (() -> Void)? + var onTogglePanel: (() -> Void)? + + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var eventHandlerRef: EventHandlerRef? + private var hotKeyRefs: [EventHotKeyRef?] = [] + private let signature: OSType = 0x50524D50 // "PRMP" + + private var lastLeftShiftRelease: TimeInterval = 0 + private var lastRightShiftRelease: TimeInterval = 0 + private let doubleTapInterval: TimeInterval = 0.35 + + private init() {} + + var isListening: Bool { eventTap != nil } + + func start() { + startEventTap() + registerFallbackHotkeys() + } + + // MARK: - Double-Shift event tap + + private func startEventTap() { + let mask = CGEventMask(1 << CGEventType.flagsChanged.rawValue) + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: mask, + callback: hotkeyEventCallback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { return } + eventTap = tap + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + runLoopSource = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + } + + fileprivate func handle(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged? { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let tap = eventTap { CGEvent.tapEnable(tap: tap, enable: true) } + return Unmanaged.passUnretained(event) + } + guard type == .flagsChanged else { return Unmanaged.passUnretained(event) } + + let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + let shiftIsDown = event.flags.contains(.maskShift) + guard !shiftIsDown else { return Unmanaged.passUnretained(event) } // only count releases + + let now = ProcessInfo.processInfo.systemUptime + switch keyCode { + case 56: // left shift + if now - lastLeftShiftRelease < doubleTapInterval { + lastLeftShiftRelease = 0 + fire { $0.onCapture?() } + } else { + lastLeftShiftRelease = now + } + case 60: // right shift + if now - lastRightShiftRelease < doubleTapInterval { + lastRightShiftRelease = 0 + fire { $0.onTogglePanel?() } + } else { + lastRightShiftRelease = now + } + default: + break + } + return Unmanaged.passUnretained(event) + } + + private func fire(_ action: @escaping (HotkeyManager) -> Void) { + DispatchQueue.main.async { action(self) } + } + + // MARK: - Carbon fallback hotkeys + + private func registerFallbackHotkeys() { + var eventType = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ) + InstallEventHandler(GetApplicationEventTarget(), { _, event, _ in + var hotKeyID = EventHotKeyID() + let status = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hotKeyID + ) + guard status == noErr else { return noErr } + DispatchQueue.main.async { + switch hotKeyID.id { + case 1: HotkeyManager.shared.onTogglePanel?() + case 2: HotkeyManager.shared.onCapture?() + default: break + } + } + return noErr + }, 1, &eventType, nil, &eventHandlerRef) + + registerHotkey(id: 1, keyCode: UInt32(kVK_Space), modifiers: UInt32(cmdKey | shiftKey)) + registerHotkey(id: 2, keyCode: UInt32(kVK_ANSI_C), modifiers: UInt32(cmdKey | shiftKey)) + } + + private func registerHotkey(id: UInt32, keyCode: UInt32, modifiers: UInt32) { + var ref: EventHotKeyRef? + let hotKeyID = EventHotKeyID(signature: signature, id: id) + RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref) + hotKeyRefs.append(ref) + } +} + +private func hotkeyEventCallback( + proxy: CGEventTapProxy, + type: CGEventType, + event: CGEvent, + refcon: UnsafeMutableRawPointer? +) -> Unmanaged? { + guard let refcon else { return Unmanaged.passUnretained(event) } + let manager = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + return manager.handle(proxy: proxy, type: type, event: event) +} diff --git a/Sources/Prompt/PanelController.swift b/Sources/Prompt/PanelController.swift new file mode 100644 index 0000000..451fb49 --- /dev/null +++ b/Sources/Prompt/PanelController.swift @@ -0,0 +1,145 @@ +import SwiftUI +import AppKit + +extension Notification.Name { + static let promptPanelDidShow = Notification.Name("PromptPanelDidShowNotification") +} + +private final class KeyablePanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +@MainActor +final class PanelController: NSObject { + static let shared = PanelController() + + private var panel: KeyablePanel? + private var pasteMonitor: Any? + let editController = EditWindowController() + + var isVisible: Bool { panel?.isVisible ?? false } + + func toggle() { + isVisible ? hide() : show() + } + + func show() { + let panel = panel ?? makePanel() + if UserDefaults.standard.string(forKey: "NSWindow Frame PromptPanel") == nil { + positionAtRightEdge(panel) + } + NSApp.activate(ignoringOtherApps: true) + panel.makeKeyAndOrderFront(nil) + NotificationCenter.default.post(name: .promptPanelDidShow, object: nil) + } + + func hide() { + panel?.orderOut(nil) + NSApp.deactivate() + } + + private func makePanel() -> KeyablePanel { + let panel = KeyablePanel( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 640), + styleMask: [.titled, .closable, .resizable, .nonactivatingPanel, .fullSizeContentView], + backing: .buffered, + defer: false + ) + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + panel.level = .floating + panel.isMovableByWindowBackground = true + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.contentView = NSHostingView(rootView: PanelView(store: Store.shared)) + panel.setFrameAutosaveName("PromptPanel") + self.panel = panel + installPasteMonitor() + return panel + } + + private func positionAtRightEdge(_ panel: NSPanel) { + guard let screen = NSScreen.main?.visibleFrame else { return } + let size = NSSize(width: 360, height: screen.height - 24) + panel.setContentSize(size) + panel.setFrameOrigin(NSPoint(x: screen.maxX - size.width - 12, y: screen.minY + 12)) + } + + // Intercept Cmd+V when the panel is key and the clipboard holds an image + // (and no text, so normal text paste into the composer is unaffected). + private func installPasteMonitor() { + guard pasteMonitor == nil else { return } + pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, let panel = self.panel, event.window == panel else { return event } + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if modifiers == [.command], event.charactersIgnoringModifiers == "v" { + let pasteboard = NSPasteboard.general + if pasteboard.string(forType: .string) == nil, + let images = pasteboard.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage], + let image = images.first { + Task { @MainActor in Store.shared.addImage(image) } + return nil + } + } + return event + } + } +} + +@MainActor +final class EditWindowController: NSObject { + private var window: NSWindow? + + func edit(itemID: UUID, text: String) { + let view = EditView( + text: text, + onSave: { [weak self] newText in + Store.shared.updateText(itemID, newText: newText) + self?.window?.close() + }, + onCancel: { [weak self] in self?.window?.close() } + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Edit Note" + window.contentView = NSHostingView(rootView: view) + window.isReleasedWhenClosed = false + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + self.window = window + } +} + +private struct EditView: View { + @State var text: String + var onSave: (String) -> Void + var onCancel: () -> Void + + var body: some View { + VStack(spacing: 12) { + TextEditor(text: $text) + .font(.body) + .padding(4) + .background(Color.primary.opacity(0.05)) + .cornerRadius(6) + HStack { + Spacer() + Button("Cancel", action: onCancel) + .keyboardShortcut(.cancelAction) + Button("Save") { onSave(text) } + .keyboardShortcut(.defaultAction) + .keyboardShortcut("s", modifiers: .command) + } + } + .padding(16) + } +} diff --git a/Sources/Prompt/PanelView.swift b/Sources/Prompt/PanelView.swift new file mode 100644 index 0000000..249303b --- /dev/null +++ b/Sources/Prompt/PanelView.swift @@ -0,0 +1,484 @@ +import SwiftUI +import AppKit +import UniformTypeIdentifiers + +struct PanelView: View { + @ObservedObject var store: Store + + @State private var composer = "" + @State private var search = "" + @State private var showSearch = false + @State private var showSectionSwitcher = false + @State private var selection = Set() + @State private var copiedID: UUID? + @State private var accessibilityGranted = AXIsProcessTrusted() + + private enum Field: Hashable { case composer, search } + @FocusState private var focusedField: Field? + + var body: some View { + VStack(spacing: 0) { + header + if !accessibilityGranted { + permissionBanner + } + if showSearch { + searchField + } + Divider() + content + Divider() + composerField + } + .frame(minWidth: 300) + .background(shortcutButtons) + .overlay(sectionSwitcher) + .onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop) + .onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in + focusedField = .composer + accessibilityGranted = AXIsProcessTrusted() + } + .onAppear { focusedField = .composer } + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 10) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() } + } label: { + HStack(spacing: 4) { + Text(store.activeSection.name.uppercased()) + .font(.system(size: 12, weight: .semibold)) + .tracking(1) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .bold)) + } + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Switch section (Cmd+K)") + + Spacer() + + Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") } + .buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)") + 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)") + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + private var permissionBanner: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text("Shift-tap capture needs Accessibility access.") + .font(.caption) + Spacer() + Button("Grant…") { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + } + .font(.caption) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.yellow.opacity(0.1)) + } + + private var searchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search all sections", text: $search) + .textFieldStyle(.plain) + .focused($focusedField, equals: .search) + .onExitCommand { + search = "" + showSearch = false + focusedField = .composer + } + if !search.isEmpty { + Button { search = "" } label: { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.plain).foregroundStyle(.secondary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + // MARK: - Content + + private var displayedItems: [(section: String, item: PromptItem)] { + if search.isEmpty { + return store.activeSection.items.map { ("", $0) } + } + let query = search.lowercased() + return store.sections.flatMap { section in + section.items.compactMap { item in + switch item.content { + case .text(let text) where text.lowercased().contains(query): + return (section.name, item) + default: + return nil + } + } + } + } + + private var content: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 6) { + if displayedItems.isEmpty { + emptyState + } + ForEach(displayedItems, id: \.item.id) { entry in + row(for: entry.item, sectionName: entry.section) + .id(entry.item.id) + } + } + .padding(10) + } + .onChange(of: store.activeSection.items.count) { _ in + if let last = store.activeSection.items.last { + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + } + } + + private var emptyState: some View { + VStack(spacing: 10) { + Image(systemName: "tray") + .font(.system(size: 28)) + .foregroundStyle(.quaternary) + Text(search.isEmpty ? "Nothing here yet" : "No matches") + .font(.callout) + .foregroundStyle(.secondary) + if search.isEmpty { + Text("Select text anywhere and tap Shift twice,\nor drop photos into this panel.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 48) + } + + private func row(for item: PromptItem, sectionName: String) -> some View { + Button { handleClick(item) } label: { + VStack(alignment: .leading, spacing: 6) { + if !sectionName.isEmpty { + Text(sectionName.uppercased()) + .font(.system(size: 9, weight: .semibold)) + .tracking(1) + .foregroundStyle(.tertiary) + } + itemContent(item) + HStack(spacing: 6) { + if let sourceApp = item.sourceApp { + Text(sourceApp) + } + Text(item.createdAt, style: .time) + if item.done { + Image(systemName: "checkmark") + } + } + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground(item)) + .cornerRadius(8) + .overlay(alignment: .topTrailing) { + if copiedID == item.id { + Label("Copied", systemImage: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.green) + .padding(6) + .transition(.opacity) + } + } + } + .buttonStyle(.plain) + .contextMenu { contextMenu(for: item) } + } + + private func rowBackground(_ item: PromptItem) -> Color { + if selection.contains(item.id) { return Color.accentColor.opacity(0.25) } + return Color.primary.opacity(0.05) + } + + @ViewBuilder + private func itemContent(_ item: PromptItem) -> some View { + switch item.content { + case .text(let text): + Text(markdown(text)) + .font(.callout) + .strikethrough(item.done) + .opacity(item.done ? 0.45 : 1) + .lineLimit(8) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + case .image(let filename): + if let image = store.image(for: filename) { + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(maxHeight: 160) + .cornerRadius(6) + .opacity(item.done ? 0.4 : 1) + } else { + Label("Missing image", systemImage: "photo") + .foregroundStyle(.secondary) + } + } + } + + private func contextMenu(for item: PromptItem) -> some View { + Group { + Button("Copy") { copy(item) } + Button("Copy as List") { + store.copyAsList(ids: effectiveIDs(for: item)) + flashCopied(item) + } + Divider() + Button(item.done ? "Mark as Not Done" : "Mark as Done") { store.toggleDone(item.id) } + if case .text(let text) = item.content { + Button("Edit…") { + PanelController.shared.editController.edit(itemID: item.id, text: text) + } + } + if case .image(let filename) = item.content { + Button("Open") { + NSWorkspace.shared.open(store.imageURL(for: filename)) + } + } + if selection.count > 1, selection.contains(item.id) { + Button("Merge Notes") { + store.merge(ids: selection) + selection.removeAll() + } + } + Menu("Move To") { + ForEach(store.sections.filter { $0.id != store.sectionID(of: item.id) }) { section in + Button(section.name) { store.move(item.id, to: section.id) } + } + } + Divider() + Button("Delete", role: .destructive) { + selection.remove(item.id) + store.delete(item.id) + } + } + } + + // MARK: - Section switcher + + @ViewBuilder + private var sectionSwitcher: some View { + if showSectionSwitcher { + VStack(spacing: 0) { + Color.black.opacity(0.001) + .onTapGesture { showSectionSwitcher = false } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + VStack(spacing: 0) { + ForEach(store.sections) { section in + Button { + store.activeSectionID = section.id + showSectionSwitcher = false + } label: { + HStack { + Text(section.name) + .font(.callout) + Spacer() + Text("\(section.items.count)") + .font(.caption) + .foregroundStyle(.tertiary) + if section.id == store.activeSection.id { + Image(systemName: "checkmark") + .font(.caption) + .foregroundStyle(Color.accentColor) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + Divider() + Button { + composer = "# " + showSectionSwitcher = false + focusedField = .composer + } label: { + HStack { + Image(systemName: "plus") + Text("New Section") + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .background(.regularMaterial) + .cornerRadius(10) + .shadow(radius: 12) + .padding(.horizontal, 24) + .frame(maxHeight: .infinity, alignment: .top) + .padding(.top, 44) + .transition(.opacity) + } + } + + // MARK: - Composer + + private var composerField: some View { + HStack(spacing: 8) { + TextField("Add a note or prompt (\(store.activeSection.name))", text: $composer) + .textFieldStyle(.plain) + .focused($focusedField, equals: .composer) + .onSubmit(submit) + .onExitCommand { PanelController.shared.hide() } + if !composer.isEmpty { + Button(action: submit) { Image(systemName: "arrow.up.circle.fill") } + .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + private func submit() { + let text = composer.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + if text.hasPrefix("#") { + let name = String(text.dropFirst()).trimmingCharacters(in: .whitespaces) + if !name.isEmpty { store.createSection(named: name) } + } else { + store.addText(text) + } + composer = "" + } + + // MARK: - Shortcuts + + // Only Cmd-modified shortcuts live here: bare keys (Space, Delete, Cmd+C) + // would be swallowed as key equivalents and break typing in the composer. + private var shortcutButtons: some View { + VStack { + Button("") { toggleSearch() } + .keyboardShortcut("f", modifiers: .command) + Button("") { + withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() } + } + .keyboardShortcut("k", modifiers: .command) + Button("") { focusedField = .composer } + .keyboardShortcut("n", modifiers: .command) + Button("") { + guard !selection.isEmpty else { return } + store.copyAsList(ids: selection) + if let id = selection.first { flashCopied(PromptItem(id: id, content: .text(""))) } + } + .keyboardShortcut("c", modifiers: [.command, .shift]) + } + .opacity(0) + .frame(width: 0, height: 0) + .allowsHitTesting(false) + } + + // MARK: - Actions + + private func handleClick(_ item: PromptItem) { + if NSApp.currentEvent?.modifierFlags.contains(.command) == true { + if selection.contains(item.id) { + selection.remove(item.id) + } else { + selection.insert(item.id) + } + return + } + copy(item) + } + + private func copy(_ item: PromptItem) { + store.copy(item) + flashCopied(item) + } + + private func flashCopied(_ item: PromptItem) { + withAnimation { copiedID = item.id } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + if copiedID == item.id { + withAnimation { copiedID = nil } + } + } + } + + private func effectiveIDs(for item: PromptItem) -> Set { + if selection.contains(item.id), selection.count > 1 { return selection } + return [item.id] + } + + private func toggleSearch() { + showSearch.toggle() + if showSearch { + focusedField = .search + } else { + search = "" + focusedField = .composer + } + } + + private func addPhotos() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.image] + panel.allowsMultipleSelection = true + guard panel.runModal() == .OK else { return } + for url in panel.urls { + if let image = NSImage(contentsOf: url) { + store.addImage(image) + } + } + } + + private func handleDrop(_ providers: [NSItemProvider]) -> Bool { + var handled = false + for provider in providers { + if provider.canLoadObject(ofClass: NSImage.self) { + handled = true + _ = provider.loadObject(ofClass: NSImage.self) { object, _ in + if let image = object as? NSImage { + Task { @MainActor in Store.shared.addImage(image) } + } + } + } else if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + handled = true + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in + guard let data = item as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil), + let image = NSImage(contentsOf: url) else { return } + Task { @MainActor in Store.shared.addImage(image) } + } + } + } + return handled + } + + private func markdown(_ string: String) -> AttributedString { + let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + return (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string) + } +} diff --git a/Sources/Prompt/Store.swift b/Sources/Prompt/Store.swift new file mode 100644 index 0000000..e31b9e1 --- /dev/null +++ b/Sources/Prompt/Store.swift @@ -0,0 +1,271 @@ +import AppKit + +enum ItemContent: Codable, Equatable { + case text(String) + case image(String) // filename inside the Images directory +} + +struct PromptItem: Codable, Identifiable, Equatable { + var id: UUID = UUID() + var content: ItemContent + var done: Bool = false + var createdAt: Date = Date() + var sourceApp: String? = nil +} + +struct Section: Codable, Identifiable, Equatable { + var id: UUID = UUID() + var name: String + var items: [PromptItem] = [] +} + +private struct StoreFile: Codable { + var sections: [Section] + var activeSectionID: UUID? +} + +@MainActor +final class Store: ObservableObject { + static let shared = Store() + + @Published private(set) var sections: [Section] = [] + @Published var activeSectionID: UUID? { + didSet { save() } + } + + let directory: URL + let imagesDirectory: URL + private let storeFile: URL + private let imageCache = NSCache() + + init() { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + directory = appSupport.appendingPathComponent("Prompt", isDirectory: true) + imagesDirectory = directory.appendingPathComponent("Images", isDirectory: true) + storeFile = directory.appendingPathComponent("store.json") + + try? FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true) + load() + if sections.isEmpty { + let inbox = Section(name: "Inbox") + sections = [inbox] + activeSectionID = inbox.id + save() + } + if activeSectionID == nil || !sections.contains(where: { $0.id == activeSectionID }) { + activeSectionID = sections.first?.id + } + } + + var activeSection: Section { + if let id = activeSectionID, let section = sections.first(where: { $0.id == id }) { + return section + } + return sections[0] + } + + // MARK: - Persistence + + private func load() { + guard let data = try? Data(contentsOf: storeFile), + let file = try? JSONDecoder().decode(StoreFile.self, from: data) else { return } + sections = file.sections + activeSectionID = file.activeSectionID + } + + private func save() { + let file = StoreFile(sections: sections, activeSectionID: activeSectionID) + guard let data = try? JSONEncoder().encode(file) else { return } + try? data.write(to: storeFile, options: .atomic) + } + + // MARK: - Sections + + @discardableResult + func createSection(named rawName: String) -> Section { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + if let existing = sections.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) { + activeSectionID = existing.id + return existing + } + let section = Section(name: name) + sections.append(section) + activeSectionID = section.id + save() + return section + } + + // MARK: - Items + + @discardableResult + func addText(_ text: String, sourceApp: String? = nil) -> PromptItem { + let item = PromptItem(content: .text(text), sourceApp: sourceApp) + append(item) + return item + } + + @discardableResult + func addImage(_ image: NSImage) -> PromptItem? { + guard let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) else { return nil } + let filename = "\(UUID().uuidString).png" + do { + try png.write(to: imagesDirectory.appendingPathComponent(filename)) + } catch { + return nil + } + let item = PromptItem(content: .image(filename)) + append(item) + return item + } + + private func append(_ item: PromptItem) { + guard let index = sections.firstIndex(where: { $0.id == activeSection.id }) else { return } + sections[index].items.append(item) + save() + } + + func toggleDone(_ id: UUID) { + mutateItem(id) { $0.done.toggle() } + } + + func updateText(_ id: UUID, newText: String) { + mutateItem(id) { item in + if case .text = item.content { item.content = .text(newText) } + } + } + + func delete(_ id: UUID) { + for sectionIndex in sections.indices { + if let itemIndex = sections[sectionIndex].items.firstIndex(where: { $0.id == id }) { + let item = sections[sectionIndex].items[itemIndex] + if case .image(let filename) = item.content { + try? FileManager.default.removeItem(at: imagesDirectory.appendingPathComponent(filename)) + imageCache.removeObject(forKey: filename as NSString) + } + sections[sectionIndex].items.remove(at: itemIndex) + save() + return + } + } + } + + func move(_ id: UUID, to targetSectionID: UUID) { + var moved: PromptItem? + for sectionIndex in sections.indices { + if let itemIndex = sections[sectionIndex].items.firstIndex(where: { $0.id == id }) { + moved = sections[sectionIndex].items.remove(at: itemIndex) + break + } + } + guard let item = moved, + let targetIndex = sections.firstIndex(where: { $0.id == targetSectionID }) else { return } + sections[targetIndex].items.append(item) + save() + } + + func merge(ids: Set) { + let merged = sections.flatMap(\.items) + .filter { ids.contains($0.id) } + .sorted { $0.createdAt < $1.createdAt } + let texts = merged.compactMap { item -> String? in + if case .text(let text) = item.content { return text } + return nil + } + guard texts.count > 1, let first = merged.first else { return } + let targetSectionID = sectionID(of: first.id) + for item in merged where !item.content.isImage { + delete(item.id) + } + let combined = PromptItem(content: .text(texts.joined(separator: "\n\n"))) + if let index = sections.firstIndex(where: { $0.id == targetSectionID }) { + sections[index].items.append(combined) + save() + } + } + + func clearDoneInActiveSection() { + guard let index = sections.firstIndex(where: { $0.id == activeSection.id }) else { return } + for item in sections[index].items where item.done { + if case .image(let filename) = item.content { + try? FileManager.default.removeItem(at: imagesDirectory.appendingPathComponent(filename)) + imageCache.removeObject(forKey: filename as NSString) + } + } + sections[index].items.removeAll { $0.done } + save() + } + + func sectionID(of itemID: UUID) -> UUID? { + for section in sections where section.items.contains(where: { $0.id == itemID }) { + return section.id + } + return nil + } + + func item(_ id: UUID) -> PromptItem? { + sections.flatMap(\.items).first { $0.id == id } + } + + private func mutateItem(_ id: UUID, _ mutate: (inout PromptItem) -> Void) { + for sectionIndex in sections.indices { + if let itemIndex = sections[sectionIndex].items.firstIndex(where: { $0.id == id }) { + mutate(§ions[sectionIndex].items[itemIndex]) + save() + return + } + } + } + + // MARK: - Clipboard + + func copy(_ item: PromptItem) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + switch item.content { + case .text(let text): + pasteboard.setString(text, forType: .string) + case .image(let filename): + if let image = image(for: filename) { + pasteboard.writeObjects([image]) + } + } + } + + func copyAsList(ids: Set) { + let items = sections.flatMap(\.items) + .filter { ids.contains($0.id) } + .sorted { $0.createdAt < $1.createdAt } + let texts = items.compactMap { item -> String? in + if case .text(let text) = item.content { return text } + return nil + } + guard !texts.isEmpty else { return } + let list = texts.enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n") + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(list, forType: .string) + } + + // MARK: - Images + + func image(for filename: String) -> NSImage? { + if let cached = imageCache.object(forKey: filename as NSString) { return cached } + let url = imagesDirectory.appendingPathComponent(filename) + guard let image = NSImage(contentsOf: url) else { return nil } + imageCache.setObject(image, forKey: filename as NSString) + return image + } + + func imageURL(for filename: String) -> URL { + imagesDirectory.appendingPathComponent(filename) + } +} + +private extension ItemContent { + var isImage: Bool { + if case .image = self { return true } + return false + } +} diff --git a/build-app.sh b/build-app.sh new file mode 100755 index 0000000..1fc4187 --- /dev/null +++ b/build-app.sh @@ -0,0 +1,66 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" + +VERSION="${1:-0.1.0}" + +swift build -c release + +BIN=".build/release/Prompt" +APP="Prompt.app" +IDENT="" + +# Prefer a Developer ID Application identity so the app gets clean Gatekeeper +# behavior; fall back to ad-hoc signing (no TCC-stable identity) otherwise. +if command -v security >/dev/null 2>&1; then + IDENT=$(security find-identity -v -p codesigning 2>/dev/null | awk '/Developer ID Application:/ { print $2; exit }') +fi + +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" +cp "$BIN" "$APP/Contents/MacOS/Prompt" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleExecutable + Prompt + CFBundleIdentifier + dev.tzgyn.prompt + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Prompt + CFBundlePackageType + APPL + CFBundleIconFile + Icon + CFBundleShortVersionString + ${VERSION} + CFBundleVersion + ${VERSION} + LSMinimumSystemVersion + 14.0 + LSUIElement + + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +PLIST + +chmod +x scripts/make-icon.sh +./scripts/make-icon.sh + +if [ -n "$IDENT" ]; then + security unlock-keychain "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null || true + codesign --force --options runtime --timestamp --sign "$IDENT" "$APP" + echo "Built $APP ${VERSION} (signed with Developer ID '$IDENT') β€” open $APP" +else + codesign --force --sign - "$APP" + echo "Built $APP ${VERSION} (ad-hoc signed) β€” open $APP" +fi diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..6896031 --- /dev/null +++ b/release.sh @@ -0,0 +1,74 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" + +if [ "$#" -ne 1 ]; then + echo "usage: $0 e.g. ./release.sh 0.1.0" >&2 + exit 1 +fi +VERSION="$1" + +# Notarization credentials. Configure once, e.g. in ~/.zshrc: +# export PROMPT_NOTARY_API_KEY_PATH="$HOME/.config/kapture/AuthKey_K5KXMMSB28.p8" +# export PROMPT_NOTARY_API_KEY_ID="K5KXMMSB28" +# export PROMPT_NOTARY_API_ISSUER="3e25e5dc-8b75-453f-a71c-c7f3feaac628" +# If these are unset (or PROMPT_SKIP_NOTARY=1), release.sh builds, zips, +# and stops there β€” the zip is signed but NOT notarized. + +./build-app.sh "$VERSION" + +mkdir -p dist +rm -f dist/Prompt.zip +ditto -c -k --keepParent Prompt.app dist/Prompt.zip + +if [ -n "${PROMPT_SKIP_NOTARY:-}" ]; then + echo + echo "Skipping notarization (PROMPT_SKIP_NOTARY set)." + shasum -a 256 dist/Prompt.zip + exit 0 +fi + +# Default to the shared TZGyn App Store Connect key (same one Kapture uses). +PROMPT_NOTARY_API_KEY_PATH="${PROMPT_NOTARY_API_KEY_PATH:-$HOME/.config/kapture/AuthKey_K5KXMMSB28.p8}" +PROMPT_NOTARY_API_KEY_ID="${PROMPT_NOTARY_API_KEY_ID:-K5KXMMSB28}" +PROMPT_NOTARY_API_ISSUER="${PROMPT_NOTARY_API_ISSUER:-3e25e5dc-8b75-453f-a71c-c7f3feaac628}" + +if [ ! -f "$PROMPT_NOTARY_API_KEY_PATH" ]; then + echo "Notarization skipped β€” key not found at $PROMPT_NOTARY_API_KEY_PATH." >&2 + shasum -a 256 dist/Prompt.zip + exit 0 +fi + +if ! command -v xcrun >/dev/null 2>&1 || ! xcrun --find notarytool >/dev/null 2>&1; then + echo "Notarization skipped β€” xcrun notarytool not found (requires Xcode 13+)." >&2 + shasum -a 256 dist/Prompt.zip + exit 0 +fi + +echo "Submitting to Apple for notarization (first run can take minutes)..." +xcrun notarytool submit dist/Prompt.zip \ + --key "$PROMPT_NOTARY_API_KEY_PATH" \ + --key-id "$PROMPT_NOTARY_API_KEY_ID" \ + --issuer "$PROMPT_NOTARY_API_ISSUER" \ + --wait \ + --output-format json > notary-result.json + +STATUS=$(jq -r '.status' notary-result.json 2>/dev/null || echo "unknown") +if [ "$STATUS" != "Accepted" ]; then + echo "Notarization failed with status: $STATUS" >&2 + cat notary-result.json >&2 || true + rm -f notary-result.json + exit 1 +fi +rm -f notary-result.json + +echo "Notarization accepted β€” stapling ticket onto the app..." +xcrun stapler staple Prompt.app +xcrun stapler validate Prompt.app + +rm -f dist/Prompt.zip +ditto -c -k --keepParent Prompt.app dist/Prompt.zip + +echo +echo "Done β€” notarized." +shasum -a 256 dist/Prompt.zip diff --git a/scripts/gen-icon.swift b/scripts/gen-icon.swift new file mode 100644 index 0000000..9583dd3 --- /dev/null +++ b/scripts/gen-icon.swift @@ -0,0 +1,45 @@ +import AppKit + +let size = NSSize(width: 1024, height: 1024) +guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: 1024, pixelsHigh: 1024, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 +) else { exit(1) } + +NSGraphicsContext.saveGraphicsState() +NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) +NSGraphicsContext.current?.imageInterpolation = .high + +// Dark rounded tile. +let tile = NSBezierPath(roundedRect: NSRect(x: 112, y: 112, width: 800, height: 800), xRadius: 180, yRadius: 180) +let gradient = NSGradient(colors: [ + NSColor(calibratedRed: 0.16, green: 0.17, blue: 0.22, alpha: 1), + NSColor(calibratedRed: 0.07, green: 0.08, blue: 0.11, alpha: 1), +])! +gradient.draw(in: tile, angle: -90) + +// Terminal-style prompt glyph: chevron + cursor block. +NSColor(calibratedRed: 0.40, green: 0.85, blue: 0.55, alpha: 1).setStroke() +let chevron = NSBezierPath() +chevron.move(to: NSPoint(x: 300, y: 640)) +chevron.line(to: NSPoint(x: 470, y: 512)) +chevron.line(to: NSPoint(x: 300, y: 384)) +chevron.lineWidth = 74 +chevron.lineCapStyle = .round +chevron.lineJoinStyle = .round +chevron.stroke() + +NSColor(calibratedRed: 0.40, green: 0.85, blue: 0.55, alpha: 1).setFill() +let cursor = NSBezierPath(roundedRect: NSRect(x: 560, y: 384, width: 150, height: 84), xRadius: 16, yRadius: 16) +cursor.fill() + +NSGraphicsContext.restoreGraphicsState() + +guard let data = rep.representation(using: .png, properties: [:]) else { exit(1) } +if CommandLine.arguments.count < 2 { exit(1) } +do { + try data.write(to: URL(fileURLWithPath: CommandLine.arguments[1])) +} catch { + exit(1) +} diff --git a/scripts/make-icon.sh b/scripts/make-icon.sh new file mode 100755 index 0000000..d0f120c --- /dev/null +++ b/scripts/make-icon.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +swift scripts/gen-icon.swift "$TMP/icon-1024.png" + +mkdir -p "$TMP/Icon.iconset" +for s in 16 32 128 256 512; do + d=$((s * 2)) + sips -z "$s" "$s" "$TMP/icon-1024.png" --out "$TMP/Icon.iconset/icon_${s}x${s}.png" >/dev/null + sips -z "$d" "$d" "$TMP/icon-1024.png" --out "$TMP/Icon.iconset/icon_${s}x${s}@2x.png" >/dev/null +done + +iconutil -c icns "$TMP/Icon.iconset" -o "$TMP/Icon.icns" +mkdir -p Prompt.app/Contents/Resources +cp "$TMP/Icon.icns" Prompt.app/Contents/Resources/Icon.icns +echo "Generated Prompt.app/Contents/Resources/Icon.icns"