5 Commits
Author SHA1 Message Date
TZGyn 90890186c0 Tell the user when Accessibility is missing
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.
2026-10-08 10:45:50 +08:00
TZGyn 962c205d1d Make the app reachable and quittable after the panel closes
Closing the panel only hid it, and launching the app again did nothing
because the running instance ignored the reopen, so it looked like the
app refused to exit. Reopening now shows the panel, and the main menu
makes Cmd+Q and Cmd+W work while the panel has focus. Termination also
tears down the event tap, hotkeys and sync timers instead of leaving
them live.
2026-10-08 10:43:42 +08:00
TZGyn 1bd346ce20 Explain the SSL failure instead of just reporting it
A Postgres URL without an sslmode made lib/pq ask for TLS and die with
"SSL is not enabled on the server", which reads like a server fault when
it is really a connection-string default. The error now says what to
append, and the server retries briefly so a deploy is not marked
unhealthy just because Postgres was still starting.

The sync server URL also defaults to the hosted instance now, so a fresh
install needs no setup.
2026-10-08 10:38:54 +08:00
TZGyn c0dd211ee5 Drop the compose file and systemd unit for the sync server
The server is a single process, so a compose file that only wrapped one
container added a second way to do the same thing. Docker is now the one
documented path, pointing at a Postgres that lives outside the container.

💘 Generated with Crush

Assisted-by: Crush:kimi-k3
2026-10-08 10:25:21 +08:00
TZGyn cbdf5538aa Add a Dockerfile so the sync server is one command to deploy
Self-hosting meant building the binary by hand and wiring Postgres up
yourself. The image is a static binary on distroless with a built-in
healthcheck flag, and docker-compose brings up Postgres alongside it.

The image itself is unbuilt here: no container runtime is available in
this environment, so only the static Linux build and the healthcheck
flag have been exercised.

💘 Generated with Crush

Assisted-by: Crush:kimi-k3
2026-10-08 10:23:54 +08:00
12 changed files with 333 additions and 58 deletions
+20 -6
View File
@@ -13,7 +13,13 @@ clicking an item copies it back to the clipboard.
`NSStatusItem Preferred Position Prompt`). Left click toggles the panel, `NSStatusItem Preferred Position Prompt`). Left click toggles the panel,
right click shows the menu (accessibility state, launch at login, open data right click shows the menu (accessibility state, launch at login, open data
folder, quit). Contains the single-instance guard and `LoginItem` folder, quit). Contains the single-instance guard and `LoginItem`
(`SMAppService`). (`SMAppService`). Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit
(Cmd+Q) — an accessory app shows no menu bar, but the main menu still
services key equivalents, which is the only way to quit from the keyboard
while the panel has focus. `applicationShouldHandleReopen` shows the panel so
launching an already-running app does something. Supports `--sync-check` and
`--diag` (writes the status item's frame and permission state to
`/tmp/prompt-diag.txt`, then exits).
- `Sources/Prompt/Store.swift` — `@MainActor` `ObservableObject` singleton. - `Sources/Prompt/Store.swift` — `@MainActor` `ObservableObject` singleton.
Data model: `Section` → `[PromptItem]`, item content is Data model: `Section` → `[PromptItem]`, item content is
`.text(String)` or `.image(String)` (filename). Persists to `.text(String)` or `.image(String)` (filename). Persists to
@@ -40,11 +46,16 @@ clicking an item copies it back to the clipboard.
a global queue); only the store append hops back to main. a global queue); only the store append hops back to main.
- `Sources/Prompt/SyncService.swift` — optional sync client. Holds the group - `Sources/Prompt/SyncService.swift` — optional sync client. Holds the group
code and server URL in `UserDefaults` (`sync.code`, `sync.serverURL`, code and server URL in `UserDefaults` (`sync.code`, `sync.serverURL`,
`sync.version`), pushes the whole store as JSON, and polls every 5s. `sync.version`). The URL defaults to `SyncService.defaultServerURL`
(`https://prompt.tzgyn.dev`); nothing is sent anywhere until a code exists,
because polling only runs when one is set. Pushes the whole store as JSON and
polls every 5s.
- `Server/main.go` — the Go sync backend (single file, `net/http` + `lib/pq`). - `Server/main.go` — the Go sync backend (single file, `net/http` + `lib/pq`).
Also ships `Server/README.md` (self-hosting) and `Server/prompt-sync.service` Also ships `Server/Dockerfile` (static binary on distroless) and
(systemd unit). Logs one line per request, creates its schema on startup, and `Server/README.md` (self-hosting). Logs one line per request, creates its
shuts down gracefully on SIGTERM so a redeploy cannot cut off a push. schema on startup, shuts down gracefully on SIGTERM so a redeploy cannot cut
off a push, and supports `-healthcheck` (pings local `/healthz`) so the
shell-less image needs no curl for its Docker `HEALTHCHECK`.
## Sync protocol ## Sync protocol
@@ -162,7 +173,10 @@ brew upgrade --cask prompt
the main thread (the hotkey handler already dispatches to a global queue). the main thread (the hotkey handler already dispatches to a global queue).
- The double-Shift tap needs Accessibility; without it the event tap silently - 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 fails and only the Carbon fallbacks work. The panel banner and the status
menu both surface the permission state. menu both surface the permission state, and `AppDelegate` shows a launch
alert when it is missing — otherwise a user who hides the panel is locked out
(no hotkey, no Dock icon) and reports it as "the app won't come back". Check
with `Prompt --diag` (`accessibility: false` is the tell).
- `Info.plist` sets `NSAllowsArbitraryLoads` because the sync server URL is - `Info.plist` sets `NSAllowsArbitraryLoads` because the sync server URL is
user-supplied and is often plain HTTP (LAN address, self-hosted box). ATS user-supplied and is often plain HTTP (LAN address, self-hosted box). ATS
would otherwise block it with a confusing error. would otherwise block it with a confusing error.
+16 -10
View File
@@ -32,10 +32,11 @@ to your work. Click anything to copy it back to your clipboard.
| Close panel | Esc | | Close panel | Esc |
Double-Shift gestures require **Accessibility access** (Prompt reads the Double-Shift gestures require **Accessibility access** (Prompt reads the
selection by simulating Cmd+C, then restores your clipboard untouched). The selection by simulating Cmd+C, then restores your clipboard untouched). Until
Cmd+Shift fallbacks work without it. Bare keys like Space and Cmd+C are it is granted, use Cmd+Shift+Space and Cmd+Shift+C, or click the menu bar icon
deliberately not bound inside the panel so they never conflict with typing in — and launching the app again always brings the panel back. Bare keys like
the composer. Space and Cmd+C are deliberately not bound inside the panel so they never
conflict with typing in the composer.
## Install ## Install
@@ -58,15 +59,20 @@ PROMPT_SYNC_DATABASE_URL="postgres://localhost:5432/prompt_sync?sslmode=disable"
go run . # listens on :8080 go run . # listens on :8080
``` ```
Then in the panel: click the sync icon, set the server URL (e.g. Or build the Docker image (`docker build -t prompt-sync Server/`) and point it
`http://192.168.1.10:8080` or an HTTPS host), and either **Generate Code** on at your own Postgres — see [Server/README.md](Server/README.md) for that and for
the first Mac or paste the code into **Join** on the other. deploying to a host.
Then in the panel: click the sync icon (or Cmd+,), confirm the server URL, and
either **Generate Code** on the first Mac or paste the code into **Join** on the
other.
Notes: Notes:
- Both Macs must be able to reach the server. A LAN address or a Tailscale - Sync defaults to `https://prompt.tzgyn.dev`; change it in the sync panel to
hostname works; `localhost` only works if the server runs on the same Mac. point at your own server. Both Macs must be able to reach it — a LAN address
See [Server/README.md](Server/README.md) to deploy it on a VPS or home server. or a Tailscale hostname works, and `localhost` only works if the server runs
on the same Mac. See [Server/README.md](Server/README.md) to deploy it.
- Joining a group that already has notes replaces what's on that Mac. - Joining a group that already has notes replaces what's on that Mac.
- The app polls every 5 seconds (a tiny version check) and pushes 1.5s after a - The app polls every 5 seconds (a tiny version check) and pushes 1.5s after a
change; last write wins, and changes that came from the server are not echoed change; last write wins, and changes that came from the server are not echoed
+4
View File
@@ -0,0 +1,4 @@
.git
prompt-sync
prompt-sync-*
*.md
+27
View File
@@ -0,0 +1,27 @@
# Build stage: the server is pure Go (lib/pq is pure Go), so CGO is off and the
# result is a static binary that runs on a shell-less base image.
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /prompt-sync .
# Runtime stage: distroless has no shell, so the binary does its own
# healthcheck (see -healthcheck in main.go). Runs as non-root.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /prompt-sync /prompt-sync
ENV PROMPT_SYNC_ADDR=:8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/prompt-sync", "-healthcheck"]
ENTRYPOINT ["/prompt-sync"]
+58 -11
View File
@@ -26,21 +26,68 @@ PROMPT_SYNC_DATABASE_URL="postgres://localhost:5432/prompt_sync?sslmode=disable"
The schema is created on startup. Every request is logged as The schema is created on startup. Every request is logged as
`METHOD PATH STATUS DURATION`; SIGTERM/SIGINT shut down gracefully so an `METHOD PATH STATUS DURATION`; SIGTERM/SIGINT shut down gracefully so an
in-flight push is not cut off mid-write. in-flight push is not cut off mid-write. If the database is not reachable yet
it retries five times, 2s apart, before giving up.
## Deploy to a Linux host > **`sslmode` matters.** lib/pq defaults to `sslmode=require`, so a connection
> string copied from a host that runs Postgres *without* TLS (Coolify's
> internal Postgres, a plain `docker run postgres`, Homebrew) fails with
> `pq: SSL is not enabled on the server`. Append `?sslmode=disable` in that
> case — the server prints exactly that hint when it hits this.
## Deploying with Coolify
Use a Dockerfile application pointed at this repo with base directory `Server/`
(so Coolify builds `Server/Dockerfile`), expose port `8080`, and set:
| Variable | Value |
| --- | --- |
| `PROMPT_SYNC_DATABASE_URL` | your Postgres URL **plus `?sslmode=disable`** |
| `PROMPT_SYNC_ADDR` | `:8080` (this is already the default) |
Coolify detects the `HEALTHCHECK` in the Dockerfile and runs
`prompt-sync -healthcheck` with its 5s start period, so the container only goes
live once `/healthz` answers. It also logs a generic warning that a healthcheck
needs `curl` or `wget` — that does not apply here, the binary does its own
check, so there is no reason to add either to the image (it is distroless and
has no shell).
## Run with Docker
The image is a static binary on `distroless` (non-root, no shell), so its
`HEALTHCHECK` calls `prompt-sync -healthcheck`, which pings the local
`/healthz` — no curl needed in the image. That is what `docker ps` reports as
the container health.
```bash ```bash
# from the repo root, on the Mac docker build -t prompt-sync Server/
cd Server docker run -d --name prompt-sync -p 8080:8080 \
GOOS=linux GOARCH=arm64 go build -o prompt-sync . # amd64 for x86 hosts -e PROMPT_SYNC_DATABASE_URL="postgres://user:pass@host:5432/prompt_sync?sslmode=disable" \
scp prompt-sync <host>:/tmp/ && ssh <host> 'sudo mv /tmp/prompt-sync /usr/local/bin/' prompt-sync
# on the host: create the database and role, then docker logs -f prompt-sync # request log
sudo cp Server/prompt-sync.service /etc/systemd/system/ ```
sudo systemctl edit --full prompt-sync # set PROMPT_SYNC_DATABASE_URL
sudo systemctl enable --now prompt-sync Postgres stays outside the container; the server only needs a URL. If Postgres
journalctl -u prompt-sync -f # verify it is listening runs on the Docker host itself, `host` is `host.docker.internal` on macOS and
Windows, or the host's LAN address on Linux (`--network host` also works).
## Deploy to a host
Build for the host's architecture and ship the image, or build it on the host
itself (a `git clone` plus `docker build` is usually simplest):
```bash
# build on the Mac for a linux/arm64 host, then copy it over
docker build --platform linux/arm64 -t prompt-sync Server/
docker save prompt-sync | gzip > prompt-sync.tar.gz
scp prompt-sync.tar.gz <host>:
ssh <host> 'gunzip -c prompt-sync.tar.gz | docker load'
ssh <host> 'docker run -d --name prompt-sync --restart unless-stopped \
-p 8080:8080 \
-e PROMPT_SYNC_DATABASE_URL="postgres://user:pass@db-host:5432/prompt_sync?sslmode=disable" \
prompt-sync'
``` ```
Put it behind TLS if it is reachable from the internet — the code is the only Put it behind TLS if it is reachable from the internet — the code is the only
+67 -5
View File
@@ -27,6 +27,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"regexp" "regexp"
"strings"
"syscall" "syscall"
"time" "time"
@@ -48,6 +49,13 @@ type Server struct {
} }
func main() { func main() {
// -healthcheck pings the local /healthz and exits non-zero on failure. It
// exists so the container image can run on a shell-less base (distroless),
// where there is no wget or curl for a HEALTHCHECK to use.
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
os.Exit(healthcheck())
}
dsn := os.Getenv("PROMPT_SYNC_DATABASE_URL") dsn := os.Getenv("PROMPT_SYNC_DATABASE_URL")
if dsn == "" { if dsn == "" {
dsn = "postgres://localhost:5432/prompt_sync?sslmode=disable" dsn = "postgres://localhost:5432/prompt_sync?sslmode=disable"
@@ -57,14 +65,11 @@ func main() {
addr = ":8080" addr = ":8080"
} }
db, err := sql.Open("postgres", dsn) db, err := openDatabase(dsn)
if err != nil { if err != nil {
log.Fatalf("open database: %v", err) log.Fatalf("connect to database: %v%s", err, hintFor(err))
} }
defer db.Close() defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("connect to database: %v", err)
}
if _, err := db.Exec(schema); err != nil { if _, err := db.Exec(schema); err != nil {
log.Fatalf("ensure schema: %v", err) log.Fatalf("ensure schema: %v", err)
} }
@@ -103,6 +108,63 @@ func main() {
} }
} }
// openDatabase connects, retrying briefly so a deploy is not marked unhealthy
// just because Postgres was still starting up.
func openDatabase(dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
var lastErr error
for attempt := 1; attempt <= 5; attempt++ {
if lastErr = db.Ping(); lastErr == nil {
return db, nil
}
if attempt < 5 {
log.Printf("database not ready (attempt %d/5): %v", attempt, lastErr)
time.Sleep(2 * time.Second)
}
}
db.Close()
return nil, lastErr
}
// hintFor turns the most common connection failure into something actionable.
// lib/pq defaults to sslmode=require, so any DSN copied from a host that runs
// Postgres without TLS (Coolify's internal Postgres, docker-compose, Homebrew)
// fails with a bare "SSL is not enabled on the server".
func hintFor(err error) string {
if !strings.Contains(err.Error(), "SSL is not enabled") {
return ""
}
return "\n\nhint: lib/pq defaults to sslmode=require, but that Postgres has TLS off." +
"\n Append ?sslmode=disable to PROMPT_SYNC_DATABASE_URL" +
"\n (e.g. postgres://user:pass@host:5432/prompt_sync?sslmode=disable)."
}
// healthcheck returns 0 when the local /healthz answers 200.
func healthcheck() int {
addr := os.Getenv("PROMPT_SYNC_ADDR")
if addr == "" {
addr = ":8080"
}
if strings.HasPrefix(addr, ":") {
addr = "127.0.0.1" + addr
}
client := &http.Client{Timeout: 3 * time.Second}
response, err := client.Get("http://" + addr + "/healthz")
if err != nil {
log.Printf("healthcheck: %v", err)
return 1
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
log.Printf("healthcheck: unexpected status %d", response.StatusCode)
return 1
}
return 0
}
// withLogging keeps a one-line trail of method, path, status and duration so a // withLogging keeps a one-line trail of method, path, status and duration so a
// self-hosted deployment can be debugged from journalctl or docker logs. // self-hosted deployment can be debugged from journalctl or docker logs.
func withLogging(next http.Handler) http.Handler { func withLogging(next http.Handler) http.Handler {
-22
View File
@@ -1,22 +0,0 @@
[Unit]
Description=Prompt sync server
Documentation=https://gitea.tzgyn.dev/TZGyn/prompt
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
# The binary is built on a Mac with:
# GOOS=linux GOARCH=arm64 go build -o prompt-sync .
ExecStart=/usr/local/bin/prompt-sync
Environment=PROMPT_SYNC_ADDR=:8080
Environment=PROMPT_SYNC_DATABASE_URL=postgres://prompt:CHANGE_ME@localhost:5432/prompt_sync?sslmode=disable
Restart=on-failure
RestartSec=2
# The server only needs to talk to Postgres and bind :8080.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
+102
View File
@@ -27,6 +27,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return 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 // Single instance: stale copies keep their hotkeys alive after an
// update and eat the events meant for the new instance. // update and eat the events meant for the new instance.
let myPid = ProcessInfo.processInfo.processIdentifier let myPid = ProcessInfo.processInfo.processIdentifier
@@ -47,6 +59,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
HotkeyManager.shared.start() HotkeyManager.shared.start()
setupStatusItem() setupStatusItem()
setupMainMenu()
promptForAccessibilityIfNeeded()
// Show the panel on launch so the app is discoverable (it has no Dock // 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). // icon and the menu bar icon can be hard to spot on a full bar).
@@ -56,6 +70,94 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
/// 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() { private func setupStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
item.autosaveName = "Prompt" item.autosaveName = "Prompt"
+16
View File
@@ -34,6 +34,22 @@ final class HotkeyManager {
registerFallbackHotkeys() registerFallbackHotkeys()
} }
func stop() {
if let tap = eventTap {
CGEvent.tapEnable(tap: tap, enable: false)
CFMachPortInvalidate(tap)
}
if let source = runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
for ref in hotKeyRefs {
if let ref { UnregisterEventHotKey(ref) }
}
hotKeyRefs.removeAll()
eventTap = nil
runLoopSource = nil
}
// MARK: - Double-Shift event tap // MARK: - Double-Shift event tap
private func startEventTap() { private func startEventTap() {
+2
View File
@@ -31,12 +31,14 @@ final class PanelController: NSObject {
} }
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil) panel.makeKeyAndOrderFront(nil)
NSLog("Prompt: panel shown")
NotificationCenter.default.post(name: .promptPanelDidShow, object: nil) NotificationCenter.default.post(name: .promptPanelDidShow, object: nil)
} }
func hide() { func hide() {
panel?.orderOut(nil) panel?.orderOut(nil)
NSApp.deactivate() NSApp.deactivate()
NSLog("Prompt: panel hidden (app keeps running for the Shift-Shift hotkeys)")
} }
private func makePanel() -> KeyablePanel { private func makePanel() -> KeyablePanel {
+7 -1
View File
@@ -76,7 +76,8 @@ struct PanelView: View {
Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") } Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos") .buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos")
Button { PanelController.shared.hide() } label: { Image(systemName: "xmark") } Button { PanelController.shared.hide() } label: { Image(systemName: "xmark") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Close (Esc)") .buttonStyle(.plain).foregroundStyle(.secondary)
.help("Hide the panel (Cmd+W) — Prompt keeps running so Shift-Shift capture still works. Quit from the menu bar icon or Cmd+Q.")
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -86,8 +87,13 @@ struct PanelView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow) .foregroundStyle(.yellow)
VStack(alignment: .leading, spacing: 2) {
Text("Shift-tap capture needs Accessibility access.") Text("Shift-tap capture needs Accessibility access.")
.font(.caption) .font(.caption)
Text("Meanwhile: Cmd+Shift+Space toggles this panel.")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer() Spacer()
Button("Grant…") { Button("Grant…") {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
+12 -1
View File
@@ -39,6 +39,9 @@ final class SyncService: ObservableObject {
private static let urlKey = "sync.serverURL" private static let urlKey = "sync.serverURL"
private static let versionKey = "sync.version" private static let versionKey = "sync.version"
/// Where sync points unless the user overrides it in the sync panel.
static let defaultServerURL = "https://prompt.tzgyn.dev"
private var lastVersion: Int64 { private var lastVersion: Int64 {
get { Int64(defaults.integer(forKey: Self.versionKey)) } get { Int64(defaults.integer(forKey: Self.versionKey)) }
set { defaults.set(newValue, forKey: Self.versionKey) } set { defaults.set(newValue, forKey: Self.versionKey) }
@@ -54,7 +57,7 @@ final class SyncService: ObservableObject {
private init() { private init() {
code = defaults.string(forKey: Self.codeKey) code = defaults.string(forKey: Self.codeKey)
serverURL = defaults.string(forKey: Self.urlKey) ?? "http://localhost:8080" serverURL = defaults.string(forKey: Self.urlKey) ?? Self.defaultServerURL
} }
func start() { func start() {
@@ -64,6 +67,14 @@ final class SyncService: ObservableObject {
restartPolling() restartPolling()
} }
func stop() {
pollTimer?.invalidate()
pollTimer = nil
pushWork?.cancel()
pushWork = nil
Store.shared.onDidChange = nil
}
// MARK: - Group membership // MARK: - Group membership
func generateCode() { func generateCode() {