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.
7.6 KiB
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:
- 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).
- 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).scrotis 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 askeep. - Not
flit. flit is synchronous peer-to-peer (both devices online at once, WebRTC, zero server storage).scrotis 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 aclipblob.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 forclip, image bytes forshot),X-Blob-Kind: clip|shotheader selects the table row shape. RequiresX-Device-Token. Returns{ id, retrieveAt }— forshot,retrieveAtis the public/s/:idURL; forclip, it's null (nothing public to hand back).GET /api/clip/latest— requiresX-Device-Token. Returns the most recent non-expiredclipblob's content plus itsid/created_at, or204if 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 theshotblob's bytes with the rightContent-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 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/xclipdepending on session type),curl -X POSTwithX-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, ormaim -son X11 — detect via$XDG_SESSION_TYPE), pipes the PNG straight tocurl -X POSTwithX-Blob-Kind: shot, and copies the returned/s/:idURL 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
clipneed 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
shotsupport 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.