Files
scrot/PROPOSAL.md
T
Fredrik Johansson 4eee44f675 Initial scaffold: blob store server, CLI scripts, and PWA share target
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.
2026-08-20 15:28:42 +02:00

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:

  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 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.