Add optional sync between Macs with a shared 8-digit code
Notes and photos were stuck on one machine. A small Go/Postgres backend
stores each group's payload under a code that doubles as the shared
secret, so a second Mac just needs the code — no account or login.
Applying a remote change must not push it straight back, otherwise the
two Macs would bump the version back and forth forever, so payloads are
fingerprinted and identical ones are skipped.
💘 Generated with Crush
Assisted-by: Crush:kimi-k3
This commit is contained in:
@@ -5,6 +5,9 @@
|
|||||||
# App bundle (build artifact, regenerate with ./build-app.sh)
|
# App bundle (build artifact, regenerate with ./build-app.sh)
|
||||||
Prompt.app/
|
Prompt.app/
|
||||||
|
|
||||||
|
# Go server binary (go build ./... writes it next to the source)
|
||||||
|
Server/server
|
||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
*.p8
|
*.p8
|
||||||
|
|
||||||
|
|||||||
@@ -38,19 +38,62 @@ clicking an item copies it back to the clipboard.
|
|||||||
pasteboard item, simulates Cmd+C, polls `changeCount` for ≤0.5s, reads the
|
pasteboard item, simulates Cmd+C, polls `changeCount` for ≤0.5s, reads the
|
||||||
string, restores the backup. Runs off the main thread (hotkey dispatches to
|
string, restores the backup. Runs off the main thread (hotkey dispatches to
|
||||||
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
|
||||||
|
code and server URL in `UserDefaults` (`sync.code`, `sync.serverURL`,
|
||||||
|
`sync.version`), pushes the whole store as JSON, and polls every 5s.
|
||||||
|
- `Server/main.go` — the Go sync backend (single file, `net/http` + `lib/pq`).
|
||||||
|
|
||||||
|
## Sync protocol
|
||||||
|
|
||||||
|
No authentication by design: a sync group is named by an 8-digit code
|
||||||
|
(`1234-5678`) that doubles as the shared secret. The server stores the client
|
||||||
|
payload as opaque `JSONB`; it never parses note contents.
|
||||||
|
|
||||||
|
| Endpoint | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /api/codes` | Generates a unique code and inserts an empty group |
|
||||||
|
| `GET /api/sync/{code}` | `{"version":N,"updatedAt":…,"data":{…}\|null}`, 404 if unknown |
|
||||||
|
| `PUT /api/sync/{code}` | Upserts the payload, returns the incremented version |
|
||||||
|
| `GET /healthz` | Liveness |
|
||||||
|
|
||||||
|
Client rules:
|
||||||
|
|
||||||
|
- Payload = `{sections, activeSectionID, images}`, where `images` maps a
|
||||||
|
filename to base64 PNG so photos travel with the notes.
|
||||||
|
- Version counter is server-owned; the client stores the last version it saw
|
||||||
|
in `sync.version` and only applies a remote payload when `remote.version >
|
||||||
|
lastVersion`.
|
||||||
|
- **Never push a payload that came from the server.** `pushNow()` compares a
|
||||||
|
sorted-keys encoding against `lastSyncedPayload` and skips identical
|
||||||
|
payloads. Without this, applying a remote change would immediately push it
|
||||||
|
back, bump the version, and the two Macs would ping-pong forever. The
|
||||||
|
`applyingRemote` flag alone is not enough: the save-triggered push is
|
||||||
|
dispatched asynchronously, so it runs *after* the flag is cleared.
|
||||||
|
- Conflict resolution is last-writer-wins at whole-store granularity. That is
|
||||||
|
acceptable for personal use; it is not a merge.
|
||||||
|
- `apply()` writes incoming images, deletes image files no longer referenced,
|
||||||
|
and replaces the store in one shot.
|
||||||
|
- `Prompt --sync-check` runs `SyncService.runDiagnostic()`: pushes any local
|
||||||
|
change and prints local vs remote versions, then exits. Use it to debug a
|
||||||
|
second Mac without the UI.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
swift build # debug build
|
swift build # debug build
|
||||||
swift run Prompt # dev run (menu bar icon appears)
|
swift run Prompt # dev run (menu bar icon appears)
|
||||||
|
./Prompt.app/Contents/MacOS/Prompt --sync-check # push + report sync state, then exit
|
||||||
./build-app.sh 0.1.0 # release build + .app bundle + icon + codesign
|
./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
|
./release.sh 0.1.0 # build-app.sh + dist/Prompt.zip + notarize + sha256
|
||||||
scripts/make-icon.sh # regenerate Prompt.app/Contents/Resources/Icon.icns
|
scripts/make-icon.sh # regenerate Prompt.app/Contents/Resources/Icon.icns
|
||||||
|
|
||||||
|
# sync server (Go, needs Postgres)
|
||||||
|
createdb prompt_sync
|
||||||
|
cd Server && go run . # :8080, PROMPT_SYNC_DATABASE_URL / PROMPT_SYNC_ADDR
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no test target and no linter/formatter configured. Verification =
|
There is no test target and no linter/formatter configured. Verification =
|
||||||
`swift build` and manual run.
|
`swift build`, `go build ./...`, and manual run (`--sync-check` for sync).
|
||||||
|
|
||||||
## Bundle build & signing
|
## Bundle build & signing
|
||||||
|
|
||||||
@@ -102,5 +145,11 @@ brew upgrade --cask prompt
|
|||||||
- 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.
|
||||||
|
- `Info.plist` sets `NSAllowsArbitraryLoads` because the sync server URL is
|
||||||
|
user-supplied and is often plain HTTP (LAN address, self-hosted box). ATS
|
||||||
|
would otherwise block it with a confusing error.
|
||||||
|
- The server keeps `data` as opaque `JSONB`, so the payload shape can evolve
|
||||||
|
without a migration — but both Macs should run the same app version, since a
|
||||||
|
newer client writing unknown fields will round-trip them to an older one.
|
||||||
- `.gitignore` covers `.build/`, `Prompt.app/`, `dist/` — build artifacts
|
- `.gitignore` covers `.build/`, `Prompt.app/`, `dist/` — build artifacts
|
||||||
should never be committed.
|
should never be committed.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ to your work. Click anything to copy it back to your clipboard.
|
|||||||
(or Shift+Cmd+C) for a numbered list ready to paste into a chat.
|
(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.
|
- **Merge Notes, Mark as Done, Edit, Move To** — right-click any item.
|
||||||
- **Search** — Cmd+F searches across all sections.
|
- **Search** — Cmd+F searches across all sections.
|
||||||
|
- **Sync (optional)** — generate an 8-digit code (`1234-5678`) on one Mac and
|
||||||
|
enter it on the other; no account, no login. The code is the only secret.
|
||||||
- **Local & private** — everything lives in
|
- **Local & private** — everything lives in
|
||||||
`~/Library/Application Support/Prompt/` (`store.json` + `Images/`). No
|
`~/Library/Application Support/Prompt/` (`store.json` + `Images/`). No
|
||||||
account, no sync, no telemetry.
|
account, no sync, no telemetry.
|
||||||
@@ -42,6 +44,32 @@ brew trust TZGyn/tap
|
|||||||
brew install --cask prompt
|
brew install --cask prompt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Sync server
|
||||||
|
|
||||||
|
Sync is optional and needs the small Go backend in `Server/`. There is no
|
||||||
|
authentication: a sync group is identified by an 8-digit code, and whoever has
|
||||||
|
the code can read and write the group.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
createdb prompt_sync
|
||||||
|
cd Server
|
||||||
|
PROMPT_SYNC_DATABASE_URL="postgres://localhost:5432/prompt_sync?sslmode=disable" \
|
||||||
|
go run . # listens on :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in the panel: click the sync icon, set the server URL (e.g.
|
||||||
|
`http://192.168.1.10:8080` or an HTTPS host), and either **Generate Code** on
|
||||||
|
the first Mac or paste the code into **Join** on the other.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Both Macs must be able to reach the server. A LAN address or a Tailscale
|
||||||
|
hostname works; `localhost` only works if the server runs on the same Mac.
|
||||||
|
- Joining a group that already has notes replaces what's on that Mac.
|
||||||
|
- The app polls every 5 seconds and pushes 1.5s after a change; last write
|
||||||
|
wins. Changes that came from the server are not echoed back.
|
||||||
|
- Verify sync from a terminal with `Prompt --sync-check`.
|
||||||
|
|
||||||
## Build from source
|
## Build from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module dev.tzgyn.prompt/server
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require github.com/lib/pq v1.10.9
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
// Prompt sync server. No authentication: a sync group is identified by an
|
||||||
|
// 8-digit code (1234-5678) that acts as the shared secret. Whoever has the
|
||||||
|
// code can read and overwrite the group's data.
|
||||||
|
//
|
||||||
|
// Endpoints:
|
||||||
|
//
|
||||||
|
// POST /api/codes -> {"code":"1234-5678"} (create group)
|
||||||
|
// GET /api/sync/{code} -> {"version":N,"data":{...}} (404 if unknown)
|
||||||
|
// PUT /api/sync/{code} -> {"version":N+1} (upsert payload)
|
||||||
|
// GET /healthz -> 200
|
||||||
|
//
|
||||||
|
// Config:
|
||||||
|
//
|
||||||
|
// PROMPT_SYNC_DATABASE_URL (default postgres://localhost:5432/prompt_sync?sslmode=disable)
|
||||||
|
// PROMPT_SYNC_ADDR (default :8080)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
const schema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_groups (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
data JSONB,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`
|
||||||
|
|
||||||
|
var codePattern = regexp.MustCompile(`^\d{4}-\d{4}$`)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dsn := os.Getenv("PROMPT_SYNC_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = "postgres://localhost:5432/prompt_sync?sslmode=disable"
|
||||||
|
}
|
||||||
|
addr := os.Getenv("PROMPT_SYNC_ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
addr = ":8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("postgres", dsn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
log.Fatalf("connect to database: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
log.Fatalf("ensure schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{db: db}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /api/codes", s.createCode)
|
||||||
|
mux.HandleFunc("GET /api/sync/{code}", s.getSync)
|
||||||
|
mux.HandleFunc("PUT /api/sync/{code}", s.putSync)
|
||||||
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("prompt-sync listening on %s", addr)
|
||||||
|
log.Fatal(http.ListenAndServe(addr, mux))
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCode generates a unique 8-digit code, formats it as 1234-5678, and
|
||||||
|
// inserts an empty group so the code exists before the first PUT.
|
||||||
|
func (s *Server) createCode(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
for range 10 {
|
||||||
|
code, err := newCode()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not generate code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var exists bool
|
||||||
|
err = s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM sync_groups WHERE code = $1)`, code).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`INSERT INTO sync_groups (code) VALUES ($1)`, code); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"code": code})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not find a free code")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getSync(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.PathValue("code")
|
||||||
|
if !codePattern.MatchString(code) {
|
||||||
|
writeError(w, http.StatusBadRequest, "code must look like 1234-5678")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
version int64
|
||||||
|
data []byte
|
||||||
|
updatedAt time.Time
|
||||||
|
)
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT version, data, updated_at FROM sync_groups WHERE code = $1`, code,
|
||||||
|
).Scan(&version, &data, &updatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "unknown code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
data = []byte("null")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprintf(w, `{"version":%d,"updatedAt":%q,"data":%s}`,
|
||||||
|
version, updatedAt.UTC().Format(time.RFC3339), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) putSync(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.PathValue("code")
|
||||||
|
if !codePattern.MatchString(code) {
|
||||||
|
writeError(w, http.StatusBadRequest, "code must look like 1234-5678")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := http.MaxBytesReader(w, r.Body, 50<<20) // 50 MB ceiling (images inside)
|
||||||
|
defer body.Close()
|
||||||
|
var payload json.RawMessage
|
||||||
|
if err := json.NewDecoder(body).Decode(&payload); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "body must be JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var version int64
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
INSERT INTO sync_groups (code, version, data, updated_at)
|
||||||
|
VALUES ($1, 1, $2, now())
|
||||||
|
ON CONFLICT (code) DO UPDATE
|
||||||
|
SET version = sync_groups.version + 1, data = $2, updated_at = now()
|
||||||
|
RETURNING version`, code, payload).Scan(&version)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]int64{"version": version})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCode() (string, error) {
|
||||||
|
var b [8]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
digits := make([]byte, 8)
|
||||||
|
for i, v := range b {
|
||||||
|
digits[i] = '0' + v%10
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%s", digits[:4], digits[4:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": message})
|
||||||
|
}
|
||||||
@@ -19,6 +19,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
NSApp.setActivationPolicy(.accessory)
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
|
||||||
|
if CommandLine.arguments.contains("--sync-check") {
|
||||||
|
Task { @MainActor in
|
||||||
|
await SyncService.shared.runDiagnostic()
|
||||||
|
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
|
||||||
@@ -43,6 +51,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
// 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).
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
SyncService.shared.start()
|
||||||
PanelController.shared.show()
|
PanelController.shared.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import UniformTypeIdentifiers
|
|||||||
|
|
||||||
struct PanelView: View {
|
struct PanelView: View {
|
||||||
@ObservedObject var store: Store
|
@ObservedObject var store: Store
|
||||||
|
@ObservedObject private var sync = SyncService.shared
|
||||||
|
|
||||||
@State private var composer = ""
|
@State private var composer = ""
|
||||||
@State private var search = ""
|
@State private var search = ""
|
||||||
@State private var showSearch = false
|
@State private var showSearch = false
|
||||||
@State private var showSectionSwitcher = false
|
@State private var showSectionSwitcher = false
|
||||||
|
@State private var showSync = false
|
||||||
|
@State private var joinCode = ""
|
||||||
@State private var selection = Set<UUID>()
|
@State private var selection = Set<UUID>()
|
||||||
@State private var copiedID: UUID?
|
@State private var copiedID: UUID?
|
||||||
@State private var accessibilityGranted = AXIsProcessTrusted()
|
@State private var accessibilityGranted = AXIsProcessTrusted()
|
||||||
@@ -33,6 +36,7 @@ struct PanelView: View {
|
|||||||
.frame(minWidth: 300)
|
.frame(minWidth: 300)
|
||||||
.background(shortcutButtons)
|
.background(shortcutButtons)
|
||||||
.overlay(sectionSwitcher)
|
.overlay(sectionSwitcher)
|
||||||
|
.overlay(syncOverlay)
|
||||||
.onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop)
|
.onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in
|
||||||
focusedField = .composer
|
focusedField = .composer
|
||||||
@@ -62,6 +66,11 @@ struct PanelView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
Button { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
.foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain).help("Sync")
|
||||||
Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") }
|
Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") }
|
||||||
.buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)")
|
.buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)")
|
||||||
Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") }
|
Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") }
|
||||||
@@ -144,7 +153,7 @@ struct PanelView: View {
|
|||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
.onChange(of: store.activeSection.items.count) { _ in
|
.onChange(of: store.activeSection.items.count) {
|
||||||
if let last = store.activeSection.items.last {
|
if let last = store.activeSection.items.last {
|
||||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||||
}
|
}
|
||||||
@@ -342,6 +351,105 @@ struct PanelView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var syncOverlay: some View {
|
||||||
|
if showSync {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Color.black.opacity(0.001)
|
||||||
|
.onTapGesture { showSync = false }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("Sync").font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if sync.isSyncing { ProgressView().controlSize(.small) }
|
||||||
|
}
|
||||||
|
TextField("Server URL", text: $sync.serverURL)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.callout)
|
||||||
|
if let code = sync.code {
|
||||||
|
Divider()
|
||||||
|
Text("Your code").font(.caption).foregroundStyle(.secondary)
|
||||||
|
HStack {
|
||||||
|
Text(code)
|
||||||
|
.font(.system(size: 20, weight: .medium, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
let pasteboard = NSPasteboard.general
|
||||||
|
pasteboard.clearContents()
|
||||||
|
pasteboard.setString(code, forType: .string)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "doc.on.doc")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Copy code")
|
||||||
|
}
|
||||||
|
Text("Enter this code on your other Mac to sync.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
statusLine
|
||||||
|
Divider()
|
||||||
|
Button("Disconnect", role: .destructive) { sync.disconnect() }
|
||||||
|
.font(.callout)
|
||||||
|
} else {
|
||||||
|
Divider()
|
||||||
|
Button("Generate Code") { sync.generateCode() }
|
||||||
|
.disabled(sync.isSyncing)
|
||||||
|
Text("Creates a new sync group and uploads what's here.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Divider()
|
||||||
|
Text("Or join an existing group").font(.caption).foregroundStyle(.secondary)
|
||||||
|
HStack {
|
||||||
|
TextField("1234-5678", text: $joinCode)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(.callout, design: .monospaced))
|
||||||
|
.onExitCommand { showSync = false }
|
||||||
|
Button("Join") {
|
||||||
|
sync.join(rawCode: joinCode)
|
||||||
|
joinCode = ""
|
||||||
|
}
|
||||||
|
.disabled(sync.isSyncing || joinCode.isEmpty)
|
||||||
|
}
|
||||||
|
Text("Joining replaces what's on this Mac with the group's notes.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let error = sync.lastError {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.cornerRadius(10)
|
||||||
|
.shadow(radius: 12)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
|
.padding(.top, 44)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var statusLine: some View {
|
||||||
|
if sync.isSyncing {
|
||||||
|
Text("Syncing…").font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else if let last = sync.lastSyncedAt {
|
||||||
|
Text("Last synced \(last, style: .relative) ago")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("Not synced yet").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Composer
|
// MARK: - Composer
|
||||||
|
|
||||||
private var composerField: some View {
|
private var composerField: some View {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ final class Store: ObservableObject {
|
|||||||
try? FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true)
|
||||||
load()
|
load()
|
||||||
if sections.isEmpty {
|
if sections.isEmpty {
|
||||||
let workspace = Section(name: "Workspace")
|
let workspace = Section(name: "Default")
|
||||||
sections = [workspace]
|
sections = [workspace]
|
||||||
activeSectionID = workspace.id
|
activeSectionID = workspace.id
|
||||||
save()
|
save()
|
||||||
@@ -77,6 +77,41 @@ final class Store: ObservableObject {
|
|||||||
let file = StoreFile(sections: sections, activeSectionID: activeSectionID)
|
let file = StoreFile(sections: sections, activeSectionID: activeSectionID)
|
||||||
guard let data = try? JSONEncoder().encode(file) else { return }
|
guard let data = try? JSONEncoder().encode(file) else { return }
|
||||||
try? data.write(to: storeFile, options: .atomic)
|
try? data.write(to: storeFile, options: .atomic)
|
||||||
|
onDidChange?()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync support
|
||||||
|
|
||||||
|
/// Called after every save so `SyncService` can push the change.
|
||||||
|
var onDidChange: (() -> Void)?
|
||||||
|
|
||||||
|
func replaceAll(sections newSections: [Section], activeSectionID newActiveSectionID: UUID?) {
|
||||||
|
sections = newSections
|
||||||
|
if let newActiveSectionID, newSections.contains(where: { $0.id == newActiveSectionID }) {
|
||||||
|
activeSectionID = newActiveSectionID
|
||||||
|
} else {
|
||||||
|
activeSectionID = newSections.first?.id
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageData(for filename: String) -> Data? {
|
||||||
|
try? Data(contentsOf: imagesDirectory.appendingPathComponent(filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
func importImage(filename: String, data: Data) {
|
||||||
|
let url = imagesDirectory.appendingPathComponent(filename)
|
||||||
|
if let existing = try? Data(contentsOf: url), existing == data { return }
|
||||||
|
try? data.write(to: url)
|
||||||
|
imageCache.removeObject(forKey: filename as NSString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupOrphanedImages(referenced: Set<String>) {
|
||||||
|
let files = (try? FileManager.default.contentsOfDirectory(at: imagesDirectory, includingPropertiesForKeys: nil)) ?? []
|
||||||
|
for file in files where !referenced.contains(file.lastPathComponent) {
|
||||||
|
try? FileManager.default.removeItem(at: file)
|
||||||
|
imageCache.removeObject(forKey: file.lastPathComponent as NSString)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sections
|
// MARK: - Sections
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
struct SyncPayload: Codable {
|
||||||
|
var sections: [Section]
|
||||||
|
var activeSectionID: UUID?
|
||||||
|
var images: [String: String] // filename -> base64 PNG
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SyncGetResponse: Codable {
|
||||||
|
var version: Int64
|
||||||
|
var data: SyncPayload?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Syncs the whole store (sections + items + images) to the Go backend.
|
||||||
|
// No authentication: the 8-digit group code (1234-5678) is the secret.
|
||||||
|
// Conflict resolution is last-writer-wins at store granularity, coordinated
|
||||||
|
// by a server-side version counter.
|
||||||
|
@MainActor
|
||||||
|
final class SyncService: ObservableObject {
|
||||||
|
static let shared = SyncService()
|
||||||
|
|
||||||
|
@Published private(set) var isSyncing = false
|
||||||
|
@Published private(set) var lastSyncedAt: Date?
|
||||||
|
@Published var lastError: String?
|
||||||
|
|
||||||
|
@Published var code: String? {
|
||||||
|
didSet {
|
||||||
|
defaults.set(code, forKey: Self.codeKey)
|
||||||
|
restartPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var serverURL: String {
|
||||||
|
didSet { defaults.set(serverURL, forKey: Self.urlKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
private static let codeKey = "sync.code"
|
||||||
|
private static let urlKey = "sync.serverURL"
|
||||||
|
private static let versionKey = "sync.version"
|
||||||
|
|
||||||
|
private var lastVersion: Int64 {
|
||||||
|
get { Int64(defaults.integer(forKey: Self.versionKey)) }
|
||||||
|
set { defaults.set(newValue, forKey: Self.versionKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pollTimer: Timer?
|
||||||
|
private var pushWork: DispatchWorkItem?
|
||||||
|
private var applyingRemote = false
|
||||||
|
/// Bytes of the last payload this client pushed or applied. Comparing it
|
||||||
|
/// before pushing stops an applied remote change from echoing straight
|
||||||
|
/// back to the server (which would ping-pong between two Macs forever).
|
||||||
|
private var lastSyncedPayload: Data?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
code = defaults.string(forKey: Self.codeKey)
|
||||||
|
serverURL = defaults.string(forKey: Self.urlKey) ?? "http://localhost:8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
Store.shared.onDidChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.schedulePush() }
|
||||||
|
}
|
||||||
|
restartPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Group membership
|
||||||
|
|
||||||
|
func generateCode() {
|
||||||
|
guard let url = endpoint("/api/codes") else {
|
||||||
|
lastError = "Server URL must start with http:// or https://"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
try Self.requireOK(response)
|
||||||
|
let result = try JSONDecoder().decode([String: String].self, from: data)
|
||||||
|
guard let code = result["code"] else { throw SyncError.badResponse }
|
||||||
|
self.code = code
|
||||||
|
self.push()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Joining a group with data REPLACES the local store with the remote one.
|
||||||
|
// Joining an empty group uploads the local store instead.
|
||||||
|
func join(rawCode: String) {
|
||||||
|
guard let formatted = Self.normalizeCode(rawCode) else {
|
||||||
|
lastError = "Code must be 8 digits, like 1234-5678"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let url = endpoint("/api/sync/\(formatted)") else {
|
||||||
|
lastError = "Server URL must start with http:// or https://"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
let (data, response) = try await URLSession.shared.data(from: url)
|
||||||
|
if (response as? HTTPURLResponse)?.statusCode == 404 {
|
||||||
|
throw SyncError.unknownCode
|
||||||
|
}
|
||||||
|
try Self.requireOK(response)
|
||||||
|
let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data)
|
||||||
|
self.code = formatted
|
||||||
|
if let payload = remote.data {
|
||||||
|
self.apply(payload, version: remote.version)
|
||||||
|
} else {
|
||||||
|
self.push()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
code = nil
|
||||||
|
lastVersion = 0
|
||||||
|
lastError = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Push / poll
|
||||||
|
|
||||||
|
func schedulePush() {
|
||||||
|
guard !applyingRemote, code != nil else { return }
|
||||||
|
pushWork?.cancel()
|
||||||
|
let work = DispatchWorkItem { [weak self] in
|
||||||
|
Task { @MainActor in self?.push() }
|
||||||
|
}
|
||||||
|
pushWork = work
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: work)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func push() {
|
||||||
|
run { try await self.pushNow() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pushNow() async throws {
|
||||||
|
guard let code, let url = endpoint("/api/sync/\(code)") else { return }
|
||||||
|
let payload = buildPayload()
|
||||||
|
guard let encoded = encode(payload) else { return }
|
||||||
|
guard encoded != lastSyncedPayload else {
|
||||||
|
syncLog("push skipped: unchanged since last sync")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "PUT"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = encoded
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
try Self.requireOK(response)
|
||||||
|
let result = try JSONDecoder().decode([String: Int64].self, from: data)
|
||||||
|
lastVersion = result["version"] ?? lastVersion
|
||||||
|
lastSyncedPayload = encoded
|
||||||
|
lastSyncedAt = Date()
|
||||||
|
syncLog("pushed as v\(lastVersion)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Powers `Prompt --sync-check`: pushes any local change and reports both
|
||||||
|
/// sides so sync can be verified from a terminal on any Mac.
|
||||||
|
func runDiagnostic() async {
|
||||||
|
print("Prompt sync check")
|
||||||
|
print("server: \(serverURL)")
|
||||||
|
guard let code else {
|
||||||
|
print("code: (none — generate one in the panel first)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
print("code: \(code)")
|
||||||
|
print("local: \(Store.shared.sections.count) sections, last known v\(lastVersion)")
|
||||||
|
do {
|
||||||
|
try await pushNow()
|
||||||
|
print("push: ok (now at v\(lastVersion))")
|
||||||
|
} catch {
|
||||||
|
print("push: failed — \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
guard let url = endpoint("/api/sync/\(code)") else { return }
|
||||||
|
do {
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
|
let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data)
|
||||||
|
print("remote: v\(remote.version), \(remote.data?.sections.count ?? 0) sections")
|
||||||
|
} catch {
|
||||||
|
print("remote: failed — \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func poll() {
|
||||||
|
guard !isSyncing, let code, let url = endpoint("/api/sync/\(code)") else { return }
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let (data, response) = try await URLSession.shared.data(from: url)
|
||||||
|
if (response as? HTTPURLResponse)?.statusCode == 404 {
|
||||||
|
lastError = "Sync group no longer exists on the server"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||||
|
syncLog("poll HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data)
|
||||||
|
syncLog("poll remote v\(remote.version) local v\(lastVersion)")
|
||||||
|
if remote.version > lastVersion, let payload = remote.data {
|
||||||
|
apply(payload, version: remote.version)
|
||||||
|
}
|
||||||
|
lastError = nil
|
||||||
|
} catch {
|
||||||
|
syncLog("poll failed: \(error.localizedDescription)")
|
||||||
|
// Polling errors are transient; stay quiet unless nothing ever worked.
|
||||||
|
if lastSyncedAt == nil { lastError = "Cannot reach sync server" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartPolling() {
|
||||||
|
pollTimer?.invalidate()
|
||||||
|
pollTimer = nil
|
||||||
|
guard code != nil else { return }
|
||||||
|
pollTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in self?.poll() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Payload mapping
|
||||||
|
|
||||||
|
private func buildPayload() -> SyncPayload {
|
||||||
|
let store = Store.shared
|
||||||
|
var images: [String: String] = [:]
|
||||||
|
for item in store.sections.flatMap(\.items) {
|
||||||
|
if case .image(let filename) = item.content,
|
||||||
|
let data = store.imageData(for: filename) {
|
||||||
|
images[filename] = data.base64EncodedString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SyncPayload(sections: store.sections, activeSectionID: store.activeSectionID, images: images)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(_ payload: SyncPayload, version: Int64) {
|
||||||
|
applyingRemote = true
|
||||||
|
defer { applyingRemote = false }
|
||||||
|
let store = Store.shared
|
||||||
|
for (filename, base64) in payload.images {
|
||||||
|
if let data = Data(base64Encoded: base64) {
|
||||||
|
store.importImage(filename: filename, data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.cleanupOrphanedImages(referenced: Set(payload.images.keys))
|
||||||
|
store.replaceAll(sections: payload.sections, activeSectionID: payload.activeSectionID)
|
||||||
|
lastVersion = version
|
||||||
|
lastSyncedPayload = encode(payload)
|
||||||
|
lastSyncedAt = Date()
|
||||||
|
syncLog("applied remote v\(version): \(payload.sections.count) sections, \(payload.images.count) images")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func run(_ operation: @escaping @MainActor () async throws -> Void) {
|
||||||
|
isSyncing = true
|
||||||
|
lastError = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await operation()
|
||||||
|
} catch {
|
||||||
|
syncLog("operation failed: \(error.localizedDescription)")
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
isSyncing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endpoint(_ path: String) -> URL? {
|
||||||
|
var base = serverURL.trimmingCharacters(in: .whitespaces)
|
||||||
|
while base.hasSuffix("/") { base.removeLast() }
|
||||||
|
guard base.hasPrefix("http://") || base.hasPrefix("https://") else { return nil }
|
||||||
|
return URL(string: base + path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncLog(_ message: String) {
|
||||||
|
NSLog("Prompt sync: %@", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable encoding used for change detection: sorted keys keep dictionary
|
||||||
|
/// ordering deterministic so identical payloads compare equal.
|
||||||
|
private func encode(_ payload: SyncPayload) -> Data? {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.sortedKeys]
|
||||||
|
return try? encoder.encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func normalizeCode(_ raw: String) -> String? {
|
||||||
|
let digits = raw.filter(\.isNumber)
|
||||||
|
guard digits.count == 8 else { return nil }
|
||||||
|
let index = digits.index(digits.startIndex, offsetBy: 4)
|
||||||
|
return "\(digits[..<index])-\(digits[index...])"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requireOK(_ response: URLResponse) throws {
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||||
|
throw SyncError.server(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum SyncError: LocalizedError {
|
||||||
|
case unknownCode
|
||||||
|
case badResponse
|
||||||
|
case server(statusCode: Int)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unknownCode: return "Unknown code — generate one first on the other Mac"
|
||||||
|
case .badResponse: return "Unexpected response from sync server"
|
||||||
|
case .server(let code): return "Sync server returned HTTP \(code)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,14 @@ cat > "$APP/Contents/Info.plist" <<PLIST
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>NSApplication</string>
|
<string>NSApplication</string>
|
||||||
|
<!-- The sync server URL is user-supplied and may be plain HTTP (a LAN
|
||||||
|
address or a self-hosted box), which App Transport Security would
|
||||||
|
otherwise block. -->
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
|
|||||||
Reference in New Issue
Block a user