Clipboard/screenshot sync across own devices via a single DEVICE_TOKEN-gated blob store, plus a small installable web app so a phone can push/pull clips and share screenshots via the OS share sheet.
154 lines
7.6 KiB
Markdown
154 lines
7.6 KiB
Markdown
# 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.
|