realtimeclipboard 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Akshay Nikhare
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,243 @@
1
+ # RealtimeClipboard — an end-to-end encrypted online clipboard that syncs text between devices
2
+
3
+ RealtimeClipboard is a free, open-source online clipboard: open it on two devices, type the
4
+ same five-character key, and whatever you copy on one is ready to paste on the
5
+ other. No account, no install, no database. Files travel peer-to-peer and never
6
+ touch the server.
7
+
8
+ **[Try it → realtimeclipboard.com](https://realtimeclipboard.com/)**
9
+
10
+ ```
11
+ Machine A ──┐ ┌── Machine B
12
+ │ text ──► relay ──► text │
13
+ │ (encrypted in the browser) │
14
+ │ │
15
+ └─── files ═══ direct P2P ═══─────┘
16
+ (never touch the server)
17
+ ```
18
+
19
+ **Status: pre-alpha, and it works end to end.** The relay is deployed and live,
20
+ the frontend is wired to it over a WebSocket with an SSE fallback, and the
21
+ 17-check end-to-end suite runs two real peers with real crypto against the
22
+ production relay. See [docs/M0-RESULTS.md](docs/M0-RESULTS.md) for exactly what
23
+ is proven, and [Known limitations](#known-limitations) for what is not.
24
+
25
+ ---
26
+
27
+ ## What it does
28
+
29
+ - **Sync clipboard text between devices** — Windows, macOS, Android, ChromeOS and Linux
30
+ - **Works across different networks**, not just the same Wi-Fi, and not just the same LAN
31
+ - **No account, no sign-up, no email** — a five-character key is the whole identity of a session
32
+ - **End-to-end encrypted** in the browser with AES-GCM; `PBKDF2` derives the key, `SHA-256` routes the room
33
+ - **Peer-to-peer file transfer** over a WebRTC data channel, 5 MB per file
34
+ - **Copy and paste images** — a screenshot copied on one machine previews on the other
35
+ - **Installable progressive web app** — own window, own icon, works offline
36
+ - **Nothing written to disk**, on your machine or the server
37
+ - **Self-hostable relay** — it is one small FastAPI service
38
+
39
+ ## Why
40
+
41
+ Moving a snippet between a work laptop, a desktop and a phone is
42
+ disproportionately annoying. The alternatives want an account, an install with
43
+ admin rights, or an email to yourself. This wants a five-character key.
44
+
45
+ ## How RealtimeClipboard compares to Snapdrop, PairDrop, LocalSend and AirDrop
46
+
47
+ The nearby tools are mostly *file droppers*: you pick a device and push a file at
48
+ it. RealtimeClipboard is a clipboard — what you copy shows up ready to paste, without
49
+ picking anything.
50
+
51
+ | Tool | Account | Install | Across networks | Lands on system clipboard | Files |
52
+ |---|---|---|---|---|---|
53
+ | **RealtimeClipboard** | None | None — browser | Yes | **Yes** | P2P, 5 MB |
54
+ | PairDrop | None | None — browser | Yes, via a 6-digit code | No — you send a message | P2P |
55
+ | Snapdrop | Optional since the LimeWire acquisition | None — browser | Same network only | No | P2P |
56
+ | LocalSend | None | Native app on both ends | Same network only | No | Unlimited, LAN |
57
+ | AirDrop | None | Built in | Nearby devices only | No | Unlimited |
58
+ | KDE Connect | None | App on both ends | Same network only | Yes | Yes |
59
+ | Pushbullet | Required | App or extension | Yes | Paid tier | Paid above a small cap |
60
+
61
+ Checked against each project's own documentation in August 2026. Corrections
62
+ welcome — open an issue.
63
+
64
+ ## How it works
65
+
66
+ | Layer | Choice |
67
+ |---|---|
68
+ | Frontend | Static HTML/JS on Cloudflare Pages, installable as a Chrome PWA |
69
+ | Relay | FastAPI on FastAPI Cloud — in-memory rooms, no database, no disk |
70
+ | Text | WebSocket through the relay, AES-GCM encrypted in the browser |
71
+ | Blocked networks | If a proxy eats the WebSocket, the client moves itself to SSE + POST on the same host and says so |
72
+ | Files | WebRTC data channel, direct between peers, 5 MB cap |
73
+ | Key | `SHA-256(key)` routes the room; `PBKDF2(key)` encrypts. The key itself is never transmitted |
74
+
75
+ The relay only ever sees a room hash and ciphertext. It cannot decrypt anything,
76
+ and it stores nothing beyond the last message in RAM.
77
+
78
+ ## Frequently asked questions
79
+
80
+ ### What is an online clipboard?
81
+
82
+ An online clipboard is a web page that holds what you copy on one device so you
83
+ can paste it on another. Both devices open the same page, identify themselves
84
+ with a short key, and share a single clipboard between them.
85
+
86
+ ### How do I sync my clipboard between my phone and my PC?
87
+
88
+ Open RealtimeClipboard on both, type the same five-character key on each, and copy
89
+ something. It arrives on the other device ready to paste. Nothing to install, so
90
+ it works on a machine where you do not have admin rights.
91
+
92
+ ### Does it work if the two devices are on different networks?
93
+
94
+ Yes. Text goes through a relay, so a laptop on home Wi-Fi and a phone on mobile
95
+ data share a clipboard fine. This is the main difference from Snapdrop, LocalSend
96
+ and AirDrop, which need both devices on the same network. Files are the
97
+ exception — they go directly between machines, and that is the part corporate
98
+ firewalls sometimes block.
99
+
100
+ ### Is an online clipboard safe?
101
+
102
+ It depends on whether the server can read what you copy, and here it cannot. Text
103
+ is encrypted in the browser before it is sent; the server sees a room hash and
104
+ ciphertext and keeps neither. The honest caveat is that the key is a bearer
105
+ credential — anyone who learns it can read that session while it is open.
106
+
107
+ ### Can it read my clipboard in the background?
108
+
109
+ No, and neither can any other web app on any browser. `readText()` requires
110
+ window focus. You switch to the RealtimeClipboard tab and it picks up what you copied.
111
+
112
+ ### Which browsers work?
113
+
114
+ Chromium browsers get the full experience. Firefox and Safari can receive
115
+ everything and can send anything you paste in by hand, but cannot read the
116
+ clipboard on their own.
117
+
118
+ ## Repo layout
119
+
120
+ ```
121
+ index.html marketing landing page (indexable)
122
+ app.html the app itself (noindex) — served at /app; the .html is
123
+ rewritten out by tools/build/build.mjs, in the deploy only
124
+ src/
125
+ main.js composition root — the only file that crosses layers
126
+ core/ bus, config, state, crypto, keys, storage, paths — no DOM
127
+ transport/ relay facade + interchangeable WebSocket and SSE channels
128
+ clipboard/ OS clipboard read/write, capture tiers
129
+ files/ thumbnails, registry, chunking, P2P transfer
130
+ ui/ primitives → shell + features → panels, in that order
131
+ styles/ design tokens + per-component CSS; lazy/ is fetched on demand
132
+ landing/ behaviour for index.html — a separate document from the app
133
+ pages/ help/ blog/ download/ — copied to the site root, not bundled
134
+ cli/ the same crypto and protocol, on the command line
135
+ backend/ FastAPI relay — deployed separately, shares no code
136
+ desktop/ Tauri shell around this very src/ — no second implementation
137
+ assets/ icons/ (precached whole) + social/ (the OG card, never)
138
+ tests/ unit/ needs nothing · dom/ needs jsdom · live/ needs a relay
139
+ tools/ build/ · check/ · release/ · seo/
140
+ docs/ PRD, architecture, clipboard design, P2P design, SEO
141
+ ```
142
+
143
+ A directory **at the root** is served at its own path — `assets/icons/icon.svg` is
144
+ `/assets/icons/icon.svg`. `src/` is the opposite: inputs that get bundled, plus `src/pages/`, which
145
+ is lifted to the site root at build time. `app.html` and `index.html` stay at the root so the app
146
+ keeps a dev loop where the disk path and the URL are the same string.
147
+
148
+ **Every directory carries its own `CLAUDE.md` and `README.md`** — the rules that
149
+ govern a change live next to the code they govern, and a static check fails if a
150
+ directory has neither. Start at [src/CLAUDE.md](src/CLAUDE.md) for the import
151
+ rules.
152
+
153
+ Details and conventions: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
154
+
155
+ ## Run it
156
+
157
+ **Frontend** — any static server (ES modules need HTTP, not `file://`):
158
+ ```bash
159
+ python -m http.server 8080
160
+ # http://127.0.0.1:8080
161
+ ```
162
+
163
+ **Relay:**
164
+ ```bash
165
+ cd backend
166
+ pip install -r requirements.txt
167
+ python -m uvicorn main:app --port 8000
168
+ python test_relay.py ws://127.0.0.1:8000 # 51-check protocol gate
169
+ python test_sse.py http://127.0.0.1:8000 # 33-check fallback gate
170
+ ```
171
+
172
+ The relay must be on **port 8000**: `src/core/config.js` points the app there
173
+ automatically when the page is served from localhost, so nothing needs
174
+ configuring — but nothing else will be found either. Open `app.html#DEVKEY` in
175
+ two windows to watch a clip cross between them.
176
+
177
+ `app.html` here, not `/app`: the clean URL is a deploy-time rewrite in
178
+ `tools/build/build.mjs`, and `python -m http.server` does not strip extensions. The
179
+ same is true of the desktop app, which ships this tree as it stands.
180
+
181
+ With the relay running, `node tests/live/fallback.mjs ws://127.0.0.1:8000` exercises
182
+ the client's WebSocket → SSE failover with WebSockets simulated as blocked.
183
+
184
+ Full setup, the service-worker cache trap, and how to check a UI change:
185
+ [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). For deployment see
186
+ [backend/README.md](backend/README.md), **including the replica-pinning step
187
+ that must not be skipped.**
188
+
189
+ ## Contributing and releasing
190
+
191
+ ```bash
192
+ npm install # sets up the git hooks
193
+ git switch -c fix/whatever # main refuses direct commits
194
+ git commit -m "fix(scope): what changed"
195
+ ```
196
+
197
+ [CONTRIBUTING.md](CONTRIBUTING.md) has the full loop, the commit convention, and
198
+ the boundaries the static checks enforce. Security problems go through
199
+ [SECURITY.md](SECURITY.md), not the issue tracker.
200
+
201
+ The tests run **locally, in a git hook, before the commit exists** — there is no
202
+ CI. GitHub's only workflow copies files to Pages, and it runs on a version tag
203
+ and nothing else. The reasoning, the trade that comes with it, and the commit
204
+ message format the changelog is generated from are in
205
+ [docs/RELEASING.md](docs/RELEASING.md).
206
+
207
+ ```bash
208
+ npm run verify # what the pre-commit hook runs
209
+ npm test # everything, needs a relay
210
+ npm run release -- minor # verify, changelog, tag, push, deploy
211
+ ```
212
+
213
+ ## Docs
214
+
215
+ | Doc | What it covers |
216
+ |---|---|
217
+ | [PRD.md](docs/PRD.md) | Requirements, architecture, security model, open issues |
218
+ | [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Running it locally, the test suite, the landing-page grid and globe, and the traps |
219
+ | [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Module layout, boundaries, and how to add a feature |
220
+ | [RELEASING.md](docs/RELEASING.md) | Hooks instead of CI, tag-triggered deploys, and the generated changelog |
221
+ | [CHANGELOG.md](CHANGELOG.md) | What shipped in each release — generated from the commits |
222
+ | [CLIPBOARD-FLOW.md](docs/CLIPBOARD-FLOW.md) | How the browser reaches the OS clipboard, and why background capture is impossible |
223
+ | [P2P-FILES.md](docs/P2P-FILES.md) | Thumbnails over the relay, bytes over WebRTC, and the corporate-network problem |
224
+ | [M0-RESULTS.md](docs/M0-RESULTS.md) | Transport gate results |
225
+ | [SEO.md](docs/SEO.md) | Search, answer-engine and distribution strategy |
226
+ | [CONTRIBUTING.md](CONTRIBUTING.md) | Setup, the commit convention, and the boundaries the checks enforce |
227
+ | [SECURITY.md](SECURITY.md) | Reporting a vulnerability privately, and what is in scope |
228
+ | [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Contributor Covenant 2.1 |
229
+
230
+ ## Known limitations
231
+
232
+ - **No background clipboard capture.** No web app can do this on any browser —
233
+ `readText()` requires window focus. You switch to RealtimeClipboard and it grabs what you
234
+ copied. [Why](docs/CLIPBOARD-FLOW.md).
235
+ - **P2P file transfer may fail on corporate networks**, which block the UDP that
236
+ WebRTC needs. Falls back to relay-chunked transfer, labelled visibly.
237
+ - **The share key is a bearer credential.** Anyone holding it can read the session.
238
+ - Chromium-first. Firefox and Safari can receive and can send via paste, but
239
+ cannot silently read the clipboard.
240
+
241
+ ## Licence
242
+
243
+ [MIT](LICENSE).
package/cli/CLAUDE.md ADDED
@@ -0,0 +1,28 @@
1
+ # cli/
2
+
3
+ One file, and how little of it there is *is* the design.
4
+
5
+ It imports the same modules the browser runs — `core/crypto.js`, `core/keys.js`,
6
+ `transport/relay.js` — because none of them ever touched `window` or `document`, and Node has had
7
+ `WebSocket`, `fetch` and `crypto.subtle` as globals since 22. The heartbeat, the jittered reconnect
8
+ backoff, the WebSocket→SSE failover and the encryption all come for free and cannot drift, because
9
+ there is nothing here to drift.
10
+
11
+ ## The one rule
12
+
13
+ **Nothing in this file may reimplement a protocol detail.** If something is missing, it belongs in
14
+ `src/` where both ends get it. A second implementation of the crypto is exactly how the two ends
15
+ end up subtly and silently disagreeing.
16
+
17
+ ## Rules
18
+
19
+ - **`import.meta.url` is allowed here and banned in `src/`.** This file is published as itself and
20
+ never goes through the bundler, so its depth in the tree is fixed. That is the whole exemption —
21
+ it does not extend to anything it imports.
22
+ - **Made for pipes.** Clip content goes to stdout and nothing else does; diagnostics go to stderr;
23
+ the exit code is what a script branches on.
24
+ - **The version is read from `package.json`**, never written here. It was a literal once and was
25
+ already wrong — the package said 0.2.1 while `--version` said 0.1.0.
26
+ - `package.json` `files:` ships `cli/`, `src/core/` and `src/transport/`. Importing anything outside
27
+ those three from here breaks the published package, and only after publish.
28
+ - `tests/live/cli.mjs` runs this against a real relay, and `prepublishOnly` runs that suite.
package/cli/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # cli/
2
+
3
+ RealtimeClipboard on the command line — the same crypto and the same protocol as the browser, because
4
+ it imports the same modules.
5
+
6
+ ```bash
7
+ npx realtimeclipboard new # print a fresh key
8
+ npx realtimeclipboard <KEY> # two-way: prints what arrives, sends what you type
9
+ npx realtimeclipboard watch <KEY> # print incoming clips and nothing else
10
+ npx realtimeclipboard send <KEY> # read stdin to EOF, send it, exit
11
+ ```
12
+
13
+ Built for pipes — clip content is the only thing on stdout, and the exit code is what a script
14
+ should branch on:
15
+
16
+ ```bash
17
+ tail -f app.log | npx realtimeclipboard send WORK5
18
+ npx realtimeclipboard watch WORK5 > incoming.txt
19
+ ```
20
+
21
+ Needs Node 22+, for the global `WebSocket`, `fetch` and `crypto.subtle` that let it share the
22
+ browser's transport and crypto unchanged.
23
+
24
+ Rules that govern edits here: [CLAUDE.md](CLAUDE.md).
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * RealtimeClipboard on the command line.
4
+ *
5
+ * The interesting thing about this file is how little of it there is. It
6
+ * imports the SAME modules the browser runs — core/crypto.js, core/keys.js,
7
+ * transport/relay.js — because none of them ever touched `window` or
8
+ * `document`, and Node has had `WebSocket`, `fetch` and `crypto.subtle` as
9
+ * globals since 22. So the CLI gets the heartbeat, the jittered reconnect
10
+ * backoff, the WebSocket→SSE failover and the encryption for free, and cannot
11
+ * drift from the browser's behaviour, because there is nothing here to drift.
12
+ *
13
+ * That is also the constraint: nothing in this file may reimplement a protocol
14
+ * detail. If something is missing, it belongs in src/ where both ends get it.
15
+ *
16
+ * realtimeclipboard <KEY> two-way: prints what arrives, sends what you type
17
+ * realtimeclipboard watch <KEY> prints incoming clips and nothing else
18
+ * realtimeclipboard send <KEY> reads stdin to EOF, sends it, exits
19
+ * realtimeclipboard new prints a fresh key
20
+ *
21
+ * Made for pipes, so the rules are the usual ones: clip content goes to stdout
22
+ * and nothing else does, and the exit code is what a script should branch on.
23
+ */
24
+
25
+ import { createInterface } from "node:readline";
26
+ import { stdin, stdout, stderr, argv, exit, env } from "node:process";
27
+ import { hostname } from "node:os";
28
+ import { readFileSync } from "node:fs";
29
+
30
+ import * as keys from "../src/core/keys.js";
31
+ import * as cryptoBox from "../src/core/crypto.js";
32
+ import * as relay from "../src/transport/relay.js";
33
+ import * as proto from "../src/transport/protocol.js";
34
+ import { on, EV } from "../src/core/bus.js";
35
+ import { DEFAULT_RELAY_URL, normaliseRelay, TEXT, LOCK } from "../src/core/config.js";
36
+
37
+ /**
38
+ * Read from package.json rather than written here.
39
+ *
40
+ * It was a literal, and it was already wrong: the package said 0.2.1 while
41
+ * `--version` said 0.1.0. That is the failure this codebase avoids everywhere
42
+ * else by deriving — RELAY_HTTP_URL from RELAY_URL, LINKS from REPO, the
43
+ * service worker's precache list from disk — and a version number is the worst
44
+ * place to have it, because the whole point of the number is telling someone
45
+ * which code they are running when they report a bug.
46
+ *
47
+ * import.meta.url is correct HERE and banned in src/: this file is published as
48
+ * itself and never goes through the bundler, so its depth in the tree is fixed.
49
+ * npm always includes package.json in a tarball, so this resolves after install.
50
+ */
51
+ const VERSION = JSON.parse(
52
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
53
+ ).version;
54
+
55
+ /* ------------------------------------------------------------------ args -- */
56
+
57
+ function parse(args) {
58
+ const opts = { relay: null, pin: null, once: false, json: false, timeout: 0, quiet: false };
59
+ const rest = [];
60
+ for (let i = 0; i < args.length; i++) {
61
+ const a = args[i];
62
+ const val = () => args[++i];
63
+ if (a === "--relay") opts.relay = val();
64
+ else if (a === "--pin") opts.pin = val();
65
+ else if (a === "--timeout") opts.timeout = Number(val()) * 1000;
66
+ else if (a === "--once") opts.once = true;
67
+ else if (a === "--long") opts.long = true;
68
+ else if (a === "--json") opts.json = true;
69
+ else if (a === "-q" || a === "--quiet") opts.quiet = true;
70
+ else if (a === "-h" || a === "--help") opts.help = true;
71
+ else if (a === "-v" || a === "--version") opts.version = true;
72
+ else if (a.startsWith("-")) die(`unknown option ${a}`, 2);
73
+ else rest.push(a);
74
+ }
75
+ return { opts, rest };
76
+ }
77
+
78
+ const USAGE = `realtimeclipboard ${VERSION} — an online clipboard, on the command line
79
+
80
+ realtimeclipboard <KEY> two-way; prints what arrives, sends what you type
81
+ realtimeclipboard watch <KEY> print incoming clips
82
+ realtimeclipboard send <KEY> read stdin to EOF and send it
83
+ realtimeclipboard new [--long] print a fresh key
84
+
85
+ Options
86
+ --relay <url> relay to use (default ${DEFAULT_RELAY_URL})
87
+ or set REALTIMECLIPBOARD_RELAY
88
+ --pin <pin> join a locked session (or set REALTIMECLIPBOARD_PIN)
89
+ --once watch: exit after the first clip
90
+ --json one JSON object per line instead of raw text
91
+ --timeout <s> give up after this many seconds
92
+ -q, --quiet no status on stderr
93
+ -h, --help this
94
+ -v, --version version
95
+
96
+ Examples
97
+ echo "deploy key" | realtimeclipboard send D75LV
98
+ realtimeclipboard watch D75LV > clip.txt
99
+ realtimeclipboard watch D75LV --once --json | jq -r .text
100
+ ssh box 'cat /etc/hosts' | realtimeclipboard send D75LV
101
+
102
+ The key is a bearer credential: anyone holding it can read the session while it
103
+ is open. Clip contents are encrypted here, before they are sent; the relay only
104
+ ever sees a room hash and ciphertext.`;
105
+
106
+ /* ---------------------------------------------------------------- output -- */
107
+
108
+ const note = (msg, opts) => { if (!opts.quiet) stderr.write(`${msg}\n`); };
109
+
110
+ function die(msg, code = 1) {
111
+ stderr.write(`realtimeclipboard: ${msg}\n`);
112
+ exit(code);
113
+ }
114
+
115
+ /* --------------------------------------------------------------- session -- */
116
+
117
+ /**
118
+ * Everything needed to talk in a room, derived exactly as the browser derives
119
+ * it — same salt, same iteration count, same HKDF info strings, because it is
120
+ * the same function.
121
+ */
122
+ async function derive(rawKey, pin) {
123
+ const key = keys.normalise(rawKey);
124
+ if (!keys.isValid(key)) die(`"${rawKey}" is not a valid key`, 2);
125
+
126
+ if (pin) {
127
+ const clean = cryptoBox.normalisePin(pin);
128
+ if (!clean) die("that PIN is too short", 2);
129
+ const d = await cryptoBox.deriveLocked(key, clean);
130
+ return { key, roomHash: d.roomHash, aesKey: d.aesKey, auth: d.authToken, locked: true };
131
+ }
132
+ return {
133
+ key,
134
+ roomHash: await cryptoBox.roomHash(key),
135
+ aesKey: await cryptoBox.deriveKey(key),
136
+ auth: null,
137
+ locked: false,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Connect and resolve once the relay has welcomed us.
143
+ *
144
+ * relay.js announces state on the bus rather than returning a promise, because
145
+ * in the browser the connection outlives any one call. Here there is a script
146
+ * waiting, so the bus event is adapted back into a promise — and a timeout,
147
+ * since a pipe that hangs forever is worse than one that fails.
148
+ */
149
+ function connect(session, opts, onClip) {
150
+ const url = opts.relay ?? env.REALTIMECLIPBOARD_RELAY ?? undefined;
151
+ if (opts.relay && !normaliseRelay(opts.relay)) die(`"${opts.relay}" is not a usable relay address`, 2);
152
+
153
+ return new Promise((resolve, reject) => {
154
+ // Connecting has its own bound, separate from --timeout below: "the relay
155
+ // never answered" and "nothing was ever sent to this room" are different
156
+ // failures, and a script that gets one when it expected the other has been
157
+ // told a lie about its own network.
158
+ const timer = setTimeout(
159
+ () => reject(new Error("the relay did not answer")),
160
+ Math.max(opts.timeout || 0, 20_000),
161
+ );
162
+
163
+ on(EV.CONN_STATE, ({ state, detail }) => {
164
+ if (state === "connected") { clearTimeout(timer); resolve(); }
165
+ if (state === "error" && detail) note(` ${detail}`, opts);
166
+ });
167
+
168
+ relay.setFrameHandler(async msg => {
169
+ if (msg.t !== proto.T.CLIP || !msg.payload) return;
170
+ try {
171
+ const text = await cryptoBox.decrypt(session.aesKey, msg.payload, msg.iv);
172
+ // The lock beacon is a control frame wearing a clip's clothes: it is
173
+ // what lets a joiner tell "wrong PIN" from "first one here". Receivers
174
+ // drop it rather than render it, and a pipe must not see it either.
175
+ // Compared against the constant, not a guess at its shape: it starts
176
+ // with NUL, and "looks like a control character" would also
177
+ // swallow a legitimate clip that happened to.
178
+ if (text === LOCK.BEACON) return;
179
+ onClip(text, msg);
180
+ } catch {
181
+ // Undecryptable means a different secret, not a corrupt relay: someone
182
+ // in the room with another PIN, or a stale frame from a rotated key.
183
+ note(" (a clip arrived that this key cannot read)", opts);
184
+ }
185
+ });
186
+
187
+ relay.connect({
188
+ roomHash: session.roomHash,
189
+ intent: "join",
190
+ name: `cli@${hostname()}`,
191
+ auth: session.auth,
192
+ ...(url ? { url: normaliseRelay(url) } : {}),
193
+ });
194
+ });
195
+ }
196
+
197
+ async function sendText(session, text, opts) {
198
+ if (!text) die("nothing on stdin to send", 2);
199
+ if (text.length > TEXT.MAX_CHARS) {
200
+ die(`that is ${text.length} characters; the limit is ${TEXT.MAX_CHARS}`, 2);
201
+ }
202
+ const { payload, iv } = await cryptoBox.encrypt(session.aesKey, text);
203
+ relay.send(proto.clip({ payload, iv, originId: `cli-${Date.now().toString(36)}` }));
204
+ // The frame is handed to a socket, not delivered. Give it a moment to flush
205
+ // before the process exits out from under it.
206
+ await new Promise(r => setTimeout(r, 250));
207
+ }
208
+
209
+ const readStdin = () => new Promise(resolve => {
210
+ let buf = "";
211
+ stdin.setEncoding("utf8");
212
+ stdin.on("data", d => { buf += d; });
213
+ stdin.on("end", () => resolve(buf));
214
+ });
215
+
216
+ /**
217
+ * `seq` and `originId` are the only provenance there is, and that is on purpose:
218
+ * the relay rebuilds a clip envelope from scratch (backend/main.py, "clip") and
219
+ * carries neither a timestamp nor a sender identity on it. `at` is therefore
220
+ * when THIS process received it, and is labelled as such rather than implying a
221
+ * send time nobody recorded.
222
+ */
223
+ const emitClip = (text, msg, opts) => stdout.write(
224
+ opts.json
225
+ ? `${JSON.stringify({ text, seq: msg.seq ?? null, origin: msg.originId ?? null, receivedAt: new Date().toISOString() })}\n`
226
+ : (text.endsWith("\n") ? text : `${text}\n`),
227
+ );
228
+
229
+ /* ------------------------------------------------------------------ main -- */
230
+
231
+ const { opts, rest } = parse(argv.slice(2));
232
+ if (opts.help) { stdout.write(`${USAGE}\n`); exit(0); }
233
+ if (opts.version) { stdout.write(`${VERSION}\n`); exit(0); }
234
+
235
+ opts.pin ??= env.REALTIMECLIPBOARD_PIN ?? null;
236
+
237
+ let [cmd, keyArg] = rest;
238
+ if (cmd === "new") {
239
+ stdout.write(`${keys.generate(opts.long ? keys.LENGTHS.LONG : keys.LENGTHS.NORMAL)}\n`);
240
+ exit(0);
241
+ }
242
+ // `realtimeclipboard D75LV` — no verb, so the first word is the key and the mode is both.
243
+ if (cmd && !["send", "watch"].includes(cmd)) { keyArg = cmd; cmd = "both"; }
244
+ if (!cmd) { stdout.write(`${USAGE}\n`); exit(2); }
245
+ if (!keyArg) die(`${cmd} needs a key — try: realtimeclipboard ${cmd} D75LV`, 2);
246
+
247
+ if (typeof WebSocket === "undefined") {
248
+ die("this needs Node 22 or newer (for the built-in WebSocket)", 3);
249
+ }
250
+
251
+ const session = await derive(keyArg, opts.pin);
252
+ note(`realtimeclipboard · room ${session.roomHash.slice(0, 8)}…${session.locked ? " · locked" : ""}`, opts);
253
+
254
+ /**
255
+ * --timeout bounds the WHOLE run, not just the connection.
256
+ *
257
+ * The distinction matters and was got wrong first time. `watch --once` on a
258
+ * room nobody sends to connects perfectly happily and then waits forever — and
259
+ * with a locked room that is not even an error, because the wrong PIN names a
260
+ * different room, which legitimately exists and is legitimately empty. A
261
+ * connect-only timeout leaves every one of those cases hanging a pipeline.
262
+ */
263
+ if (opts.timeout) {
264
+ setTimeout(() => {
265
+ relay.close();
266
+ note(` timed out after ${opts.timeout / 1000}s`, opts);
267
+ exit(5);
268
+ }, opts.timeout).unref?.();
269
+ }
270
+
271
+ let got = 0;
272
+ try {
273
+ await connect(session, opts, (text, msg) => {
274
+ got++;
275
+ emitClip(text, msg, opts);
276
+ if (cmd === "watch" && opts.once) { relay.close(); exit(0); }
277
+ });
278
+ } catch (err) {
279
+ die(err.message, 4);
280
+ }
281
+ note(" connected", opts);
282
+
283
+ if (cmd === "send") {
284
+ await sendText(session, await readStdin(), opts);
285
+ relay.close();
286
+ exit(0);
287
+ }
288
+
289
+ if (cmd === "both") {
290
+ // Not readStdin(): that waits for EOF, and this mode has to send each line as
291
+ // it is typed while still printing what arrives in between.
292
+ const rl = createInterface({ input: stdin, terminal: false });
293
+ rl.on("line", line => { if (line.length) sendText(session, line, opts).catch(() => {}); });
294
+ rl.on("close", () => { relay.close(); exit(0); });
295
+ }
296
+
297
+ for (const sig of ["SIGINT", "SIGTERM"]) {
298
+ process.on(sig, () => {
299
+ relay.close();
300
+ note(` ${got} clip${got === 1 ? "" : "s"} received`, opts);
301
+ exit(0);
302
+ });
303
+ }