realtimeclipboard 0.3.0 → 0.5.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.
@@ -3,41 +3,33 @@
3
3
  * if you find a magic number elsewhere, it belongs here.
4
4
  */
5
5
 
6
- /**
7
- * Relay endpoint.
8
- *
9
- * Must be wss:// in production: the site is served over HTTPS from GitHub
10
- * Pages, and a browser refuses a ws:// connection from an https:// page as
11
- * mixed content. Localhost is exempt from that rule, which is why the dev
12
- * branch can stay ws://.
13
- */
14
- // Guarded: this module is imported by node-based tests where `location` does
15
- // not exist, and a bare reference would throw at import time and take the whole
16
- // graph down.
6
+ import { IS_DESKTOP } from "./native.js";
7
+
8
+ // Guarded: imported by node tests where `location` does not exist, and a bare
9
+ // reference would throw at import time and take the whole graph down.
17
10
  const IS_LOCAL = typeof location !== "undefined" &&
18
11
  ["localhost", "127.0.0.1", "[::1]"].includes(location.hostname);
19
12
 
20
- /** The relay this build ships pointed at. Self-hosters change this one line. */
13
+ /**
14
+ * Must be wss:// in production — a browser refuses ws:// from an https:// page
15
+ * as mixed content. Localhost is exempt, which is why dev can stay ws://.
16
+ * Self-hosters change this one line.
17
+ */
21
18
  export const DEFAULT_RELAY_URL = "wss://realtimeclipboard.fastapicloud.dev";
22
19
  const LOCAL_RELAY_URL = "ws://127.0.0.1:8000";
23
20
 
24
21
  /**
25
- * Key under which the chosen relay is remembered.
26
- *
27
- * The storage PREFIX lives here rather than in core/storage.js so that this
28
- * module can read a setting without importing storage.js — which imports this
29
- * one, and a cycle between "every constant" and "the thing that persists them"
30
- * is the sort that works until someone moves a line to module scope.
22
+ * The storage prefix lives here rather than in core/storage.js so this module
23
+ * can read a setting without importing storage.js — which imports this one, and
24
+ * a cycle between "every constant" and "the thing that persists them" works
25
+ * until someone moves a line to module scope.
31
26
  */
32
27
  export const STORAGE_PREFIX = "realtimeclipboard.";
33
28
  const RELAY_KEY = "relayUrl";
34
29
 
35
30
  /**
36
- * Accept only something that can actually be a relay, and normalise it.
37
- *
38
31
  * `http(s)://` is accepted and converted, because that is what a person copies
39
- * out of a browser bar when their IT department gives them an address, and
40
- * rejecting it would be pedantry with a support ticket attached.
32
+ * out of a browser bar when IT gives them an address.
41
33
  */
42
34
  export function normaliseRelay(raw) {
43
35
  if (!raw || typeof raw !== "string") return null;
@@ -57,20 +49,14 @@ const stored = () => {
57
49
  };
58
50
 
59
51
  /**
60
- * `?relay=` in the address, for builds that are deployed rather than visited:
61
- * a corporate MSI transform, a macOS config profile, or a desktop shell that
62
- * launches the webview at its own relay.
63
- *
64
- * It wins over the stored setting on purpose — the deployment is more current
65
- * than whatever the machine remembers — and main.js persists it, so the switch
66
- * survives the next launch without the flag.
52
+ * `?relay=` for builds that are deployed rather than visited: an MSI transform,
53
+ * a config profile, a desktop shell launching the webview at its own relay. It
54
+ * wins over the stored setting because the deployment is more current, and
55
+ * main.js persists it so the switch survives the next launch.
67
56
  *
68
- * !! This is NOT an attack surface on the hosted site, and the reason is worth
69
- * knowing: the CSP pins `connect-src` to this origin and the default relay, so
70
- * a link carrying `?relay=` to anywhere else cannot open a connection at all.
71
- * The override only does anything on a build whose operator also edited that
72
- * meta tag — which is precisely the self-hosting case it exists for. The CSP is
73
- * the enforcement; this is only the plumbing. !!
57
+ * NOT an attack surface on the hosted site: the CSP pins `connect-src` to this
58
+ * origin and the default relay, so a link carrying `?relay=` elsewhere cannot
59
+ * open a connection. The CSP is the enforcement; this is only the plumbing.
74
60
  */
75
61
  const fromQuery = () => {
76
62
  try { return new URLSearchParams(location.search).get("relay"); }
@@ -86,21 +72,17 @@ export const RELAY_URL =
86
72
  export const RELAY_IS_CUSTOM = RELAY_URL !== DEFAULT_RELAY_URL && RELAY_URL !== LOCAL_RELAY_URL;
87
73
 
88
74
  /**
89
- * The same relay over plain HTTP, for the SSE+POST fallback and /stats.
90
- *
91
- * One hostname, two ways in — which is what makes the fallback worth having:
92
- * IT allowlists a single domain (PRD §5.4) and both transports are covered by
93
- * it. Derived rather than written twice so the two can never drift.
75
+ * The same relay over plain HTTP, for the SSE+POST fallback and /stats. One
76
+ * hostname, two ways in, so IT allowlists a single domain (PRD §5.4) and both
77
+ * transports are covered. Derived so the two can never drift.
94
78
  */
95
79
  export const RELAY_HTTP_URL = RELAY_URL.replace(/^ws/i, "http");
96
80
 
97
81
  /**
98
- * How we talk to the relay.
99
- *
100
82
  * ws — WebSocket. Lower latency, one connection, the default.
101
- * sse — Server-Sent Events downstream + fetch POST upstream (PRD §4.3 R3).
102
- * Plain HTTP with no Upgrade, so it survives the TLS-inspecting
103
- * proxies that eat WebSockets on corporate networks (§5.4).
83
+ * sse — Server-Sent Events down + fetch POST up (PRD §4.3 R3). Plain HTTP
84
+ * with no Upgrade, so it survives the TLS-inspecting proxies that eat
85
+ * WebSockets on corporate networks (§5.4).
104
86
  *
105
87
  * Both carry the identical envelopes from §6 — see transport/protocol.js.
106
88
  */
@@ -109,29 +91,15 @@ export const TRANSPORT = { WS: "ws", SSE: "sse" };
109
91
  /**
110
92
  * Clip size, derived — not chosen.
111
93
  *
112
- * A clip does not go on the wire as text. It is UTF-8 encoded, sealed with
113
- * AES-GCM (which appends a 16-byte tag), base64'd, and dropped into a JSON
114
- * envelope — and the relay rejects any frame over MAX_FRAME_BYTES
115
- * (backend/main.py, 32 KB). Base64 alone inflates by a third, so the real
116
- * limit is a BYTE budget on the wire and a character count is only an
117
- * approximation of it.
94
+ * A clip is UTF-8 encoded, sealed with AES-GCM (16-byte tag), base64'd and put
95
+ * in a JSON envelope, and the relay rejects any frame over MAX_FRAME_BYTES. So
96
+ * the real limit is a BYTE budget and a character count only approximates it.
118
97
  *
119
- * This is the same arithmetic files/chunker.js does for RELAY_CHUNK_BYTES, and
120
- * it is here for the same reason: a hand-tuned second number drifts out of
121
- * sync with the relay's cap, and the failure mode is silent.
122
- *
123
- * It had. MAX_CHARS was 50,000 — 66 KB on the wire against a 32 KB frame — and
124
- * the constant cited FR-2.8 while contradicting it, because "max payload 32 KB"
125
- * had been transcribed into a character count once and never recomputed. Every
126
- * clip over ~24 KB was accepted by the editor, encrypted, sent, and dropped by
127
- * the relay, with the rejection arriving as an async `error` frame long after
128
- * the UI had said it went. Deriving the number is what stops that recurring;
129
- * tests/unit/clipsize.mjs is what proves the derivation.
130
- *
131
- * MAX_CHARS is what the counter shows and what the editor guards on, because
132
- * users think in characters and one ASCII character is one byte. MAX_BYTES is
133
- * the truth, and is what the send path enforces: 24,000 CJK characters are
134
- * 72,000 bytes and will not fit, whatever the counter says.
98
+ * MAX_CHARS was once 50,000 — 66 KB on the wire against a 32 KB frame — because
99
+ * "max payload 32 KB" had been transcribed into a character count and never
100
+ * recomputed. Every clip over ~24 KB was accepted, encrypted, sent, and dropped
101
+ * by the relay, the rejection arriving long after the UI said it went.
102
+ * tests/unit/clipsize.mjs proves the derivation.
135
103
  */
136
104
  const RELAY_FRAME_BYTES = 32 * 1024; // backend/main.py MAX_FRAME_BYTES
137
105
  const CLIP_ENVELOPE_BYTES = 512; // {"t","originId","iv","payload"} + slack
@@ -143,60 +111,112 @@ const CLIP_MAX_BYTES = Math.floor(
143
111
  );
144
112
 
145
113
  /**
146
- * Wire size of a string. Exported beside the limit it is measured against so
147
- * the editor and the send path cannot disagree about what "too big" means —
148
- * `String.length` counts UTF-16 units, which is not what the relay counts.
114
+ * Wire size of a string, exported beside the limit it is measured against so the
115
+ * editor and the send path cannot disagree — `String.length` counts UTF-16
116
+ * units, which is not what the relay counts.
149
117
  */
150
118
  export const textBytes = (s) => new TextEncoder().encode(s).length;
151
119
 
152
120
  export const TEXT = {
121
+ // MAX_CHARS is what the counter shows, because users think in characters.
122
+ // MAX_BYTES is the truth: 24,000 CJK characters are 72,000 bytes.
153
123
  MAX_BYTES: CLIP_MAX_BYTES, // the wire limit — authoritative
154
124
  MAX_CHARS: Math.floor(CLIP_MAX_BYTES / 1000) * 1000, // the friendly one, for the counter
155
125
  SUPPRESS_MS: 1500, // loop-suppression window after applying a remote clip (FR-2.6)
126
+
127
+ /**
128
+ * Generous because the commit is invisible — the text is already on the far
129
+ * screen via the stream, so this only decides when it becomes a clip. A
130
+ * mid-sentence pause is 300-500 ms, and less would fragment one sentence into
131
+ * six history entries.
132
+ */
133
+ COMMIT_IDLE_MS: 800,
134
+
135
+ /** 10 sends/sec — half the relay's 20/s `stream` budget, as cursors.js does. */
136
+ STREAM_THROTTLE_MS: 100,
137
+
138
+ /**
139
+ * Above this, typing is not streamed and the text syncs on commit only.
140
+ *
141
+ * A stream frame carries the WHOLE text, which is what lets this feature exist
142
+ * without diffs or operational transform — a trade that only holds while the
143
+ * text is small. The degradation is invisible because big text is pasted,
144
+ * never typed.
145
+ */
146
+ STREAM_MAX_BYTES: 4096,
147
+
148
+ /**
149
+ * How long a local copy outranks an arriving clip. The moment that actually
150
+ * hurts is "I copied something, went to paste it, and it was gone", so inside
151
+ * this window the arriving clip is queued and offered instead of written.
152
+ */
153
+ LOCAL_COPY_GRACE_MS: 10_000,
154
+ };
155
+
156
+ /**
157
+ * Pastejacking — see clipboard/guard.js for what is actually checked.
158
+ *
159
+ * The room is joinable by anyone holding the key, and an arriving clip is
160
+ * written to the OS clipboard with no gesture from the person receiving it. So
161
+ * a peer chooses what you paste into your next terminal.
162
+ *
163
+ * `ENABLED` governs only the heuristic that demands a click. Stripping the
164
+ * trailing newline and the invisible characters is unconditional and has no
165
+ * switch: it is not a policy, it is the difference between a clip that pastes
166
+ * and a clip that runs.
167
+ */
168
+ export const PASTE_GUARD = {
169
+ ENABLED: true,
170
+
171
+ /**
172
+ * Past this, do not scan. The patterns are anchored per line, so a pasted
173
+ * logfile is a lot of backtracking for a case that is not what this defends
174
+ * against — nobody is tricked into pasting 200 KB into a shell.
175
+ */
176
+ MAX_SCAN_CHARS: 8_192,
156
177
  };
157
178
 
158
179
  export const FILES = {
159
180
  MAX_BYTES: 5 * 1024 * 1024, // 5 MB per file (FR-7.1)
160
181
  MAX_COUNT: 20, // per session, memory only (FR-7.7)
182
+
183
+ /**
184
+ * A filename is chosen by whoever sent the file, so it is bounded like every
185
+ * other piece of peer-supplied metadata. Long enough that no real name is
186
+ * truncated; short enough that a hostile one is a tile, not a layout.
187
+ * files/registry.js `safeName` applies it, along with the character rules.
188
+ */
189
+ MAX_NAME_CHARS: 120,
161
190
  THUMB_PX: 160, // longest edge (FR-7.2)
162
191
  THUMB_QUALITY: 0.7,
192
+
163
193
  /**
164
- * Chunk size for the P2P data channel, which carries raw binary.
165
- *
166
- * The relay fallback CANNOT use this value directly: a chunk there is
167
- * base64'd inside a JSON frame, and base64 (plus the AES-GCM tag and the
168
- * envelope fields) inflates it by roughly a third — a 32 KB chunk becomes a
169
- * ~44 KB frame and is rejected by the relay's own 32 KB cap. The relay chunk
170
- * size is therefore DERIVED from this, in files/chunker.js as
171
- * RELAY_CHUNK_BYTES, rather than being a second hand-tuned number that could
172
- * drift out of sync with it.
194
+ * For the P2P data channel, which carries raw binary. The relay fallback
195
+ * cannot use this directly — base64 plus the tag and envelope inflate a 32 KB
196
+ * chunk into a ~44 KB frame, past the relay's own 32 KB cap — so
197
+ * RELAY_CHUNK_BYTES is DERIVED from this in files/chunker.js rather than being
198
+ * a second hand-tuned number.
173
199
  */
174
200
  CHUNK_BYTES: 32 * 1024,
175
201
 
176
202
  /**
177
- * How long a file request lives before both ends give up on it.
203
+ * How long a file request lives before BOTH ends give up, on the same number,
204
+ * so neither is left believing in a transfer the other abandoned. Approving
205
+ * into a peer that gave up thirty seconds ago sends 5 MB nowhere.
178
206
  *
179
- * Both ends, deliberately: the holder's prompt counts down to a denial and
180
- * the requester stops waiting, on the same number, so neither is left
181
- * believing in a transfer the other has already abandoned. Approving into a
182
- * peer that gave up thirty seconds ago sends 5 MB nowhere.
183
- *
184
- * Its own constant rather than a multiple of the ICE timeout, which is what
185
- * it used to be. That coupling meant shortening the ICE race in a test also
186
- * shortened how long a human had to answer a dialog, and tuning the network
187
- * silently retuned the UI.
207
+ * Its own constant rather than a multiple of the ICE timeout: that coupling
208
+ * meant shortening the ICE race in a test also shortened how long a human had
209
+ * to answer a dialog.
188
210
  */
189
211
  REQUEST_TIMEOUT_MS: 15_000,
190
212
 
191
213
  /**
192
- * How long the sender waits for a data channel it has closed to confirm the
193
- * close, before dropping the peer connection out from under it.
214
+ * How long a closed data channel gets to confirm the close.
194
215
  *
195
216
  * bufferedAmount reaching zero only means SCTP accepted the bytes, not that
196
217
  * the peer has them. The stream reset dc.close() sends is ordered behind the
197
- * data already queued on that stream, so the close event is the nearest thing
198
- * the transport offers to a delivery signal — worth one round trip. Bounded,
199
- * because a wedged association must not hold a finished transfer open.
218
+ * queued data, so the close event is the nearest thing to a delivery signal.
219
+ * Bounded, because a wedged association must not hold a transfer open.
200
220
  */
201
221
  CHANNEL_CLOSE_MS: 1_000,
202
222
  };
@@ -204,8 +224,34 @@ export const FILES = {
204
224
  export const KEY = {
205
225
  // Crockford-ish: no 0/O, no 1/I/L. Ambiguity here becomes a support ticket.
206
226
  ALPHABET: "23456789ABCDEFGHJKMNPQRSTVWXYZ",
207
- LENGTH: 6, // PRD D3
208
- LONG_LENGTH: 10, // "high security" option
227
+ /**
228
+ * Ten, not six. PRD D3 chose six for the phone keyboard and that argument was
229
+ * sound about typing and wrong about arithmetic.
230
+ *
231
+ * The open room hash is derived through the same 250k PBKDF2 as the AES key
232
+ * (crypto.js `deriveOpen`), so a guess costs a full derivation rather than one
233
+ * SHA-256. But `SALT` is a single global constant, so the table an attacker
234
+ * builds is built ONCE and then opens every unlocked session of that length,
235
+ * for every user, for as long as the salt stands. That is the number that
236
+ * matters, and at six characters it is ~12 minutes on 100 rented GPUs:
237
+ *
238
+ * 6 chars 7.3e8 keys 12 min on 100 GPUs 29.4 bits
239
+ * 10 chars 5.9e14 keys 19 years on 100 GPUs 49.1 bits
240
+ * 16 chars 5.3e23 keys out of reach 78.5 bits
241
+ *
242
+ * Six is therefore not a preference, it is a defect, and no iteration count
243
+ * fixes a keyspace. Typing cost is paid once per session and only when someone
244
+ * declines the QR code and the copied link, both of which are in front of it.
245
+ */
246
+ LENGTH: 10,
247
+ LONG_LENGTH: 16, // "high security" option, PRD §7.3
248
+
249
+ /**
250
+ * An installed app links a device by QR or copied link, so it does not pay the
251
+ * typing cost at all — and it is the surface running all day reading every
252
+ * copy, which is the one that should take the longer option.
253
+ */
254
+ DEFAULT_LONG: IS_DESKTOP,
209
255
  };
210
256
 
211
257
  export const CRYPTO = {
@@ -214,29 +260,34 @@ export const CRYPTO = {
214
260
  ROOM_HASH_BYTES: 16,
215
261
 
216
262
  /**
217
- * Locked sessions — see LOCK below and core/crypto.js `deriveLocked`.
218
- *
219
- * The salt is a PREFIX, completed with the share key: the key is random per
220
- * session, which is exactly what a salt is for. The open-session salt above
221
- * is one global constant, so a single precomputed table covers every user on
222
- * earth; here an attacker has to build one per key.
263
+ * One PBKDF2 run, two outputs — see crypto.js `deriveOpen`.
223
264
  *
224
- * 600k rather than 250k because the threat is different. An open session's
225
- * secret is a 29-bit key an attacker has to guess; a locked session's secret
226
- * is a PIN held by someone who may ALREADY have the link, so the PIN is the
227
- * whole defence and every doubling of the iteration count is a doubling of
228
- * their cost. It is paid once per session, behind the "unlocking" state.
265
+ * The room hash used to be `SHA-256("realtimeclipboard:" + KEY)`: unsalted,
266
+ * unstretched, and the one value the relay holds by construction. Anyone with
267
+ * it could sweep the whole 6-character keyspace in 0.07s and read back the
268
+ * key, which made the 250k iterations on the AES key worth exactly one
269
+ * derivation to an attacker. Deriving both from the same PRK costs nothing —
270
+ * that PBKDF2 was already being awaited before the connection opened.
229
271
  */
230
- LOCK_SALT: "realtimeclipboard-lock-v1:",
231
- LOCK_ITERATIONS: 600_000,
272
+ OPEN_INFO: {
273
+ AES: "realtimeclipboard/aes",
274
+ ROOM: "realtimeclipboard/room",
275
+ },
232
276
 
233
277
  /**
234
- * HKDF info strings: one PBKDF2 run, three independent outputs.
278
+ * The lock salt is a PREFIX, completed with the share key — random per
279
+ * session, which is what a salt is for. The open-session salt above is one
280
+ * global constant, so a single precomputed table covers every user on earth;
281
+ * KEY.LENGTH is what has to make that table too large to build.
235
282
  *
236
- * Asking PBKDF2 itself for 64 bytes would cost DOUBLE — it reruns the full
237
- * iteration count per 32-byte output block, and OI-8 already flags PBKDF2
238
- * cost on a low-end Android. HKDF expansion is a couple of HMACs.
283
+ * 600k rather than 250k because the threat differs: a locked session's secret
284
+ * is a PIN held by someone who may already have the link, so the PIN is the
285
+ * whole defence and every doubling doubles their cost. Paid once per session.
239
286
  */
287
+ LOCK_SALT: "realtimeclipboard-lock-v1:",
288
+ LOCK_ITERATIONS: 600_000,
289
+
290
+ /** One PBKDF2 run, three independent outputs — see crypto.js `deriveLocked`. */
240
291
  LOCK_INFO: {
241
292
  AES: "realtimeclipboard-lock/aes",
242
293
  ROOM: "realtimeclipboard-lock/room",
@@ -247,66 +298,46 @@ export const CRYPTO = {
247
298
  /**
248
299
  * Locked sessions: the share key in the link, a PIN that never travels with it.
249
300
  *
250
- * The key is a bearer credential (PRD §7.1) and a link is a leaky thing — it
251
- * gets forwarded, screenshotted, pasted into a group chat. A locked session
252
- * adds a second secret that is never in the URL, never on disk, and never sent
253
- * to the relay, so holding the link is not sufficient to read the clipboard.
254
- *
255
- * MIN_PIN is 6 and the PIN is free-form rather than 4-6 digits, because against
256
- * someone who already has the link the key contributes nothing and the PIN is
257
- * the entire secret: a 4-digit PIN is ~13 bits, which is minutes of offline
258
- * guessing. The dialog states the number rather than an adjective.
301
+ * The key is a bearer credential (PRD §7.1) and links get forwarded,
302
+ * screenshotted and pasted into group chats. MIN_PIN is 6 and free-form rather
303
+ * than 4-6 digits because against someone who already has the link the key
304
+ * contributes nothing: a 4-digit PIN is ~13 bits, minutes of offline guessing.
259
305
  */
260
306
  export const LOCK = {
261
307
  MIN_PIN: 6,
262
308
 
263
309
  /**
264
- * Fragment marker: `#!ABCDEF`. That a session is locked is not a secret — the
265
- * PIN is — and the app has to know before it connects, so the flag rides in
266
- * the link. Leading rather than trailing: chat clients that trim punctuation
267
- * off a pasted URL trim the END of it.
310
+ * Fragment marker: `#!ABCDEF`. Leading rather than trailing — chat clients
311
+ * that trim punctuation off a pasted URL trim the END of it.
268
312
  */
269
313
  SIGIL: "!",
270
314
 
271
315
  /**
272
- * Sent once on creating a locked room, retained by the relay as the room's
273
- * last clip and replayed to every joiner — so a joiner can tell "wrong PIN"
274
- * from "first one here" by whether it decrypts. Receivers drop it instead of
275
- * rendering it. See core/crypto.js and the beacon note in main.js.
316
+ * Sent once on creating a locked room and replayed to every joiner, so a
317
+ * joiner can tell "wrong PIN" from "first one here" by whether it decrypts.
318
+ * Receivers drop it instead of rendering it.
276
319
  */
277
320
  BEACON: String.fromCharCode(0) + "realtimeclipboard-lock-v1",
278
321
 
279
322
  /**
280
- * Sent into the room being ABANDONED when the session is locked.
281
- *
282
- * Locking is a room change (see main.js): the lock flag is part of the room
283
- * name, so the locking device leaves for a new, locked room and everyone
284
- * else is simply left behind in the old one — connected, in sync with
285
- * nobody, with no way to tell that from a quiet afternoon. This sentinel is
286
- * the goodbye. A device that decrypts it closes its connection and says what
287
- * happened, which is the difference between being removed and being
288
- * mysteriously alone.
323
+ * Sent into the room being ABANDONED when a session is locked.
289
324
  *
290
- * A clip rather than a new frame type, for the same reason BEACON is one: it
291
- * needs no relay change, so it works against a deployed relay, and it is
292
- * sealed with the room key — the relay forwards a sentinel it cannot read.
325
+ * Locking is a room change: the locking device leaves for a new room and
326
+ * everyone else is left behind, connected, in sync with nobody, unable to tell
327
+ * that from a quiet afternoon. This sentinel is the goodbye.
293
328
  *
294
- * It does overwrite the room's retained last clip (backend `room.last`), and
295
- * that is deliberate: the retained copy is what a late joiner to the dead
296
- * room receives, so they are told the session moved instead of being handed
297
- * a clip from a session they are no longer part of.
329
+ * A clip rather than a new frame type, like BEACON — it needs no relay change
330
+ * and is sealed with the room key, so the relay forwards what it cannot read.
331
+ * It overwrites the room's retained last clip deliberately, so a late joiner
332
+ * to the dead room is told the session moved.
298
333
  */
299
334
  EVICT: String.fromCharCode(0) + "realtimeclipboard-lock-evict-v1",
300
335
 
301
336
  /**
302
- * How long the goodbye gets to leave the machine before the socket is torn
303
- * down under it.
304
- *
305
- * Not paranoia about WebSocket buffering — the SSE fallback batches upstream
306
- * frames into a POST and its close() drops whatever is still queued
307
- * (transport/sse.js), so closing in the same tick would send the sentinel to
308
- * nobody on exactly the network where the fallback is in use. A quarter of a
309
- * second, once, in a flow that is already opening a dialog.
337
+ * How long the goodbye gets to leave before the socket is torn down. Not
338
+ * paranoia about buffering: the SSE fallback batches upstream frames into a
339
+ * POST and its close() drops whatever is queued, so closing in the same tick
340
+ * would send the sentinel to nobody on exactly the network using the fallback.
310
341
  */
311
342
  EVICT_FLUSH_MS: 250,
312
343
  };
@@ -318,25 +349,18 @@ export const NET = {
318
349
  ICE_TIMEOUT_MS: 5_000, // then fall back to relay chunks (FR-7.6)
319
350
 
320
351
  /**
321
- * How long a transport gets to become usable before we give up on it.
322
- *
323
352
  * A blocked WebSocket frequently does NOT fail: an intercepting proxy accepts
324
- * the TCP connection, swallows the Upgrade, and leaves the socket hanging
325
- * with no open, no close and no error — forever. Without this timer the app
326
- * sits on "Connecting…" indefinitely and never tries the fallback, which is
327
- * the exact failure this whole path exists for.
353
+ * the TCP connection, swallows the Upgrade, and leaves the socket hanging with
354
+ * no open, no close and no error. Without this timer the app sits on
355
+ * "Connecting…" forever and never tries the fallback.
328
356
  *
329
- * Generous enough to survive a scale-to-zero cold start (PRD R2), which is
330
- * seconds rather than milliseconds.
357
+ * Generous enough to survive a scale-to-zero cold start (PRD R2).
331
358
  */
332
359
  PROBE_MS: 8_000,
333
360
 
334
361
  /**
335
- * Consecutive attempts that never became usable before switching transport.
336
- *
337
- * Two, not one: a single failure is far more often a cold start or a flaky
338
- * moment than a policy, and switching on it would put users on the slower
339
- * path for no reason. Two failures in a row is a proxy.
362
+ * Two, not one: a single failure is more often a cold start than a policy, and
363
+ * switching on it would put users on the slower path for no reason.
340
364
  */
341
365
  SWITCH_AFTER: 2,
342
366
 
@@ -351,38 +375,40 @@ export const NET = {
351
375
  export const POLL_OPTIONS = { "Off": 0, "500ms": 500, "1s": 1000, "2s": 2000 };
352
376
 
353
377
  /**
354
- * How much of the OS clipboard the app takes on itself.
378
+ * How far the sync reaches on THIS device. One ladder, three rungs, each adding
379
+ * exactly one thing to the one below:
355
380
  *
356
- * live — anything you copy anywhere is picked up when this window has
357
- * focus, and sent. The default, and what "shared clipboard" means.
358
- * manual — nothing leaves this machine until you paste it in here or press
359
- * Send. Receiving is unaffected.
381
+ * off — nothing leaves and nothing arrives. The editor is private again.
382
+ * manual — the session syncs in the app window; the OS clipboard is neither
383
+ * read nor written.
384
+ * live — the OS clipboard is wired to the room in both directions.
360
385
  *
361
- * Manual exists because "live" means every password, token and private
362
- * message you copy for any reason goes to every device in the session. That is
363
- * the point of the product, and it is also a lot of trust to extend
364
- * permanently — someone on a shared or work machine may want the sharing to be
365
- * a deliberate act.
386
+ * The stored strings are a compatibility surface: "live" and "manual" are on
387
+ * disk for every existing user, and relabelling the UI is free while renaming
388
+ * these silently resets the one preference where being wrong matters most.
389
+ * Change the labels in ui/features/syncMode.js instead — they read
390
+ * Off / App / Clipboard, naming the destination rather than a manner of working.
366
391
  */
367
392
  export const SYNC_MODES = {
368
- LIVE: "live",
393
+ OFF: "off",
369
394
  MANUAL: "manual",
395
+ LIVE: "live",
370
396
  };
371
397
  export const DEFAULT_SYNC_MODE = SYNC_MODES.LIVE;
372
398
 
399
+ /** Does this rung let the OS clipboard be read or written? */
400
+ export const bindsClipboard = (mode) => mode === SYNC_MODES.LIVE;
401
+
402
+ /** Does this rung put anything on the wire at all? */
403
+ export const sharesSession = (mode) => mode !== SYNC_MODES.OFF;
404
+
373
405
  /**
374
- * The project's own addresses: where the code is, and where it asks for help.
375
- *
376
- * Derived from OWNER/NAME rather than written out four times, for the same
377
- * reason RELAY_HTTP_URL is derived from RELAY_URL — a fork, a rename, or a
378
- * moved account is then one edit, and the four links cannot drift into pointing
379
- * at three different projects.
406
+ * Derived from OWNER/NAME rather than written out four times, so a fork, rename
407
+ * or moved account is one edit and the links cannot drift apart.
380
408
  *
381
- * Every link below was checked against a live GitHub on 2026-08-07 and returned
382
- * 200: the repo, the issue chooser, and the sponsors page. Re-check the sponsors
383
- * page if the account ever moves — a donate link that 404s costs more goodwill
384
- * than no donate link. The .github/FUNDING.yml button is the same destination
385
- * and has to move with it.
409
+ * Re-check the sponsors page if the account ever moves — a donate link that 404s
410
+ * costs more goodwill than no donate link, and .github/FUNDING.yml is the same
411
+ * destination and has to move with it.
386
412
  */
387
413
  export const REPO = {
388
414
  OWNER: "akshaynikhare",
@@ -393,14 +419,9 @@ export const LINKS = {
393
419
  REPO: `https://github.com/${REPO.OWNER}/${REPO.NAME}`,
394
420
  ISSUES: `https://github.com/${REPO.OWNER}/${REPO.NAME}/issues`,
395
421
  /**
396
- * The chooser, not a blank issue: "report" that lands on a list of other
397
- * people's bugs asks the user to find the button themselves, and a blank box
398
- * asks them to guess what we need.
399
- *
400
- * `/new/choose` picks between the bug and feature forms in
401
- * .github/ISSUE_TEMPLATE/ — which is also where the "do not paste your share
402
- * key" warning lives, and that warning is the single most valuable thing on
403
- * the page for this product.
422
+ * The chooser, not a blank issue: `/new/choose` picks between the forms in
423
+ * .github/ISSUE_TEMPLATE/, which is where the "do not paste your share key"
424
+ * warning lives — the single most valuable thing on the page for this product.
404
425
  */
405
426
  NEW_ISSUE: `https://github.com/${REPO.OWNER}/${REPO.NAME}/issues/new/choose`,
406
427
  SPONSOR: `https://github.com/sponsors/${REPO.OWNER}`,
@@ -412,3 +433,71 @@ export const IMAGES = {
412
433
  /** Named so a received screenshot does not land as "blob" on disk. */
413
434
  NAME_PREFIX: "clipboard-image",
414
435
  };
436
+
437
+ /**
438
+ * Google Analytics and AdSense.
439
+ *
440
+ * Empty by default and every consumer no-ops on empty, so a fork, a local
441
+ * checkout and a self-hosted deploy load no third-party script at all. Filling
442
+ * these in is the switch that turns tracking and ads on for a build.
443
+ *
444
+ * !! These IDs put third-party script in EVERY document, app.html included.
445
+ * That was a deliberate call and it costs the app a security property it used
446
+ * to have — docs/ARCHITECTURE.md §5 and src/ui/features/ads.js record what.
447
+ * Anything running in app.html can read location.hash and the decrypted
448
+ * clipboard in the DOM; contextual ad targeting reads page content by design.
449
+ *
450
+ * Adding an origin here means adding it to the CSP in _headers AND in every
451
+ * page's meta tag, which tools/check/site-check.mjs asserts agree.
452
+ */
453
+ export const GOOGLE = {
454
+ /** GA4 measurement ID, "G-XXXXXXXXXX". Admin → Data streams → your stream. */
455
+ GA4_ID: "G-259V6H3K5M",
456
+ /** AdSense publisher ID, "ca-pub-################". Account → Settings. */
457
+ ADSENSE_CLIENT: "ca-pub-6053041142492498",
458
+ /**
459
+ * Per-unit slot IDs, the 10-digit number AdSense prints as `data-ad-slot`
460
+ * when you create a unit. One per placement, and a unit renders nothing
461
+ * until its own ID is filled in.
462
+ */
463
+ ADSENSE_SLOTS: {
464
+ /** index.html, the 728×90 after "how it works". */
465
+ LEADERBOARD: "7038244690",
466
+ /** index.html, the 300×600 rail beside the FAQ. */
467
+ RAIL: "8299355473",
468
+ /** app.html, below the editor. */
469
+ APP: "6948680552",
470
+ },
471
+ };
472
+
473
+ /** Nothing loads unless the ID that drives it is present. */
474
+ export const analyticsEnabled = () => Boolean(GOOGLE.GA4_ID);
475
+ export const adsEnabled = () => Boolean(GOOGLE.ADSENSE_CLIENT);
476
+
477
+ /**
478
+ * Consent Mode v2 starts denied in these jurisdictions and stays denied until
479
+ * the CMP says otherwise; everywhere else the tags behave normally. EEA, plus
480
+ * the UK and Switzerland.
481
+ */
482
+ export const CONSENT_REGIONS = [
483
+ "AT", "BE", "BG", "CH", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GB",
484
+ "GR", "HR", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MT", "NL", "NO",
485
+ "PL", "PT", "RO", "SE", "SI", "SK",
486
+ ];
487
+
488
+ /**
489
+ * The page URL with the fragment removed, for `page_location`.
490
+ *
491
+ * !! app.html carries the share key in `location.hash`, and gtag's default
492
+ * `page_location` is `location.href`. The unmodified tag would therefore send
493
+ * the key to Google on the first page_view — the one thing this project
494
+ * promises never leaves the browser. Every gtag config passes this instead. !!
495
+ */
496
+ export const pageLocation = () =>
497
+ typeof location === "undefined" ? "" : location.origin + location.pathname + location.search;
498
+
499
+ export const GOOGLE_SRC = {
500
+ gtag: (id) => `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,
501
+ adsense: (client) =>
502
+ `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${encodeURIComponent(client)}`,
503
+ };