# Proposal: `scrot` — clipboard sync + screenshot sharing, one blob store **Status:** proposed, being scaffolded. Self-contained — written so a fresh agent with no prior conversation context can pick this up and implement it without anything explained first. ## Motivation Two small personal itches, both solved by the same mechanism: 1. **Clipboard/note sync across your own devices** — copy something on one machine, paste it on another, without a cloud clipboard manager whose sync feature might get paywalled or discontinued (Authy killed its desktop app; plenty of "free" sync tools have gone the same way). 2. **Screenshot → instant public link** — hit a hotkey, get a shareable URL on the clipboard, without depending on Imgur/CloudApp/ShareX's own uploader service. Both are "send a blob to ``, get it back later" — one privately (to your own other devices), one publicly (a link you hand to someone else). Building two separate services would just be `wisp` and `flit` again with extra steps. `scrot` is one blob store with a `visibility` flag, because the underlying problem is identical and only the retrieval path differs. ## What it isn't - **Not `wisp`.** wisp is single-retrieval, end-to-end encrypted, multi-user (anyone with the link decrypts). `scrot` is single-user (you and only you, across your own devices), multi-retrieval for clipboard items (poll for "is there something newer than what I have"), and does not need E2E encryption — the server is already fully trusted homelab infrastructure, same trust level as `keep`. - **Not `flit`.** flit is synchronous peer-to-peer (both devices online at once, WebRTC, zero server storage). `scrot` is deliberately the opposite: async, server-stored, no P2P handshake — you copy on your phone at lunch and it's there on your laptop that evening. - **Not a general file sync tool.** No folder watching, no bidirectional sync, no conflict resolution. One blob at a time, pushed explicitly, read explicitly. If this needs Syncthing-shaped features later, that's a sign it's the wrong tool for the job, not a sign to add them here. ## Core model Single `blobs` table. Every row has a `kind` (`clip` | `shot`) and a `visibility` implied by kind: - **`clip`** — text or small file, private. Pull-only: any of your authenticated devices can ask "what's the latest clip?" and get it back. No public URL is ever minted for a `clip` blob. - **`shot`** — image, public. Upload returns a short unguessable link (`/s/:id`) that renders/downloads the image, no auth required to view — same trust model as handing someone a link to a photo, not a secret. TTL-based cleanup for both (default: clips expire fast, a day or so — they're meant to be grabbed promptly, not archived; shots live longer, long enough to actually be shared and viewed — a week or two). This reuses `wisp`'s sweep-on-interval `cleanup.ts` pattern outright, just with per-kind TTLs instead of one global one. **No E2E encryption.** Unlike wisp (built for sharing with other people, where the server operator shouldn't be able to read the payload), scrot's server is single-user infrastructure you already trust with everything else (`keep`, `trace`). Adding client-side crypto here would be defense against a threat model (yourself, or your own trusted host) that doesn't apply — skip it, keep the client thin. ## Auth Single shared `DEVICE_TOKEN` (env var, server refuses to start without it — same posture as wisp's `UPLOAD_PASSWORD`), sent as `X-Device-Token` on every request. Not a per-device registry: this is you, across a handful of machines you personally set up, not a multi-tenant service. If a device needs revoking, rotate the token and re-provision the (few) devices that need it — full device-management UI would be over-engineering for a single-user tool. ## API - `POST /api/blobs` — body is the raw content (text for `clip`, image bytes for `shot`), `X-Blob-Kind: clip|shot` header selects the table row shape. Requires `X-Device-Token`. Returns `{ id, retrieveAt }` — for `shot`, `retrieveAt` is the public `/s/:id` URL; for `clip`, it's null (nothing public to hand back). - `GET /api/clip/latest` — requires `X-Device-Token`. Returns the most recent non-expired `clip` blob's content plus its `id`/`created_at`, or `204` if there isn't one. Polling this (or calling it on-demand from a keybinding) is the whole "pull" side of clipboard sync — no push notification, no websocket, deliberately simple. - `GET /s/:id` — public, no auth. Serves the `shot` blob's bytes with the right `Content-Type`. 404 once expired/swept. That's the entire API surface. No listing endpoint, no delete endpoint (TTL handles cleanup), no rename/metadata — matches the family's existing bias toward the smallest API that solves the actual problem. ## Server implementation Directly reuses `wisp/server`'s shape: Express + `better-sqlite3`, TS, WAL-mode SQLite, `data/` volume holding both the DB and blob bytes on disk (not inline in SQLite — same reasoning as wisp, keeps the DB small and lets large images stream instead of buffering fully into a BLOB column). ``` server/ src/ config.ts // PORT, DATA_DIR, DEVICE_TOKEN, CLIP_TTL_SECONDS, SHOT_TTL_SECONDS db.ts // blobs table, blobPath() ids.ts // reused verbatim from wisp (base62 random ids) auth.ts // checkDeviceToken(req) — single timing-safe comparison routes.ts // POST /blobs, GET /clip/latest public.ts // GET /s/:id (separate from routes.ts: no auth middleware) cleanup.ts // per-kind TTL sweep, same interval-timer pattern as wisp index.ts ``` ## Client side No native app. Two thin shell scripts under `cli/`, each a keybinding away from useless friction, plus a small installable PWA (`server/public/`) for mobile — see [README.md](./README.md#mobile--pwa) for the share-target / onboarding design, added once this looked headed for public use and "SSH in and edit a dotfile" stopped being an acceptable mobile onboarding story: - `cli/scrot-clip-push` — reads the system clipboard (`wl-paste`/`xclip` depending on session type), `curl -X POST` with `X-Blob-Kind: clip`. - `cli/scrot-clip-pull` — `curl GET /api/clip/latest`, writes the result back into the system clipboard (`wl-copy`/`xclip -selection clipboard`). - `cli/scrot-shot` — runs the local screenshot capture tool (`grim -g "$(slurp)"` on Wayland, or `maim -s` on X11 — detect via `$XDG_SESSION_TYPE`), pipes the PNG straight to `curl -X POST` with `X-Blob-Kind: shot`, and copies the returned `/s/:id` URL to the clipboard so the very next action is "paste the link." Each script is `DEVICE_TOKEN`/`SCROT_SERVER` via environment (sourced from a `~/.config/scrot/env` file, gitignored, same as every other project's `.env` convention) — no config file parsing needed beyond that. ## Deploy Directly mirrors `wisp`'s `Dockerfile`/`docker-compose.yml` — single-stage server build (no client/ to build in stage 1, since there's no browser UI at all), named volume for `data/`, env-driven config, later migrated onto `keep` for secret delivery like every other project on the migration list. ## Open questions (deliberately deferred, not blocking scaffolding) - Does `clip` need a history (last N items) instead of just "latest"? Start with latest-only — it's the actual use case (grab what I just copied elsewhere), and multi-item history is a strictly bigger feature to add later if it turns out to matter. - Should `shot` support a "burn after first view" mode like wisp's drops? Deferred — the primary use case is "share a link with someone," which wants repeat viewing, not single-retrieval.