realtimeclipboard 0.5.0 → 0.7.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/README.md CHANGED
@@ -19,8 +19,8 @@ touch the server.
19
19
  **Status: beta, and it works end to end.** The relay is deployed and live,
20
20
  the frontend is wired to it over a WebSocket with an SSE fallback, and the
21
21
  32-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.
22
+ production relay. See [Known limitations](#known-limitations) for what is not
23
+ proven.
24
24
 
25
25
  ---
26
26
 
@@ -223,8 +223,8 @@ npm run release -- minor # verify, changelog, release PR, tag, deplo
223
223
  | [CHANGELOG.md](CHANGELOG.md) | What shipped in each release — generated from the commits |
224
224
  | [CLIPBOARD-FLOW.md](docs/CLIPBOARD-FLOW.md) | How the browser reaches the OS clipboard, and why background capture is impossible |
225
225
  | [P2P-FILES.md](docs/P2P-FILES.md) | Thumbnails over the relay, bytes over WebRTC, and the corporate-network problem |
226
- | [M0-RESULTS.md](docs/M0-RESULTS.md) | Transport gate results |
227
- | [SEO.md](docs/SEO.md) | Search, answer-engine and distribution strategy |
226
+ | [ACCESSIBILITY.md](docs/ACCESSIBILITY.md) | Keyboard paths, focus handling, and what the screen reader announces |
227
+ | [SELF-HOSTING.md](docs/SELF-HOSTING.md) | Running your own relay and pointing a build at it |
228
228
  | [CONTRIBUTING.md](CONTRIBUTING.md) | Setup, the commit convention, and the boundaries the checks enforce |
229
229
  | [SECURITY.md](SECURITY.md) | Reporting a vulnerability privately, and what is in scope |
230
230
  | [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Contributor Covenant 2.1 |
@@ -28,11 +28,9 @@ import { hostname } from "node:os";
28
28
  import { readFileSync } from "node:fs";
29
29
 
30
30
  import * as keys from "../src/core/keys.js";
31
- import * as cryptoBox from "../src/core/crypto.js";
32
31
  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";
32
+ import * as room from "./session.mjs";
33
+ import { DEFAULT_RELAY_URL, normaliseRelay, TEXT } from "../src/core/config.js";
36
34
  import { INVISIBLE_SOURCE } from "../src/core/text.js";
37
35
 
38
36
  /**
@@ -168,87 +166,43 @@ function die(msg, code = 1) {
168
166
  /* --------------------------------------------------------------- session -- */
169
167
 
170
168
  /**
171
- * Everything needed to talk in a room, derived exactly as the browser derives
172
- * it same salt, same iteration count, same HKDF info strings, because it is
173
- * the same function.
169
+ * Derived exactly as the browser derives it, because it is the same function —
170
+ * see cli/session.mjs. This wrapper exists only to turn a throw into an exit
171
+ * code, which is the part a pipe cares about.
174
172
  */
175
173
  async function derive(rawKey, pin) {
176
- const key = keys.normalise(rawKey);
177
- if (!keys.isValid(key)) die(`"${rawKey}" is not a valid key`, 2);
178
-
179
- if (pin) {
180
- const clean = cryptoBox.normalisePin(pin);
181
- if (!clean) die("that PIN is too short", 2);
182
- const d = await cryptoBox.deriveLocked(key, clean);
183
- return { key, roomHash: d.roomHash, aesKey: d.aesKey, auth: d.authToken, locked: true };
184
- }
185
- const open = await cryptoBox.deriveOpen(key);
186
- return { key, roomHash: open.roomHash, aesKey: open.aesKey, auth: null, locked: false };
174
+ try { return await room.derive(rawKey, pin); }
175
+ catch (err) { die(err.message, 2); }
187
176
  }
188
177
 
189
178
  /**
190
- * Connect and resolve once the relay has welcomed us.
191
- *
192
- * relay.js announces state on the bus rather than returning a promise, because
193
- * in the browser the connection outlives any one call. Here there is a script
194
- * waiting, so the bus event is adapted back into a promise — and a timeout,
195
- * since a pipe that hangs forever is worse than one that fails.
179
+ * Connect and resolve once the relay has welcomed us. The adapter is shared; what
180
+ * stays here is the CLI's own reporting — a `--quiet`-aware note on a transport
181
+ * error, and a line on stderr for a clip this key cannot read.
196
182
  */
197
- function connect(session, opts, onClip) {
183
+ function connect(sess, opts, onClip) {
198
184
  const url = opts.relay ?? env.REALTIMECLIPBOARD_RELAY ?? undefined;
199
185
  if (opts.relay && !normaliseRelay(opts.relay)) die(`"${opts.relay}" is not a usable relay address`, 2);
200
186
 
201
- return new Promise((resolve, reject) => {
202
- // Connecting has its own bound, separate from --timeout below: "the relay
203
- // never answered" and "nothing was ever sent to this room" are different
204
- // failures, and a script that gets one when it expected the other has been
205
- // told a lie about its own network.
206
- const timer = setTimeout(
207
- () => reject(new Error("the relay did not answer")),
208
- Math.max(opts.timeout || 0, 20_000),
209
- );
210
-
211
- on(EV.CONN_STATE, ({ state, detail }) => {
212
- if (state === "connected") { clearTimeout(timer); resolve(); }
213
- if (state === "error" && detail) note(` ${detail}`, opts);
214
- });
215
-
216
- relay.setFrameHandler(async msg => {
217
- if (msg.t !== proto.T.CLIP || !msg.payload) return;
218
- try {
219
- const text = await cryptoBox.decrypt(session.aesKey, msg.payload, msg.iv);
220
- // The lock beacon is a control frame wearing a clip's clothes: it is
221
- // what lets a joiner tell "wrong PIN" from "first one here". Receivers
222
- // drop it rather than render it, and a pipe must not see it either.
223
- // Compared against the constant, not a guess at its shape: it starts
224
- // with NUL, and "looks like a control character" would also
225
- // swallow a legitimate clip that happened to.
226
- if (text === LOCK.BEACON) return;
227
- onClip(text, msg);
228
- } catch {
229
- // Undecryptable means a different secret, not a corrupt relay: someone
230
- // in the room with another PIN, or a stale frame from a rotated key.
231
- note(" (a clip arrived that this key cannot read)", opts);
232
- }
233
- });
234
-
235
- relay.connect({
236
- roomHash: session.roomHash,
237
- intent: "join",
238
- name: `cli@${hostname()}`,
239
- auth: session.auth,
240
- ...(url ? { url: normaliseRelay(url) } : {}),
241
- });
187
+ return room.open({
188
+ session: sess,
189
+ name: `cli@${hostname()}`,
190
+ url: url ? normaliseRelay(url) : undefined,
191
+ timeoutMs: opts.timeout || 0,
192
+ onClip,
193
+ onState: (state, detail) => { if (state === "error" && detail) note(` ${detail}`, opts); },
194
+ onUndecryptable: () => note(" (a clip arrived that this key cannot read)", opts),
242
195
  });
243
196
  }
244
197
 
245
- async function sendText(session, text, opts) {
198
+ async function sendText(sess, text, opts) {
246
199
  if (!text) die("nothing on stdin to send", 2);
247
200
  if (text.length > TEXT.MAX_CHARS) {
248
201
  die(`that is ${text.length} characters; the limit is ${TEXT.MAX_CHARS}`, 2);
249
202
  }
250
- const { payload, iv } = await cryptoBox.encrypt(session.aesKey, text);
251
- relay.send(proto.clip({ payload, iv, originId: `cli-${Date.now().toString(36)}` }));
203
+ try {
204
+ await room.send(sess, text, `cli-${Date.now().toString(36)}`);
205
+ } catch (err) { die(err.message, 2); }
252
206
  // The frame is handed to a socket, not delivered. Give it a moment to flush
253
207
  // before the process exits out from under it.
254
208
  await new Promise(r => setTimeout(r, 250));
@@ -338,7 +292,15 @@ if (cmd === "both") {
338
292
  // Not readStdin(): that waits for EOF, and this mode has to send each line as
339
293
  // it is typed while still printing what arrives in between.
340
294
  const rl = createInterface({ input: stdin, terminal: false });
341
- rl.on("line", line => { if (line.length) sendText(session, line, opts).catch(() => {}); });
295
+ // Chained, not launched: each line is encrypted before it is sent, and two
296
+ // encryptions in flight complete in whichever order WebCrypto finishes them.
297
+ // A pasted block of lines could reach the relay out of order and be stamped
298
+ // with inverted `seq`, which is what receivers order and dedupe by.
299
+ let sending = Promise.resolve();
300
+ rl.on("line", line => {
301
+ if (!line.length) return;
302
+ sending = sending.then(() => sendText(session, line, opts)).catch(() => {});
303
+ });
342
304
  rl.on("close", () => { relay.close(); exit(0); });
343
305
  }
344
306
 
@@ -0,0 +1,134 @@
1
+ /**
2
+ * A room, as a promise-shaped API — for the surfaces that have a caller waiting.
3
+ *
4
+ * relay.js announces state on the bus because in a browser the connection
5
+ * outlives any one call. `cli/`, `mcp/` and `vscode/` all have something waiting
6
+ * instead, and all three had adapted the bus back into a promise separately.
7
+ * This is that adapter, once, because three copies of "derive, connect, decrypt,
8
+ * drop the beacon" is three chances for two ends to disagree silently.
9
+ *
10
+ * Deliberately below the app: it touches no state, emits no bus events and knows
11
+ * nothing about history, editors or clipboards. It hands decrypted text to a
12
+ * callback and stops. What each surface does next is that surface's business.
13
+ *
14
+ * WHY IT LIVES IN cli/ AND NOT src/transport/, which is where it looks like it
15
+ * belongs: `src/` is the web app, and every .js under it is app shell — the
16
+ * precache check in tests/unit/static-check.mjs treats the directory itself as
17
+ * the list. A module no page ever loads would have to be precached anyway, or
18
+ * carved out with an exception. `cli/` is already the npm publish boundary and
19
+ * already the headless half of this codebase, so it is the honest home; `mcp/`
20
+ * and `vscode/` import it from here.
21
+ */
22
+
23
+ import * as keys from "../src/core/keys.js";
24
+ import * as cryptoBox from "../src/core/crypto.js";
25
+ import { LOCK, TEXT, textBytes, NET } from "../src/core/config.js";
26
+ import { on, EV } from "../src/core/bus.js";
27
+ import * as relay from "../src/transport/relay.js";
28
+ import * as proto from "../src/transport/protocol.js";
29
+
30
+ /**
31
+ * Everything needed to talk in a room, derived exactly as the browser derives
32
+ * it — same salt, same iteration count, same HKDF info strings, because it is
33
+ * the same function.
34
+ *
35
+ * Throws rather than exiting: a library that calls process.exit() cannot be
36
+ * used by anything that wants to handle the failure.
37
+ */
38
+ export async function derive(rawKey, pin) {
39
+ const key = keys.normalise(rawKey);
40
+ const refused = keys.rejectMessage(key);
41
+ if (refused) throw new Error(`"${rawKey}" cannot be used — ${refused}`);
42
+
43
+ if (pin) {
44
+ const clean = cryptoBox.normalisePin(pin);
45
+ if (!clean) throw new Error(`a PIN needs at least ${LOCK.MIN_PIN} characters`);
46
+ const d = await cryptoBox.deriveLocked(key, clean);
47
+ return { key, roomHash: d.roomHash, aesKey: d.aesKey, auth: d.authToken, locked: true };
48
+ }
49
+ const d = await cryptoBox.deriveOpen(key);
50
+ return { key, roomHash: d.roomHash, aesKey: d.aesKey, auth: null, locked: false };
51
+ }
52
+
53
+ /**
54
+ * Connect, and resolve once the relay has welcomed us.
55
+ *
56
+ * The connect bound is separate from any read timeout a caller keeps: "the relay
57
+ * never answered" and "nothing was ever sent to this room" are different
58
+ * failures, and a script handed one when it expected the other has been told a
59
+ * lie about its own network.
60
+ *
61
+ * onClip(text, frame) decrypted, beacon already dropped
62
+ * onUndecryptable() someone in the room on another secret — not an error
63
+ */
64
+ export function open({ session, name, url, onClip, onUndecryptable, onState, timeoutMs = 0 }) {
65
+ return new Promise((resolve, reject) => {
66
+ let off = null;
67
+ const timer = setTimeout(() => {
68
+ off?.(); // or the subscription outlives every failed open
69
+ reject(new Error("the relay did not answer"));
70
+ }, Math.max(timeoutMs || 0, NET.PROBE_MS * 2.5));
71
+
72
+ off = on(EV.CONN_STATE, ({ state, detail }) => {
73
+ onState?.(state, detail);
74
+ if (state !== "connected") return;
75
+ clearTimeout(timer);
76
+ off();
77
+ resolve(session);
78
+ });
79
+
80
+ relay.setFrameHandler(async (msg) => {
81
+ if (msg.t !== proto.T.CLIP || !msg.payload) return;
82
+ let text;
83
+ try {
84
+ text = await cryptoBox.decrypt(session.aesKey, msg.payload, msg.iv);
85
+ } catch {
86
+ // Undecryptable means a different secret, not a corrupt relay: someone
87
+ // in the room on another PIN, or a stale frame from a rotated key.
88
+ onUndecryptable?.();
89
+ return;
90
+ }
91
+ // A control frame wearing a clip's clothes — it is what lets a joiner tell
92
+ // "wrong PIN" from "first one here". Compared against the constant, never
93
+ // "looks like a control character", which would also swallow a legitimate
94
+ // clip that happened to start with NUL.
95
+ if (text === LOCK.BEACON) return;
96
+ onClip?.(text, msg);
97
+ });
98
+
99
+ relay.connect({
100
+ roomHash: session.roomHash,
101
+ intent: "join",
102
+ name,
103
+ auth: session.auth,
104
+ ...(url ? { url } : {}),
105
+ });
106
+ });
107
+ }
108
+
109
+ /** Bytes, not characters: the frame cap is what the relay actually enforces. */
110
+ export async function send(session, text, originId) {
111
+ if (!text) throw new Error("there is nothing to send");
112
+ if (textBytes(text) > TEXT.MAX_BYTES) {
113
+ throw new Error(`that is ${textBytes(text)} bytes; the limit is ${TEXT.MAX_BYTES}`);
114
+ }
115
+ const { payload, iv } = await cryptoBox.encrypt(session.aesKey, text);
116
+ return relay.send(proto.clip({ payload, iv, originId }));
117
+ }
118
+
119
+ /** Announce a room being abandoned, so nobody is left connected to nothing. */
120
+ export async function evict(session, originId) {
121
+ try {
122
+ const { payload, iv } = await cryptoBox.encrypt(session.aesKey, LOCK.EVICT);
123
+ relay.send(proto.clip({ payload, iv, originId }));
124
+ } catch { /* leaving anyway */ }
125
+ }
126
+
127
+ /** Is the room actually usable right now? Callers that reconnect on demand ask
128
+ * this before tearing a live connection down and building it again. */
129
+ export const isOpen = () => relay.isOpen();
130
+
131
+ export function close() {
132
+ relay.setFrameHandler(() => {});
133
+ relay.close();
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtimeclipboard",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Live clipboard sharing. Static frontend of native ES modules — development needs no build, and `npm run build` exists only to assemble the deploy. Nothing here is needed to READ the app.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,19 +34,28 @@
34
34
  "node": ">=22"
35
35
  },
36
36
  "scripts": {
37
- "verify": "node tests/unit/static-check.mjs && node tests/unit/relay-url.mjs && node tests/unit/lock.mjs && node tests/unit/files.mjs && node tests/unit/transfer.mjs && node tests/unit/clipsize.mjs && node tests/unit/syncmode.mjs && node tests/unit/pasteguard.mjs && node tests/dom/dialog.mjs && node tests/dom/whatsnew.mjs && node tests/dom/tiles.mjs && node tests/dom/guide.mjs && node tests/dom/editor.mjs && node tests/dom/offer.mjs",
38
- "test": "npm run verify && node tests/dom/bundle.mjs && node tests/live/e2e.mjs && node tests/live/boot.mjs && node tests/live/boot.mjs --locked && node tests/live/fallback.mjs",
37
+ "verify": "node tests/unit/static-check.mjs && node tests/unit/relay-url.mjs && node tests/unit/lock.mjs && node tests/unit/files.mjs && node tests/unit/transfer.mjs && node tests/unit/clipsize.mjs && node tests/unit/syncmode.mjs && node tests/unit/pasteguard.mjs && node tests/unit/sharelink.mjs && node tests/unit/keyfloor.mjs && node tests/unit/adpolicy.mjs && node tests/unit/vscode-host.mjs && node tests/unit/browser-worker.mjs && node tests/dom/dialog.mjs && node tests/dom/theme.mjs && node tests/dom/whatsnew.mjs && node tests/dom/tiles.mjs && node tests/dom/guide.mjs && node tests/dom/links.mjs && node tests/dom/editor.mjs && node tests/dom/capture.mjs && node tests/dom/offer.mjs",
38
+ "test": "npm run verify && npm run build:vscode && npm run build:mcp && npm run build:browser && node tests/dom/bundle.mjs && node tests/dom/extension.mjs && node tests/live/vscode.mjs && node tests/live/mcp.mjs && node tests/live/cli.mjs && node tests/live/e2e.mjs && node tests/live/boot.mjs && node tests/live/boot.mjs --locked && node tests/live/boot.mjs --qr && node tests/live/boot.mjs --locked --qr && node tests/live/fallback.mjs",
39
39
  "test:static": "node tests/unit/static-check.mjs",
40
40
  "test:lock": "node tests/unit/lock.mjs && node tests/dom/dialog.mjs",
41
41
  "test:e2e": "node tests/live/e2e.mjs",
42
- "test:boot": "node tests/live/boot.mjs && node tests/live/boot.mjs --locked",
42
+ "test:boot": "node tests/live/boot.mjs && node tests/live/boot.mjs --locked && node tests/live/boot.mjs --qr && node tests/live/boot.mjs --locked --qr",
43
43
  "test:bundle": "node tests/dom/bundle.mjs",
44
+ "test:vscode": "node tools/build/build-vscode.mjs && node tests/dom/extension.mjs && node tests/live/vscode.mjs",
44
45
  "test:tiles": "node tests/dom/tiles.mjs",
45
46
  "test:fallback": "node tests/live/fallback.mjs",
46
47
  "test:cli": "node tests/live/cli.mjs",
48
+ "test:mcp": "node tests/live/mcp.mjs",
49
+ "test:editor": "node tests/editor/host.mjs",
47
50
  "build": "node tools/build/build.mjs",
48
51
  "build:site": "node tools/build/build.mjs _site && node tools/check/site-check.mjs _site",
49
- "build:desktop": "node tools/build/build.mjs _desktop --desktop",
52
+ "size:record": "node tools/build/build.mjs _site --record-size",
53
+ "build:desktop": "node tools/build/build.mjs _desktop --desktop && node tools/check/desktop-check.mjs _desktop",
54
+ "build:vscode": "node tools/build/build-vscode.mjs",
55
+ "build:browser": "node tools/build/build-browser.mjs",
56
+ "build:mcp": "node tools/build/build-mcp.mjs",
57
+ "package:browser": "node tools/build/build-browser.mjs --zip",
58
+ "package:vscode": "node tools/build/build-vscode.mjs --package",
50
59
  "build:og": "python tools/build/build-og-card.py",
51
60
  "check:og": "python tools/build/build-og-card.py --check",
52
61
  "build:icons": "python tools/build/build-icons.py",
@@ -9,7 +9,7 @@ and the node test suites run these files unchanged.
9
9
  | `config.js` | Every tunable constant, the relay address, and the crypto parameters |
10
10
  | `state.js` | Session state and the setters that announce changes |
11
11
  | `crypto.js` | Room hashing, PBKDF2/HKDF derivation, AES-GCM. No libraries |
12
- | `keys.js` | Share-key generation, normalisation, and the URL fragment |
12
+ | `keys.js` | Share-key generation, normalisation, the URL fragment, and the link a peer opens |
13
13
  | `storage.js` | localStorage, wrapped so a disabled-storage browser degrades instead of throwing |
14
14
  | `paths.js` | Where the app is served from, resolved once for the whole codebase |
15
15
  | `device.js` | A human-readable name for this device, shown in the peer list |
package/src/core/bus.js CHANGED
@@ -38,7 +38,7 @@ export const EV = {
38
38
  TEXT_STREAMED: "text:streamed", // {text, caret, name, from} in
39
39
  TIER_CHANGED: "clipboard:tier", // {tier, note}
40
40
  PENDING_CLIP: "clipboard:pending",// {pending, text} — arrived while unfocused
41
- CLIP_OFFERED: "clipboard:offered",// {text} — arrived, but the editor has unsent work
41
+ CLIP_OFFERED: "clipboard:offered",// {text, onClipboard} — arrived over unsent work; text:null retracts the offer
42
42
  PEER_JOINED: "peers:joined", // {name} — a device entered the session
43
43
  PEER_LEFT: "peers:left", // {name}
44
44
  PERMISSION: "clipboard:permission", // {state} granted|prompt|denied
@@ -64,6 +64,10 @@ export const EV = {
64
64
  // from LOCK_STATE because `state.locked` is still false: no session was ever
65
65
  // opened to be locked.
66
66
  LOCK_REQUIRED: "session:lockrequired",
67
+ // {key, reason} — a key was named and refused before any connection opened.
68
+ // Like LOCK_REQUIRED there is no session behind it, and for the same reason:
69
+ // the app must not look usable when nothing it does would go anywhere.
70
+ KEY_REJECTED: "session:keyrejected",
67
71
  FOUNDER: "session:founder", // {founder} — first into this room? null = not yet known
68
72
 
69
73
  // files