Files

154 lines
7.6 KiB
Markdown
Raw Permalink Normal View History

# 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 `<server>`, 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.