realtimeclipboard 0.7.0 → 1.0.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 +10 -10
- package/cli/CLAUDE.md +7 -0
- package/cli/realtimeclipboard.mjs +44 -9
- package/cli/session.mjs +108 -4
- package/package.json +5 -2
- package/src/core/CLAUDE.md +4 -0
- package/src/core/bus.js +2 -2
- package/src/core/config.js +116 -6
- package/src/core/frames.js +117 -0
- package/src/core/history.js +51 -21
- package/src/core/i18n.js +92 -0
- package/src/core/keys.js +22 -3
- package/src/core/native.js +1 -1
- package/src/core/state.js +37 -4
- package/src/core/storage.js +14 -0
- package/src/core/verify.js +152 -0
- package/src/transport/protocol.js +30 -0
- package/src/transport/relay.js +58 -8
- package/src/transport/sse.js +10 -4
- package/src/transport/ws.js +28 -5
package/README.md
CHANGED
|
@@ -26,16 +26,16 @@ proven.
|
|
|
26
26
|
|
|
27
27
|
## What it does
|
|
28
28
|
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
29
|
+
- Syncs clipboard text between 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 short key is the whole identity of a session
|
|
32
32
|
(ten characters on the web, sixteen in the installed apps, and either works on both)
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
33
|
+
- End-to-end encrypted in the browser with AES-GCM; one `PBKDF2` derivation produces both the key and the room address
|
|
34
|
+
- Peer-to-peer file transfer over a WebRTC data channel, 5 MB per file
|
|
35
|
+
- Copy and paste images: a screenshot copied on one machine previews on the other
|
|
36
|
+
- Installable progressive web app, with its own window and icon, and it works offline
|
|
37
|
+
- Nothing written to disk, on your machine or the server
|
|
38
|
+
- Self-hostable relay, one small FastAPI service
|
|
39
39
|
|
|
40
40
|
## Why
|
|
41
41
|
|
|
@@ -138,7 +138,7 @@ desktop/ Tauri shell around this very src/ — no second implemen
|
|
|
138
138
|
assets/ icons/ (precached whole) + social/ (the OG card, never)
|
|
139
139
|
tests/ unit/ needs nothing · dom/ needs jsdom · live/ needs a relay
|
|
140
140
|
tools/ build/ · check/ · release/ · seo/
|
|
141
|
-
docs/ PRD, architecture, clipboard design, P2P design,
|
|
141
|
+
docs/ PRD, architecture, clipboard design, P2P design, threat model
|
|
142
142
|
```
|
|
143
143
|
|
|
144
144
|
A directory **at the root** is served at its own path — `assets/icons/icon.svg` is
|
package/cli/CLAUDE.md
CHANGED
|
@@ -25,4 +25,11 @@ end up subtly and silently disagreeing.
|
|
|
25
25
|
already wrong — the package said 0.2.1 while `--version` said 0.1.0.
|
|
26
26
|
- `package.json` `files:` ships `cli/`, `src/core/` and `src/transport/`. Importing anything outside
|
|
27
27
|
those three from here breaks the published package, and only after publish.
|
|
28
|
+
- **`session.mjs` acts on its own initiative in exactly one place**, and that place needs the
|
|
29
|
+
caller's permission. Everything else in it runs because a surface asked — it sends when told,
|
|
30
|
+
closes when told. The lock-verification responder answers a frame off the wire with nobody in the
|
|
31
|
+
loop, so it takes a `sharing` predicate (VS Code has an Off rung; the other three do not) and
|
|
32
|
+
carries a session generation, because producing an answer spans two awaits and the surface can
|
|
33
|
+
leave, be evicted, or rejoin on another key inside either. Anything else added here that
|
|
34
|
+
transmits unprompted needs both. `tests/unit/shared-session.mjs`.
|
|
28
35
|
- `tests/live/cli.mjs` runs this against a real relay, and `prepublishOnly` runs that suite.
|
|
@@ -47,6 +47,13 @@ import { INVISIBLE_SOURCE } from "../src/core/text.js";
|
|
|
47
47
|
* itself and never goes through the bundler, so its depth in the tree is fixed.
|
|
48
48
|
* npm always includes package.json in a tarball, so this resolves after install.
|
|
49
49
|
*/
|
|
50
|
+
/**
|
|
51
|
+
* This process's id in the room. One per run, not one per clip: it is how peers
|
|
52
|
+
* recognise our own frames coming back, and how a locked room's verification
|
|
53
|
+
* answer is attributed.
|
|
54
|
+
*/
|
|
55
|
+
const ORIGIN = `cli-${Date.now().toString(36)}`;
|
|
56
|
+
|
|
50
57
|
const VERSION = JSON.parse(
|
|
51
58
|
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
52
59
|
).version;
|
|
@@ -158,6 +165,9 @@ const forTerminal = text => (stdout.isTTY ? text.replace(TERMINAL_UNSAFE, "") :
|
|
|
158
165
|
|
|
159
166
|
const note = (msg, opts) => { if (!opts.quiet) stderr.write(`${msg}\n`); };
|
|
160
167
|
|
|
168
|
+
/** How long EOF waits for queued lines to finish leaving. */
|
|
169
|
+
const DRAIN_MS = 5_000;
|
|
170
|
+
|
|
161
171
|
function die(msg, code = 1) {
|
|
162
172
|
stderr.write(`realtimeclipboard: ${msg}\n`);
|
|
163
173
|
exit(code);
|
|
@@ -187,6 +197,7 @@ function connect(sess, opts, onClip) {
|
|
|
187
197
|
return room.open({
|
|
188
198
|
session: sess,
|
|
189
199
|
name: `cli@${hostname()}`,
|
|
200
|
+
originId: ORIGIN,
|
|
190
201
|
url: url ? normaliseRelay(url) : undefined,
|
|
191
202
|
timeoutMs: opts.timeout || 0,
|
|
192
203
|
onClip,
|
|
@@ -195,14 +206,17 @@ function connect(sess, opts, onClip) {
|
|
|
195
206
|
});
|
|
196
207
|
}
|
|
197
208
|
|
|
198
|
-
|
|
199
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Throws rather than exiting. `send` is one shot and dies on a failure, but
|
|
211
|
+
* two-way mode has a session to keep: killing the process on one refused line
|
|
212
|
+
* would drop the reader half too.
|
|
213
|
+
*/
|
|
214
|
+
async function sendText(sess, text) {
|
|
215
|
+
if (!text) throw new Error("nothing on stdin to send");
|
|
200
216
|
if (text.length > TEXT.MAX_CHARS) {
|
|
201
|
-
|
|
217
|
+
throw new Error(`that is ${text.length} characters; the limit is ${TEXT.MAX_CHARS}`);
|
|
202
218
|
}
|
|
203
|
-
|
|
204
|
-
await room.send(sess, text, `cli-${Date.now().toString(36)}`);
|
|
205
|
-
} catch (err) { die(err.message, 2); }
|
|
219
|
+
await room.send(sess, text, ORIGIN);
|
|
206
220
|
// The frame is handed to a socket, not delivered. Give it a moment to flush
|
|
207
221
|
// before the process exits out from under it.
|
|
208
222
|
await new Promise(r => setTimeout(r, 250));
|
|
@@ -283,7 +297,9 @@ try {
|
|
|
283
297
|
note(" connected", opts);
|
|
284
298
|
|
|
285
299
|
if (cmd === "send") {
|
|
286
|
-
|
|
300
|
+
try {
|
|
301
|
+
await sendText(session, await readStdin());
|
|
302
|
+
} catch (err) { die(err.message, 2); }
|
|
287
303
|
relay.close();
|
|
288
304
|
exit(0);
|
|
289
305
|
}
|
|
@@ -297,11 +313,30 @@ if (cmd === "both") {
|
|
|
297
313
|
// A pasted block of lines could reach the relay out of order and be stamped
|
|
298
314
|
// with inverted `seq`, which is what receivers order and dedupe by.
|
|
299
315
|
let sending = Promise.resolve();
|
|
316
|
+
let failed = false;
|
|
300
317
|
rl.on("line", line => {
|
|
301
318
|
if (!line.length) return;
|
|
302
|
-
sending = sending
|
|
319
|
+
sending = sending
|
|
320
|
+
.then(() => sendText(session, line))
|
|
321
|
+
.catch(err => { failed = true; note(` ${err.message}`, opts); });
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Drained, not abandoned. `close` fires the moment stdin ends, and calling
|
|
325
|
+
// exit() there killed the chain mid-flight: piping three lines in sent none of
|
|
326
|
+
// them, because the first was still being encrypted — and the exit status said
|
|
327
|
+
// it had worked. Bounded, so a wedged relay cannot hold the process open.
|
|
328
|
+
rl.on("close", async () => {
|
|
329
|
+
// Which of the two won matters: the timeout winning means lines are still
|
|
330
|
+
// unsent, and exiting 0 there reports a success that did not happen — the
|
|
331
|
+
// same overstatement as the send path that ignored its own return value.
|
|
332
|
+
const drained = await Promise.race([
|
|
333
|
+
sending.then(() => true, () => true),
|
|
334
|
+
new Promise(done => setTimeout(() => done(false), DRAIN_MS)),
|
|
335
|
+
]);
|
|
336
|
+
relay.close();
|
|
337
|
+
if (!drained) note(" timed out with lines still unsent", opts);
|
|
338
|
+
exit(failed || !drained ? 4 : 0);
|
|
303
339
|
});
|
|
304
|
-
rl.on("close", () => { relay.close(); exit(0); });
|
|
305
340
|
}
|
|
306
341
|
|
|
307
342
|
for (const sig of ["SIGINT", "SIGTERM"]) {
|
package/cli/session.mjs
CHANGED
|
@@ -26,6 +26,19 @@ import { LOCK, TEXT, textBytes, NET } from "../src/core/config.js";
|
|
|
26
26
|
import { on, EV } from "../src/core/bus.js";
|
|
27
27
|
import * as relay from "../src/transport/relay.js";
|
|
28
28
|
import * as proto from "../src/transport/protocol.js";
|
|
29
|
+
import { sealWith, openWith } from "../src/core/frames.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Which room this module is currently in. Bumped by open() and close(), and
|
|
33
|
+
* therefore by eviction, which goes through close().
|
|
34
|
+
*
|
|
35
|
+
* It exists for the one thing here that outlives the frame it started from: a
|
|
36
|
+
* verification answer takes two awaits to produce, and both of them are long
|
|
37
|
+
* enough for the surface to have left the room, rejoined on another key, or
|
|
38
|
+
* been evicted from it. Sealed with the old key and sent into the new room,
|
|
39
|
+
* that answer is undecryptable noise attributed to this device.
|
|
40
|
+
*/
|
|
41
|
+
let epoch = 0;
|
|
29
42
|
|
|
30
43
|
/**
|
|
31
44
|
* Everything needed to talk in a room, derived exactly as the browser derives
|
|
@@ -41,8 +54,14 @@ export async function derive(rawKey, pin) {
|
|
|
41
54
|
if (refused) throw new Error(`"${rawKey}" cannot be used — ${refused}`);
|
|
42
55
|
|
|
43
56
|
if (pin) {
|
|
57
|
+
// Length, not truthiness. A one-character PIN passed this and derived a real
|
|
58
|
+
// locked room that the web PIN dialog then refused to join, because it
|
|
59
|
+
// enforces the floor on every mode — so these clients could create sessions
|
|
60
|
+
// no browser could open, with a secret worth a couple of bits.
|
|
44
61
|
const clean = cryptoBox.normalisePin(pin);
|
|
45
|
-
if (
|
|
62
|
+
if (clean.length < LOCK.MIN_PIN) {
|
|
63
|
+
throw new Error(`a PIN needs at least ${LOCK.MIN_PIN} characters`);
|
|
64
|
+
}
|
|
46
65
|
const d = await cryptoBox.deriveLocked(key, clean);
|
|
47
66
|
return { key, roomHash: d.roomHash, aesKey: d.aesKey, auth: d.authToken, locked: true };
|
|
48
67
|
}
|
|
@@ -58,10 +77,29 @@ export async function derive(rawKey, pin) {
|
|
|
58
77
|
* failures, and a script handed one when it expected the other has been told a
|
|
59
78
|
* lie about its own network.
|
|
60
79
|
*
|
|
61
|
-
* onClip(text, frame) decrypted,
|
|
80
|
+
* onClip(text, frame) decrypted, control frames already dropped
|
|
81
|
+
* onEvicted() the room was abandoned; the connection is ALREADY shut
|
|
62
82
|
* onUndecryptable() someone in the room on another secret — not an error
|
|
83
|
+
*
|
|
84
|
+
* `originId` is this surface's own id — the same one it passes to send(). It is
|
|
85
|
+
* wanted here because a locked room's verification is answered from inside this
|
|
86
|
+
* handler, with no caller to ask.
|
|
87
|
+
*
|
|
88
|
+
* `sharing` is that answer's rung gate. Defaulted to "yes" rather than made
|
|
89
|
+
* required, and the default is correct rather than convenient: the CLI, the MCP
|
|
90
|
+
* server and the browser extension have no Off rung to consult — every one of
|
|
91
|
+
* their sessions exists because somebody asked for it. VS Code does have one,
|
|
92
|
+
* and passes it. See vscode/src/room.mjs.
|
|
63
93
|
*/
|
|
64
|
-
export function open({
|
|
94
|
+
export function open({
|
|
95
|
+
session, name, url, originId, sharing = () => true,
|
|
96
|
+
onClip, onEvicted, onUndecryptable, onState, timeoutMs = 0,
|
|
97
|
+
}) {
|
|
98
|
+
// A rejoin is a different room, even on the same key — re-PINning keeps the
|
|
99
|
+
// key and moves the room. Anything still in flight for the last one is now
|
|
100
|
+
// answering a question nobody asked.
|
|
101
|
+
const mine = ++epoch;
|
|
102
|
+
|
|
65
103
|
return new Promise((resolve, reject) => {
|
|
66
104
|
let off = null;
|
|
67
105
|
const timer = setTimeout(() => {
|
|
@@ -78,6 +116,9 @@ export function open({ session, name, url, onClip, onUndecryptable, onState, tim
|
|
|
78
116
|
});
|
|
79
117
|
|
|
80
118
|
relay.setFrameHandler(async (msg) => {
|
|
119
|
+
if (msg.t === proto.T.VERIFY) {
|
|
120
|
+
return answerVerify({ session, msg, originId, sharing, mine });
|
|
121
|
+
}
|
|
81
122
|
if (msg.t !== proto.T.CLIP || !msg.payload) return;
|
|
82
123
|
let text;
|
|
83
124
|
try {
|
|
@@ -93,6 +134,21 @@ export function open({ session, name, url, onClip, onUndecryptable, onState, tim
|
|
|
93
134
|
// "looks like a control character", which would also swallow a legitimate
|
|
94
135
|
// clip that happened to start with NUL.
|
|
95
136
|
if (text === LOCK.BEACON) return;
|
|
137
|
+
|
|
138
|
+
// The other control frame, and it was not filtered — it reached callers as
|
|
139
|
+
// clip text, so the sentinel was printed to stdout by the CLI, recorded as
|
|
140
|
+
// a clip for the model by the MCP server, and put on the extension's badge.
|
|
141
|
+
// Every one of them then stayed connected to a room the owner had left.
|
|
142
|
+
//
|
|
143
|
+
// Closed here rather than left to each surface: "stop talking to a room
|
|
144
|
+
// nobody is in" is not a per-client decision, and three of the four
|
|
145
|
+
// clients did not know they had to make it.
|
|
146
|
+
if (text === LOCK.EVICT) {
|
|
147
|
+
close();
|
|
148
|
+
onEvicted?.();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
96
152
|
onClip?.(text, msg);
|
|
97
153
|
});
|
|
98
154
|
|
|
@@ -106,6 +162,42 @@ export function open({ session, name, url, onClip, onUndecryptable, onState, tim
|
|
|
106
162
|
});
|
|
107
163
|
}
|
|
108
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Prove a locked room to whoever asked.
|
|
167
|
+
*
|
|
168
|
+
* These four surfaces are frequently the ONLY other device in a room — a phone
|
|
169
|
+
* on the web app plus a terminal, plus nothing else — and a locked session
|
|
170
|
+
* cannot confirm its PIN without a peer that answers. Silent here, and the
|
|
171
|
+
* browser at the other end sits on "the PIN may not match theirs" for the whole
|
|
172
|
+
* session while the CLI it is talking to reads every clip perfectly.
|
|
173
|
+
*
|
|
174
|
+
* `probe !== true` is the loop guard, and it is the same one the app uses: an
|
|
175
|
+
* answer is never answered.
|
|
176
|
+
*
|
|
177
|
+
* Both gates are re-read at every step rather than once at the top, because
|
|
178
|
+
* there are two awaits here and each is a window: the rung can come down and
|
|
179
|
+
* the room can change inside either.
|
|
180
|
+
*
|
|
181
|
+
* The rung is checked BEFORE the decryption, which is where this deliberately
|
|
182
|
+
* differs from the app (src/main.js, case proto.T.VERIFY). There, opening the
|
|
183
|
+
* frame is what sets `verified`, so an Off device is entitled to do it — the
|
|
184
|
+
* rung governs what reaches the user, not what the session knows. Here nothing
|
|
185
|
+
* is learned from opening it: the only thing this function produces is a frame
|
|
186
|
+
* on the wire, so a surface that has said nothing leaves has no reason to start
|
|
187
|
+
* decrypting.
|
|
188
|
+
*/
|
|
189
|
+
async function answerVerify({ session, msg, originId, sharing, mine }) {
|
|
190
|
+
if (!session.locked || !sharing() || mine !== epoch) return;
|
|
191
|
+
|
|
192
|
+
const frame = await openWith(session.aesKey, msg);
|
|
193
|
+
if (!sharing() || mine !== epoch) return;
|
|
194
|
+
if (!frame || frame.probe !== true) return;
|
|
195
|
+
|
|
196
|
+
const answer = await sealWith(session.aesKey, proto.verify({ probe: false, originId }));
|
|
197
|
+
if (!sharing() || mine !== epoch) return;
|
|
198
|
+
relay.send(answer);
|
|
199
|
+
}
|
|
200
|
+
|
|
109
201
|
/** Bytes, not characters: the frame cap is what the relay actually enforces. */
|
|
110
202
|
export async function send(session, text, originId) {
|
|
111
203
|
if (!text) throw new Error("there is nothing to send");
|
|
@@ -113,7 +205,15 @@ export async function send(session, text, originId) {
|
|
|
113
205
|
throw new Error(`that is ${textBytes(text)} bytes; the limit is ${TEXT.MAX_BYTES}`);
|
|
114
206
|
}
|
|
115
207
|
const { payload, iv } = await cryptoBox.encrypt(session.aesKey, text);
|
|
116
|
-
|
|
208
|
+
// The relay's boolean means "handed to an open socket", and every caller
|
|
209
|
+
// ignored it: the CLI exited 0 having sent nothing, the MCP server told the
|
|
210
|
+
// model the clip was delivered, and the extension said "Sent N characters."
|
|
211
|
+
// — all while disconnected. A throw is the one result none of them can drop
|
|
212
|
+
// by accident, and each already reports a thrown error.
|
|
213
|
+
if (!relay.send(proto.clip({ payload, iv, originId }))) {
|
|
214
|
+
throw new Error("not connected to the relay — nothing was sent");
|
|
215
|
+
}
|
|
216
|
+
return true;
|
|
117
217
|
}
|
|
118
218
|
|
|
119
219
|
/** Announce a room being abandoned, so nobody is left connected to nothing. */
|
|
@@ -129,6 +229,10 @@ export async function evict(session, originId) {
|
|
|
129
229
|
export const isOpen = () => relay.isOpen();
|
|
130
230
|
|
|
131
231
|
export function close() {
|
|
232
|
+
// Before the socket, not after: a response mid-seal must be abandoned even
|
|
233
|
+
// though relay.send() would refuse it anyway, because the next open() may
|
|
234
|
+
// have handed the transport a new room by the time that seal resolves.
|
|
235
|
+
epoch++;
|
|
132
236
|
relay.setFrameHandler(() => {});
|
|
133
237
|
relay.close();
|
|
134
238
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "realtimeclipboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.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": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"files": [
|
|
27
27
|
"cli/",
|
|
28
28
|
"src/core/",
|
|
29
|
+
"!src/core/lang/",
|
|
29
30
|
"src/transport/",
|
|
30
31
|
"LICENSE",
|
|
31
32
|
"README.md"
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
"node": ">=22"
|
|
35
36
|
},
|
|
36
37
|
"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/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
|
+
"verify": "node tests/unit/static-check.mjs && node tools/i18n/build-pages.mjs --check && node tools/i18n/app-strings.mjs --check && node tests/unit/i18n.mjs && node tests/unit/relay-url.mjs && node tests/unit/lock.mjs && node tests/unit/session-boundaries.mjs && node tests/unit/verify.mjs && node tests/unit/shared-session.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/unit/browser-popup.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
39
|
"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
40
|
"test:static": "node tests/unit/static-check.mjs",
|
|
40
41
|
"test:lock": "node tests/unit/lock.mjs && node tests/dom/dialog.mjs",
|
|
@@ -49,6 +50,8 @@
|
|
|
49
50
|
"test:editor": "node tests/editor/host.mjs",
|
|
50
51
|
"build": "node tools/build/build.mjs",
|
|
51
52
|
"build:site": "node tools/build/build.mjs _site && node tools/check/site-check.mjs _site",
|
|
53
|
+
"i18n:pages": "node tools/i18n/build-pages.mjs",
|
|
54
|
+
"i18n:app": "node tools/i18n/app-strings.mjs",
|
|
52
55
|
"size:record": "node tools/build/build.mjs _site --record-size",
|
|
53
56
|
"build:desktop": "node tools/build/build.mjs _desktop --desktop && node tools/check/desktop-check.mjs _desktop",
|
|
54
57
|
"build:vscode": "node tools/build/build-vscode.mjs",
|
package/src/core/CLAUDE.md
CHANGED
|
@@ -36,3 +36,7 @@ runs these exact modules. Two consequences:
|
|
|
36
36
|
there. The active session lives in `sessionStorage` and dies with the tab.
|
|
37
37
|
- `text.js` is a security character class with three consumers that may not import each other. It
|
|
38
38
|
belongs here for that reason, and the ranges in it are not a style preference — see the comment.
|
|
39
|
+
- **`verify.js` and `frames.js` take their seams injected, not imported.** A frame shape and the
|
|
40
|
+
transport are both rank 10, so neither is reachable from here — but the reason to keep it that
|
|
41
|
+
way is that every rule in them is then exercisable from a plain node test. Both were four lines
|
|
42
|
+
in the composition root first, and four lines in `main.js` can only be checked by reading them.
|
package/src/core/bus.js
CHANGED
|
@@ -52,10 +52,10 @@ export const EV = {
|
|
|
52
52
|
PEERS_CHANGED: "peers:changed", // {count, list}
|
|
53
53
|
INSTANCE_CHANGED:"conn:instance", // {from, to} — split-brain warning (OI-3)
|
|
54
54
|
KEY_COLLISION: "session:collision",// generated key was taken (OI-2)
|
|
55
|
-
ROOM_STATE: "conn:room", // {existing
|
|
55
|
+
ROOM_STATE: "conn:room", // {existing} — what `welcome` said
|
|
56
56
|
|
|
57
57
|
// session
|
|
58
|
-
KEY_CHANGED: "session:key", // {key, locked}
|
|
58
|
+
KEY_CHANGED: "session:key", // {key, locked, roomHash}
|
|
59
59
|
// Announcements are past tense, commands are bare verbs, and the extra
|
|
60
60
|
// syllable here is load-bearing — see core/CLAUDE.md. The bus does no
|
|
61
61
|
// namespacing of its own, so a collision between the two is silent.
|
package/src/core/config.js
CHANGED
|
@@ -63,6 +63,40 @@ export const RELAY_URL =
|
|
|
63
63
|
/** True when the app is not talking to the relay it was built against. */
|
|
64
64
|
export const RELAY_IS_CUSTOM = RELAY_URL !== DEFAULT_RELAY_URL && RELAY_URL !== LOCAL_RELAY_URL;
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The organisation token for a relay that runs with REALTIMECLIPBOARD_JOIN_TOKEN set.
|
|
68
|
+
*
|
|
69
|
+
* Such a relay refuses every join that does not present `?org=`, and no client
|
|
70
|
+
* had any way to send one — so turning the setting on, which SELF-HOSTING.md
|
|
71
|
+
* tells operators to do to stop their relay being open to anyone who learns the
|
|
72
|
+
* hostname, bricked every shipped client instead of restricting it.
|
|
73
|
+
*
|
|
74
|
+
* Deliberately NOT in the share link: a link is pasted into chats and read off
|
|
75
|
+
* screens, and this is the credential that admits a device to the deployment.
|
|
76
|
+
* It is configured once per device — `?org=` on first load, which is persisted,
|
|
77
|
+
* or REALTIMECLIPBOARD_ORG for the CLI and MCP server.
|
|
78
|
+
*/
|
|
79
|
+
const ORG_KEY = "orgToken";
|
|
80
|
+
|
|
81
|
+
/** The query parameter that carries it. Named once — safeSearch() strips it. */
|
|
82
|
+
export const ORG_PARAM = "org";
|
|
83
|
+
const storedOrg = () => {
|
|
84
|
+
try { return JSON.parse(localStorage.getItem(STORAGE_PREFIX + ORG_KEY)); }
|
|
85
|
+
catch { return null; }
|
|
86
|
+
};
|
|
87
|
+
const orgFromQuery = () => {
|
|
88
|
+
try { return new URLSearchParams(location.search).get(ORG_PARAM); }
|
|
89
|
+
catch { return null; }
|
|
90
|
+
};
|
|
91
|
+
const orgFromEnv = () => {
|
|
92
|
+
try { return globalThis.process?.env?.REALTIMECLIPBOARD_ORG || null; }
|
|
93
|
+
catch { return null; }
|
|
94
|
+
};
|
|
95
|
+
const cleanOrg = v => (typeof v === "string" && v.trim() ? v.trim() : null);
|
|
96
|
+
|
|
97
|
+
export const ORG_TOKEN = cleanOrg(orgFromQuery()) ?? cleanOrg(orgFromEnv()) ?? cleanOrg(storedOrg());
|
|
98
|
+
export const ORG_STORAGE_KEY = ORG_KEY;
|
|
99
|
+
|
|
66
100
|
/**
|
|
67
101
|
* The same relay over plain HTTP, for the SSE+POST fallback and /stats. One
|
|
68
102
|
* hostname so IT allowlists one domain (PRD §5.4); derived so the two cannot drift.
|
|
@@ -146,8 +180,13 @@ export const PASTE_GUARD = {
|
|
|
146
180
|
ENABLED: true,
|
|
147
181
|
|
|
148
182
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
183
|
+
* The longest LINE the scanner will look at. Every pattern is anchored to the
|
|
184
|
+
* start of a line and none spans one, so the scan runs per line and this
|
|
185
|
+
* bounds the work any single regex does.
|
|
186
|
+
*
|
|
187
|
+
* A line past it is treated as risky rather than safe. This was once a cap on
|
|
188
|
+
* the whole clip, above which nothing was scanned at all — and it sat below
|
|
189
|
+
* the clip size limit, so padding a command past it turned the guard off.
|
|
151
190
|
*/
|
|
152
191
|
MAX_SCAN_CHARS: 8_192,
|
|
153
192
|
};
|
|
@@ -188,6 +227,17 @@ export const FILES = {
|
|
|
188
227
|
* wedged association must not hold a transfer open.
|
|
189
228
|
*/
|
|
190
229
|
CHANNEL_CLOSE_MS: 1_000,
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* How long a sender waits for the receiver to confirm it has the file, whole
|
|
233
|
+
* and verified, before reporting what it actually knows.
|
|
234
|
+
*
|
|
235
|
+
* "Sent" used to mean "the last frame was handed to a socket". A digest
|
|
236
|
+
* failure at the far end was reported to nobody, so the holder's tile said
|
|
237
|
+
* the transfer had worked while the other device showed an error. Bounded,
|
|
238
|
+
* because a receiver that vanishes must not leave a progress bar up forever.
|
|
239
|
+
*/
|
|
240
|
+
RECEIPT_MS: 10_000,
|
|
191
241
|
};
|
|
192
242
|
|
|
193
243
|
export const KEY = {
|
|
@@ -291,8 +341,21 @@ export const LOCK = {
|
|
|
291
341
|
SIGIL: "!",
|
|
292
342
|
|
|
293
343
|
/**
|
|
294
|
-
*
|
|
295
|
-
*
|
|
344
|
+
* READ, never written. Locked sessions used to prove themselves by planting
|
|
345
|
+
* this as a clip: the relay retains one clip per room and replays it, so a
|
|
346
|
+
* joiner that decrypted it had proved its PIN before any peer was awake.
|
|
347
|
+
*
|
|
348
|
+
* It worked and it cost too much. The proof took the room's single retained
|
|
349
|
+
* slot, so it either destroyed the last thing the user copied or, once the
|
|
350
|
+
* plant started honouring the sync rung, silently never happened — and the
|
|
351
|
+
* two guards that stopped those cases could not both hold. Verification is
|
|
352
|
+
* now its own frame (`proto.verify`), forwarded and not retained.
|
|
353
|
+
*
|
|
354
|
+
* Still recognised on arrival, because a client older than that change is
|
|
355
|
+
* still planting one, and a sentinel that reached the editor would be
|
|
356
|
+
* printed, put on the OS clipboard and written to history as a clip.
|
|
357
|
+
* Compared against the constant, never "starts with NUL", which would also
|
|
358
|
+
* swallow a legitimate clip.
|
|
296
359
|
*/
|
|
297
360
|
BEACON: String.fromCharCode(0) + "realtimeclipboard-lock-v1",
|
|
298
361
|
|
|
@@ -314,10 +377,30 @@ export const LOCK = {
|
|
|
314
377
|
* that needs the fallback.
|
|
315
378
|
*/
|
|
316
379
|
EVICT_FLUSH_MS: 250,
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Floor between two verification probes. Not a retry timer — every trigger
|
|
383
|
+
* is an event worth asking on (a peer arrived, the rung came off Off) and
|
|
384
|
+
* roster churn can fire several in a second. One question per interval is
|
|
385
|
+
* plenty: a peer that can answer answers immediately, and a peer that cannot
|
|
386
|
+
* will not answer the second one either.
|
|
387
|
+
*/
|
|
388
|
+
VERIFY_MIN_INTERVAL_MS: 3_000,
|
|
317
389
|
};
|
|
318
390
|
|
|
319
391
|
export const NET = {
|
|
320
392
|
HEARTBEAT_MS: 30_000, // must beat proxy idle reaping (PRD 5.4, FR-3.6)
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* How long a ping may go unanswered before the connection is treated as dead.
|
|
396
|
+
*
|
|
397
|
+
* A socket can sit at readyState OPEN with nothing at the other end: no
|
|
398
|
+
* error, no close, and the session reads "connected" for as long as the tab
|
|
399
|
+
* is left open. The ping had no deadline at all, so nothing ever noticed.
|
|
400
|
+
* Generous against a slow phone network, short enough that a dead session
|
|
401
|
+
* reconnects rather than sits.
|
|
402
|
+
*/
|
|
403
|
+
PONG_DEADLINE_MS: 10_000,
|
|
321
404
|
BACKOFF_MIN_MS: 1_000,
|
|
322
405
|
BACKOFF_MAX_MS: 30_000,
|
|
323
406
|
ICE_TIMEOUT_MS: 5_000, // then fall back to relay chunks (FR-7.6)
|
|
@@ -482,7 +565,8 @@ export const LAYOUT = {
|
|
|
482
565
|
* removed 2026-08-09). The ad tag reports the page URL itself with no override,
|
|
483
566
|
* so what survives is TIMING: ui/features/ads.js must never load it while the
|
|
484
567
|
* share key is still in `location.hash`, and waits for keys.clearUrl(). gtag
|
|
485
|
-
* takes `page_location` from `pageLocation()` below, which strips it too
|
|
568
|
+
* takes `page_location` from `pageLocation()` below, which strips it too — and
|
|
569
|
+
* strips `?org=` with it, a credential rather than a room name. !!
|
|
486
570
|
*
|
|
487
571
|
* Adding an origin means adding it to the CSP in _headers AND every page's meta
|
|
488
572
|
* tag — app.html's included — which tools/check/site-check.mjs asserts agree.
|
|
@@ -570,6 +654,32 @@ export const CONSENT_REGIONS = [
|
|
|
570
654
|
* `page_location` is `location.href` — the unmodified tag would send the key to
|
|
571
655
|
* Google on the first page_view. Every gtag config passes this instead. !!
|
|
572
656
|
*/
|
|
657
|
+
/**
|
|
658
|
+
* `location.search` with the deployment credential taken out.
|
|
659
|
+
*
|
|
660
|
+
* `?org=` admits a device to a self-hosted relay, and it rides in the query
|
|
661
|
+
* because a browser cannot set a header on a WebSocket or an EventSource. That
|
|
662
|
+
* put it in `location.search` — which is exactly what gtag's `page_location`
|
|
663
|
+
* sends, and what AdSense reads off the page for itself. A deployment that
|
|
664
|
+
* turned the token on was handing it to Google on the first page_view.
|
|
665
|
+
*
|
|
666
|
+
* Fails CLOSED: anything unparseable yields no query at all rather than the
|
|
667
|
+
* original string, because the string is the thing under suspicion.
|
|
668
|
+
*/
|
|
669
|
+
export function safeSearch(search) {
|
|
670
|
+
const raw = search ?? (typeof location === "undefined" ? "" : location.search);
|
|
671
|
+
if (!raw) return "";
|
|
672
|
+
try {
|
|
673
|
+
const q = new URLSearchParams(raw);
|
|
674
|
+
if (!q.has(ORG_PARAM)) return raw;
|
|
675
|
+
q.delete(ORG_PARAM);
|
|
676
|
+
const rest = q.toString();
|
|
677
|
+
return rest ? `?${rest}` : "";
|
|
678
|
+
} catch {
|
|
679
|
+
return "";
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
573
683
|
export const pageLocation = () => {
|
|
574
684
|
if (typeof location === "undefined") return "";
|
|
575
685
|
/* The desktop webview answers at `tauri.localhost` on Windows and
|
|
@@ -580,7 +690,7 @@ export const pageLocation = () => {
|
|
|
580
690
|
on-disk name is not the name a report should show. Which surface a hit came
|
|
581
691
|
from is `rtc_surface`, not the hostname. */
|
|
582
692
|
if (IS_DESKTOP) return `${SITE.ORIGIN}/app`;
|
|
583
|
-
return location.origin + location.pathname +
|
|
693
|
+
return location.origin + location.pathname + safeSearch();
|
|
584
694
|
};
|
|
585
695
|
|
|
586
696
|
export const GOOGLE_SRC = {
|