realtimeclipboard 0.6.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.
@@ -1,44 +1,38 @@
1
1
  /**
2
- * Every tunable constant. Nothing else in the app hard-codes a limit —
3
- * if you find a magic number elsewhere, it belongs here.
2
+ * Every tunable constant. A magic number anywhere else is a bug.
4
3
  */
5
4
 
6
- import { IS_DESKTOP } from "./native.js";
5
+ import { IS_INSTALLED, IS_DESKTOP } from "./native.js";
7
6
 
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.
7
+ // Guarded: node tests import this and have no `location`; a bare reference
8
+ // throws at import time and takes the whole graph down.
10
9
  const IS_LOCAL = typeof location !== "undefined" &&
11
10
  ["localhost", "127.0.0.1", "[::1]"].includes(location.hostname);
12
11
 
13
12
  /**
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.
13
+ * Must be wss:// in production — a browser refuses ws:// from https:// as mixed
14
+ * content. Localhost is exempt. Self-hosters change this one line.
17
15
  */
18
16
  export const DEFAULT_RELAY_URL = "wss://realtimeclipboard.fastapicloud.dev";
19
17
  const LOCAL_RELAY_URL = "ws://127.0.0.1:8000";
20
18
 
21
19
  /**
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.
20
+ * Here rather than in core/storage.js so this module can read a setting without
21
+ * importing storage.js, which imports this one. The cycle works until someone
22
+ * moves a line to module scope.
26
23
  */
27
24
  export const STORAGE_PREFIX = "realtimeclipboard.";
28
25
  const RELAY_KEY = "relayUrl";
29
26
 
30
- /**
31
- * `http(s)://` is accepted and converted, because that is what a person copies
32
- * out of a browser bar when IT gives them an address.
33
- */
27
+ /** `http(s)://` is converted, because that is what IT hands someone to paste. */
34
28
  export function normaliseRelay(raw) {
35
29
  if (!raw || typeof raw !== "string") return null;
36
30
  try {
37
31
  const u = new URL(raw.trim());
38
32
  const scheme = { "http:": "ws:", "https:": "wss:", "ws:": "ws:", "wss:": "wss:" }[u.protocol];
39
33
  if (!scheme) return null;
40
- // Path, query and hash are meaningless here — the routes are fixed (/ws,
41
- // /sse, /pub, /health) and a trailing slash would produce "//ws/<room>".
34
+ // The routes are fixed (/ws, /sse, /pub, /health) and a trailing slash would
35
+ // produce "//ws/<room>".
42
36
  return `${scheme}//${u.host}`;
43
37
  } catch { return null; }
44
38
  }
@@ -49,14 +43,12 @@ const stored = () => {
49
43
  };
50
44
 
51
45
  /**
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.
46
+ * For builds that are deployed rather than visited — an MSI transform, a config
47
+ * profile, a desktop shell. Beats the stored setting because the deployment is
48
+ * more current; main.js persists it so it survives the next launch.
56
49
  *
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.
50
+ * Not an attack surface on the hosted site: the CSP pins `connect-src` to this
51
+ * origin and the default relay, so `?relay=` elsewhere cannot connect.
60
52
  */
61
53
  const fromQuery = () => {
62
54
  try { return new URLSearchParams(location.search).get("relay"); }
@@ -73,32 +65,27 @@ export const RELAY_IS_CUSTOM = RELAY_URL !== DEFAULT_RELAY_URL && RELAY_URL !==
73
65
 
74
66
  /**
75
67
  * 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.
68
+ * hostname so IT allowlists one domain (PRD §5.4); derived so the two cannot drift.
78
69
  */
79
70
  export const RELAY_HTTP_URL = RELAY_URL.replace(/^ws/i, "http");
80
71
 
81
72
  /**
82
- * ws — WebSocket. Lower latency, one connection, the default.
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).
73
+ * ws — lower latency, one connection, the default.
74
+ * sse — SSE down + fetch POST up (PRD §4.3 R3). No Upgrade, so it survives the
75
+ * TLS-inspecting proxies that eat WebSockets on corporate networks (§5.4).
86
76
  *
87
- * Both carry the identical envelopes from §6 — see transport/protocol.js.
77
+ * Both carry the identical envelopes — see transport/protocol.js.
88
78
  */
89
79
  export const TRANSPORT = { WS: "ws", SSE: "sse" };
90
80
 
91
81
  /**
92
- * Clip size, derived — not chosen.
82
+ * Clip size, derived — not chosen. A clip is UTF-8, AES-GCM sealed, base64'd and
83
+ * JSON-wrapped, and the relay drops any frame over MAX_FRAME_BYTES, so the real
84
+ * limit is a byte budget that a character count only approximates.
93
85
  *
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.
97
- *
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.
86
+ * MAX_CHARS was once 50,000 — 66 KB against a 32 KB frame — because "32 KB" had
87
+ * been transcribed as a character count. Every clip over ~24 KB was encrypted,
88
+ * sent and dropped, the rejection arriving long after the UI said it went.
102
89
  * tests/unit/clipsize.mjs proves the derivation.
103
90
  */
104
91
  const RELAY_FRAME_BYTES = 32 * 1024; // backend/main.py MAX_FRAME_BYTES
@@ -111,24 +98,20 @@ const CLIP_MAX_BYTES = Math.floor(
111
98
  );
112
99
 
113
100
  /**
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.
101
+ * Exported beside the limit it is measured against so the editor and the send
102
+ * path cannot disagree — `String.length` counts UTF-16 units, not wire bytes.
117
103
  */
118
104
  export const textBytes = (s) => new TextEncoder().encode(s).length;
119
105
 
120
106
  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.
123
107
  MAX_BYTES: CLIP_MAX_BYTES, // the wire limit — authoritative
124
- MAX_CHARS: Math.floor(CLIP_MAX_BYTES / 1000) * 1000, // the friendly one, for the counter
108
+ MAX_CHARS: Math.floor(CLIP_MAX_BYTES / 1000) * 1000, // shown in the counter; 24,000 CJK chars are 72,000 bytes
125
109
  SUPPRESS_MS: 1500, // loop-suppression window after applying a remote clip (FR-2.6)
126
110
 
127
111
  /**
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.
112
+ * Generous because the text is already on the far screen via the stream — this
113
+ * only decides when it becomes a clip. A mid-sentence pause is 300-500 ms, and
114
+ * less would fragment one sentence into six history entries.
132
115
  */
133
116
  COMMIT_IDLE_MS: 800,
134
117
 
@@ -136,42 +119,35 @@ export const TEXT = {
136
119
  STREAM_THROTTLE_MS: 100,
137
120
 
138
121
  /**
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.
122
+ * Above this, typing syncs on commit only. A stream frame carries the WHOLE
123
+ * text, which is what lets this exist without diffs or OT — a trade that holds
124
+ * only while the text is small. Invisible, because big text is pasted.
145
125
  */
146
126
  STREAM_MAX_BYTES: 4096,
147
127
 
148
128
  /**
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.
129
+ * How long a local copy outranks an arriving clip. "I copied something, went to
130
+ * paste it, and it was gone" is the moment that hurts, so inside this window
131
+ * the arriving clip is queued and offered instead of written.
152
132
  */
153
133
  LOCAL_COPY_GRACE_MS: 10_000,
154
134
  };
155
135
 
156
136
  /**
157
- * Pastejacking — see clipboard/guard.js for what is actually checked.
137
+ * Pastejacking — clipboard/guard.js does the checking. The room is joinable by
138
+ * anyone holding the key and an arriving clip reaches the OS clipboard with no
139
+ * gesture, so a peer chooses what you paste into your next terminal.
158
140
  *
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.
141
+ * ENABLED governs only the heuristic that demands a click. Stripping the trailing
142
+ * newline and the invisible characters is unconditional: not a policy, the
143
+ * difference between a clip that pastes and a clip that runs.
167
144
  */
168
145
  export const PASTE_GUARD = {
169
146
  ENABLED: true,
170
147
 
171
148
  /**
172
149
  * 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.
150
+ * logfile is a lot of backtracking for a case this does not defend against.
175
151
  */
176
152
  MAX_SCAN_CHARS: 8_192,
177
153
  };
@@ -181,10 +157,9 @@ export const FILES = {
181
157
  MAX_COUNT: 20, // per session, memory only (FR-7.7)
182
158
 
183
159
  /**
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.
160
+ * Peer-supplied, so bounded like every other piece of peer metadata. Long
161
+ * enough that no real name truncates, short enough that a hostile one is a
162
+ * tile rather than a layout. files/registry.js `safeName` applies it.
188
163
  */
189
164
  MAX_NAME_CHARS: 120,
190
165
  THUMB_PX: 160, // longest edge (FR-7.2)
@@ -192,31 +167,25 @@ export const FILES = {
192
167
 
193
168
  /**
194
169
  * 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.
170
+ * cannot reuse it — base64 plus tag and envelope inflate 32 KB into ~44 KB,
171
+ * past the relay's own cap — so files/chunker.js derives RELAY_CHUNK_BYTES
172
+ * from this rather than hand-tuning a second number.
199
173
  */
200
174
  CHUNK_BYTES: 32 * 1024,
201
175
 
202
176
  /**
203
177
  * 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.
206
- *
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.
178
+ * so neither believes in a transfer the other abandoned. Its own constant
179
+ * rather than a multiple of the ICE timeout: that coupling meant shortening
180
+ * the ICE race in a test also shortened how long a human had to answer.
210
181
  */
211
182
  REQUEST_TIMEOUT_MS: 15_000,
212
183
 
213
184
  /**
214
- * How long a closed data channel gets to confirm the close.
215
- *
216
- * bufferedAmount reaching zero only means SCTP accepted the bytes, not that
217
- * the peer has them. The stream reset dc.close() sends is ordered behind the
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.
185
+ * bufferedAmount hitting zero means SCTP accepted the bytes, not that the peer
186
+ * has them. dc.close()'s stream reset is ordered behind the queued data, so the
187
+ * close event is the nearest thing to a delivery signal. Bounded, because a
188
+ * wedged association must not hold a transfer open.
220
189
  */
221
190
  CHANNEL_CLOSE_MS: 1_000,
222
191
  };
@@ -225,33 +194,45 @@ export const KEY = {
225
194
  // Crockford-ish: no 0/O, no 1/I/L. Ambiguity here becomes a support ticket.
226
195
  ALPHABET: "23456789ABCDEFGHJKMNPQRSTVWXYZ",
227
196
  /**
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:
197
+ * Ten, not the six PRD D3 chose — that argument was sound about phone typing
198
+ * and wrong about arithmetic. The open room hash goes through the same 250k
199
+ * PBKDF2 as the AES key, but `SALT` is one global constant, so the table an
200
+ * attacker builds is built ONCE and opens every unlocked session of that
201
+ * length, for everyone, for as long as the salt stands:
237
202
  *
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
203
+ * 6 chars 7.3e8 keys 12 min on 100 GPUs 29.4 bits
204
+ * 10 chars 5.9e14 keys 19 years on 100 GPUs 49.1 bits
205
+ * 16 chars 5.3e23 keys out of reach 78.5 bits
241
206
  *
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.
207
+ * So six is a defect, not a preference, and no iteration count fixes a
208
+ * keyspace. Typing is paid once per session, and only by someone who declined
209
+ * both the QR code and the copied link.
245
210
  */
246
211
  LENGTH: 10,
247
212
  LONG_LENGTH: 16, // "high security" option, PRD §7.3
248
213
 
249
214
  /**
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.
215
+ * The floor for a key we will HASH, as opposed to one we produce.
216
+ *
217
+ * Validation is deliberately more permissive than generation — a key from an
218
+ * older build has to keep working, and no build ever emitted fewer than six.
219
+ * But it used to accept four, which is ~19.6 bits: the whole keyspace is
220
+ * 810,000 rooms, and the relay will happily route any of them. Someone typing
221
+ * `#F5H4` into the address bar got a session that a laptop can enumerate,
222
+ * with nothing on screen saying so.
223
+ *
224
+ * Six is the floor rather than ten because six is what earlier builds handed
225
+ * out, and stranding those links is a worse failure than a weak room the
226
+ * holder chose. New keys are still KEY.LENGTH.
253
227
  */
254
- DEFAULT_LONG: IS_DESKTOP,
228
+ MIN_LENGTH: 6,
229
+ MAX_LENGTH: 32,
230
+
231
+ /**
232
+ * An installed app links by QR or link and never pays the typing cost — and it
233
+ * is the surface reading every copy all day, so it takes the longer option.
234
+ */
235
+ DEFAULT_LONG: IS_INSTALLED,
255
236
  };
256
237
 
257
238
  export const CRYPTO = {
@@ -262,12 +243,11 @@ export const CRYPTO = {
262
243
  /**
263
244
  * One PBKDF2 run, two outputs — see crypto.js `deriveOpen`.
264
245
  *
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.
246
+ * The room hash used to be unsalted, unstretched SHA-256, and it is the one
247
+ * value the relay holds by construction: anyone with it could sweep the whole
248
+ * 6-character keyspace in 0.07s and read back the key, making the 250k
249
+ * iterations worth one derivation to an attacker. Sharing the PRK costs
250
+ * nothing — that PBKDF2 was already awaited before the connection opened.
271
251
  */
272
252
  OPEN_INFO: {
273
253
  AES: "realtimeclipboard/aes",
@@ -275,14 +255,13 @@ export const CRYPTO = {
275
255
  },
276
256
 
277
257
  /**
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.
258
+ * A PREFIX, completed with the share key — so it is random per session, which
259
+ * is what a salt is for. The open-session salt above is one global constant,
260
+ * and KEY.LENGTH is what keeps its precomputed table too large to build.
282
261
  *
283
262
  * 600k rather than 250k because the threat differs: a locked session's secret
284
263
  * 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.
264
+ * whole defence and every doubling doubles their cost.
286
265
  */
287
266
  LOCK_SALT: "realtimeclipboard-lock-v1:",
288
267
  LOCK_ITERATIONS: 600_000,
@@ -298,46 +277,41 @@ export const CRYPTO = {
298
277
  /**
299
278
  * Locked sessions: the share key in the link, a PIN that never travels with it.
300
279
  *
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.
280
+ * MIN_PIN is 6 and free-form rather than 4-6 digits because against someone who
281
+ * already has the link — and links get forwarded, screenshotted and pasted into
282
+ * group chats — the key contributes nothing. A 4-digit PIN is ~13 bits.
305
283
  */
306
284
  export const LOCK = {
307
285
  MIN_PIN: 6,
308
286
 
309
287
  /**
310
- * Fragment marker: `#!ABCDEF`. Leading rather than trailing — chat clients
311
- * that trim punctuation off a pasted URL trim the END of it.
288
+ * Fragment marker: `#!ABCDEF`. Leading rather than trailing — chat clients that
289
+ * trim punctuation off a pasted URL trim the END of it.
312
290
  */
313
291
  SIGIL: "!",
314
292
 
315
293
  /**
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.
294
+ * Sent on creating a locked room and replayed to joiners, so a joiner can tell
295
+ * "wrong PIN" from "first one here" by whether it decrypts. Receivers drop it.
319
296
  */
320
297
  BEACON: String.fromCharCode(0) + "realtimeclipboard-lock-v1",
321
298
 
322
299
  /**
323
- * Sent into the room being ABANDONED when a session is locked.
324
- *
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.
300
+ * The goodbye sent into the room being ABANDONED. Locking is a room change: the
301
+ * locking device leaves and everyone else is left connected, in sync with
302
+ * nobody, unable to tell that from a quiet afternoon.
328
303
  *
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.
304
+ * A clip rather than a new frame type, like BEACON — no relay change, and
305
+ * sealed with the room key. It overwrites the room's retained last clip
306
+ * deliberately, so a late joiner is told the session moved.
333
307
  */
334
308
  EVICT: String.fromCharCode(0) + "realtimeclipboard-lock-evict-v1",
335
309
 
336
310
  /**
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.
311
+ * How long the goodbye gets to leave. Not buffering paranoia: the SSE fallback
312
+ * batches upstream frames into a POST and close() drops the queue, so closing
313
+ * in the same tick would send the sentinel to nobody on exactly the network
314
+ * that needs the fallback.
341
315
  */
342
316
  EVICT_FLUSH_MS: 250,
343
317
  };
@@ -349,12 +323,10 @@ export const NET = {
349
323
  ICE_TIMEOUT_MS: 5_000, // then fall back to relay chunks (FR-7.6)
350
324
 
351
325
  /**
352
- * A blocked WebSocket frequently does NOT fail: an intercepting proxy accepts
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.
356
- *
357
- * Generous enough to survive a scale-to-zero cold start (PRD R2).
326
+ * A blocked WebSocket frequently does not fail: the proxy accepts the TCP
327
+ * connection, swallows the Upgrade, and leaves the socket hanging with no open,
328
+ * no close and no error. Without this the app sits on "Connecting…" forever and
329
+ * never tries the fallback. Generous enough for a cold start (PRD R2).
358
330
  */
359
331
  PROBE_MS: 8_000,
360
332
 
@@ -375,19 +347,16 @@ export const NET = {
375
347
  export const POLL_OPTIONS = { "Off": 0, "500ms": 500, "1s": 1000, "2s": 2000 };
376
348
 
377
349
  /**
378
- * How far the sync reaches on THIS device. One ladder, three rungs, each adding
379
- * exactly one thing to the one below:
350
+ * How far the sync reaches on THIS device. One ladder, each rung adding exactly
351
+ * one thing to the one below:
380
352
  *
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.
353
+ * off — nothing leaves and nothing arrives.
354
+ * manual — the session syncs in the app window; the OS clipboard is untouched.
384
355
  * live — the OS clipboard is wired to the room in both directions.
385
356
  *
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.
357
+ * The stored strings are a compatibility surface: they are on disk for every
358
+ * existing user, so renaming them silently resets the one preference where being
359
+ * wrong matters most. Relabel in ui/features/syncMode.js instead.
391
360
  */
392
361
  export const SYNC_MODES = {
393
362
  OFF: "off",
@@ -396,6 +365,21 @@ export const SYNC_MODES = {
396
365
  };
397
366
  export const DEFAULT_SYNC_MODE = SYNC_MODES.LIVE;
398
367
 
368
+ /**
369
+ * Appearance. `SYSTEM` means "do not decide" — no attribute is stamped and
370
+ * styles/tokens.css answers `prefers-color-scheme` on its own, which is also
371
+ * what renders before any of this JavaScript has run.
372
+ *
373
+ * The stored strings are a compatibility surface like SYNC_MODES: relabel the
374
+ * UI, never the values.
375
+ */
376
+ export const THEMES = {
377
+ SYSTEM: "system",
378
+ LIGHT: "light",
379
+ DARK: "dark",
380
+ };
381
+ export const DEFAULT_THEME = THEMES.SYSTEM;
382
+
399
383
  /** Does this rung let the OS clipboard be read or written? */
400
384
  export const bindsClipboard = (mode) => mode === SYNC_MODES.LIVE;
401
385
 
@@ -403,28 +387,71 @@ export const bindsClipboard = (mode) => mode === SYNC_MODES.LIVE;
403
387
  export const sharesSession = (mode) => mode !== SYNC_MODES.OFF;
404
388
 
405
389
  /**
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.
390
+ * The public address of the hosted app, for the one job `location` cannot do:
391
+ * build a link for somebody else to open.
392
+ *
393
+ * The desktop shell serves this tree from `http://tauri.localhost` and `cli/`
394
+ * has no `location` at all, so a share link built from the current URL there
395
+ * names a host that resolves in one process and nowhere else — the copied link
396
+ * and the QR code both pointed somewhere no other device could reach.
397
+ * keys.shareLink() falls back to this; a browser still links to itself, so a
398
+ * localhost dev pair and a self-hosted deploy keep pointing at themselves.
408
399
  *
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.
400
+ * `/app`, not `/app.html`: the extensionless path is what the deploy publishes,
401
+ * and Pages 308s the `.html` form onto it through a redirect nobody needs.
402
+ */
403
+ const SITE_ORIGIN = "https://realtimeclipboard.com";
404
+
405
+ export const SITE = {
406
+ ORIGIN: SITE_ORIGIN,
407
+ HOST: SITE_ORIGIN.replace(/^https?:\/\//, ""), // what the guide tells you to type
408
+ APP_URL: `${SITE_ORIGIN}/app`,
409
+ /** The published contact address — src/pages/contact/ and the AdSense
410
+ publisher identity name it, so a change here is a change there too. */
411
+ EMAIL: "info@realtimeclipboard.com",
412
+
413
+ /**
414
+ * `?qr=1` opens the share code as soon as the session is up.
415
+ *
416
+ * A QUERY parameter and not part of the fragment, deliberately: the fragment
417
+ * is the key, its grammar is a compatibility surface every existing share
418
+ * link depends on, and "show a QR" is a request about this visit rather than
419
+ * a property of the session. It rides beside ?relay= for the same reason.
420
+ *
421
+ * It exists so a surface with no way to draw one — the VS Code extension, the
422
+ * CLI — can hand the job to the app instead of growing its own renderer.
423
+ */
424
+ QR_PARAM: "qr",
425
+ };
426
+
427
+ /**
428
+ * Derived rather than written out four times, so a fork or rename is one edit.
429
+ * If the account moves, .github/FUNDING.yml is the same destination and has to
430
+ * move with it — a donate link that 404s costs more than no donate link.
412
431
  */
413
432
  export const REPO = {
414
433
  OWNER: "akshaynikhare",
415
434
  NAME: "RealtimeClipboard",
416
435
  };
417
436
 
437
+ const GITHUB = `https://github.com/${REPO.OWNER}/${REPO.NAME}`;
438
+
418
439
  export const LINKS = {
419
- REPO: `https://github.com/${REPO.OWNER}/${REPO.NAME}`,
420
- ISSUES: `https://github.com/${REPO.OWNER}/${REPO.NAME}/issues`,
440
+ REPO: GITHUB,
441
+ ISSUES: `${GITHUB}/issues`,
421
442
  /**
422
443
  * 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.
444
+ * .github/ISSUE_TEMPLATE/, where the "do not paste your share key" warning is.
425
445
  */
426
- NEW_ISSUE: `https://github.com/${REPO.OWNER}/${REPO.NAME}/issues/new/choose`,
446
+ NEW_ISSUE: `${GITHUB}/issues/new/choose`,
427
447
  SPONSOR: `https://github.com/sponsors/${REPO.OWNER}`,
448
+ /**
449
+ * The full changelog for a reader who is NOT in this origin. The web app links
450
+ * its own precached copy — offline, and the same file the build shipped — but
451
+ * the desktop shell's copy sits on `tauri.localhost`, an address that means
452
+ * nothing in the browser the link opens in. See ui/features/whatsNew.js.
453
+ */
454
+ CHANGELOG: `${GITHUB}/blob/main/CHANGELOG.md`,
428
455
  };
429
456
 
430
457
  export const IMAGES = {
@@ -435,24 +462,30 @@ export const IMAGES = {
435
462
  };
436
463
 
437
464
  /**
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.
465
+ * The phone breakpoint. CSS cannot read this file, so every media query in
466
+ * `styles/` is matched to it by hand and this is the copy the JavaScript uses.
467
+ * Three modules each carried their own string of it, and a module that
468
+ * disagrees with the stylesheet renders for a layout that is not on screen.
469
+ */
470
+ const NARROW_MAX = 900;
471
+
472
+ export const LAYOUT = {
473
+ NARROW_MAX,
474
+ NARROW_MQ: `(max-width:${NARROW_MAX}px)`,
475
+ };
476
+
477
+ /**
478
+ * Google Analytics and AdSense. Empty by default and every consumer no-ops on
479
+ * empty, so a fork or self-hosted deploy loads no third-party script at all.
443
480
  *
444
- * !! Analytics loads everywhere; ADSENSE loads on the 19 crawlable pages and
445
- * never in app.html, which holds the share key in its fragment and decrypted
446
- * clipboard text in its DOM. The difference is who controls what gets reported:
447
- * gtag takes `page_location` from us, so `pageLocation()` below strips the key
448
- * out of it, while the ad tag reports the URL itself with no override. That
449
- * boundary is enforced by app.html's own meta CSP naming no ad origin — not by
450
- * this file, and not by anyone remembering. docs/ARCHITECTURE.md §5 and
451
- * src/ui/features/ads.js have the argument. !!
481
+ * !! Both load everywhere, app.html included (the no-ads-in-the-app rule was
482
+ * removed 2026-08-09). The ad tag reports the page URL itself with no override,
483
+ * so what survives is TIMING: ui/features/ads.js must never load it while the
484
+ * share key is still in `location.hash`, and waits for keys.clearUrl(). gtag
485
+ * takes `page_location` from `pageLocation()` below, which strips it too. !!
452
486
  *
453
- * Adding an origin means adding it to the CSP in _headers AND in the crawlable
454
- * pages' meta tags, which tools/check/site-check.mjs asserts agree — and
455
- * leaving app.html's alone, which it also asserts.
487
+ * Adding an origin means adding it to the CSP in _headers AND every page's meta
488
+ * tag — app.html's included — which tools/check/site-check.mjs asserts agree.
456
489
  */
457
490
  export const GOOGLE = {
458
491
  /** GA4 measurement ID, "G-XXXXXXXXXX". Admin → Data streams → your stream. */
@@ -460,23 +493,62 @@ export const GOOGLE = {
460
493
  /** AdSense publisher ID, "ca-pub-################". Account → Settings. */
461
494
  ADSENSE_CLIENT: "ca-pub-6053041142492498",
462
495
  /**
463
- * Per-unit slot IDs, the 10-digit number AdSense prints as `data-ad-slot`
464
- * when you create a unit. One per placement, and a unit renders nothing
465
- * until its own ID is filled in.
496
+ * `data-ad-slot` per placement. A unit renders nothing until its ID is filled.
466
497
  */
467
498
  ADSENSE_SLOTS: {
468
499
  /** index.html, the 728×90 after "how it works". */
469
500
  LEADERBOARD: "7038244690",
470
501
  /** index.html, the 300×600 rail beside the FAQ. */
471
502
  RAIL: "8299355473",
472
- /**
473
- * There is deliberately no APP slot. A unit exists in the AdSense account
474
- * for it (6948680552) and is left unused: app.html carries the share key in
475
- * its fragment and decrypted clipboard text in its DOM, and AdSense reports
476
- * the page URL with no way to strip it. src/ui/features/ads.js has the
477
- * argument; the app's own CSP is what enforces it.
478
- */
503
+ /** app.html, under the editor — mounted only once the key has left the URL. */
504
+ APP: "6948680552",
505
+ },
506
+ };
507
+
508
+ /**
509
+ * The app's ad slot, independent of who fills it.
510
+ *
511
+ * Deliberately not under GOOGLE: the slot is reserved and collapsed by
512
+ * ui/features/ads.js, which dispatches between networks and must not know
513
+ * about any of them. That separation is what lets tests/unit/static-check.mjs
514
+ * assert AdSense identifiers appear in one module only.
515
+ */
516
+ export const AD = {
517
+ /**
518
+ * What the slot asks for, per layout.
519
+ *
520
+ * NARROW is requested as a FIXED unit rather than a responsive one, because
521
+ * the slot sits `flex:none` under an editor that is `flex:1`: every pixel it
522
+ * takes comes straight out of the textarea. `data-full-width-responsive`
523
+ * answered a 390px phone with a ~300px creative and left no editor at all.
524
+ * WIDE is the responsive unit's usual size and is only what the placeholder
525
+ * claims — that column is drag-resizable, so it is not requested as a fixed
526
+ * one.
527
+ */
528
+ UNIT: {
529
+ NARROW: { W: 320, H: 50 },
530
+ WIDE: { W: 728, H: 90 },
479
531
  },
532
+ /**
533
+ * How long the slot holds its reserved height before giving it back.
534
+ *
535
+ * An ad that never arrives — blocked, offline, or unfilled — used to leave an
536
+ * empty box above the status bar for the life of the session. Long enough
537
+ * that a slow fill is not thrown away, short enough that nobody stares at a
538
+ * hole.
539
+ */
540
+ SETTLE_MS: 4000,
541
+ /**
542
+ * How long a unit that reported "filled" gets to actually paint, and the
543
+ * height below which we conclude it did not.
544
+ *
545
+ * "filled" is the auction's answer, not the page's: an account still in
546
+ * review reports filled and renders nothing, which held the reserved 90px
547
+ * open for the whole session. The smallest unit requested here is 320x50, so
548
+ * anything under a couple of dozen pixels rendered nothing.
549
+ */
550
+ RENDER_GRACE_MS: 1200,
551
+ MIN_RENDERED_PX: 24,
480
552
  };
481
553
 
482
554
  /** Nothing loads unless the ID that drives it is present. */
@@ -484,9 +556,8 @@ export const analyticsEnabled = () => Boolean(GOOGLE.GA4_ID);
484
556
  export const adsEnabled = () => Boolean(GOOGLE.ADSENSE_CLIENT);
485
557
 
486
558
  /**
487
- * Consent Mode v2 starts denied in these jurisdictions and stays denied until
488
- * the CMP says otherwise; everywhere else the tags behave normally. EEA, plus
489
- * the UK and Switzerland.
559
+ * Consent Mode v2 starts denied here and stays denied until the CMP says
560
+ * otherwise; elsewhere the tags behave normally. EEA, plus UK and Switzerland.
490
561
  */
491
562
  export const CONSENT_REGIONS = [
492
563
  "AT", "BE", "BG", "CH", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GB",
@@ -495,15 +566,22 @@ export const CONSENT_REGIONS = [
495
566
  ];
496
567
 
497
568
  /**
498
- * The page URL with the fragment removed, for `page_location`.
499
- *
500
569
  * !! app.html carries the share key in `location.hash`, and gtag's default
501
- * `page_location` is `location.href`. The unmodified tag would therefore send
502
- * the key to Google on the first page_view — the one thing this project
503
- * promises never leaves the browser. Every gtag config passes this instead. !!
570
+ * `page_location` is `location.href` — the unmodified tag would send the key to
571
+ * Google on the first page_view. Every gtag config passes this instead. !!
504
572
  */
505
- export const pageLocation = () =>
506
- typeof location === "undefined" ? "" : location.origin + location.pathname + location.search;
573
+ export const pageLocation = () => {
574
+ if (typeof location === "undefined") return "";
575
+ /* The desktop webview answers at `tauri.localhost` on Windows and
576
+ `tauri://localhost` elsewhere — two hostnames for one surface, neither of
577
+ them ours, both landing in the same property as the real /app. SITE is the
578
+ public address, and `/app` rather than `/app.html` because that is what the
579
+ web publishes; the desktop build skips the pretty-URL rewrite, so its
580
+ on-disk name is not the name a report should show. Which surface a hit came
581
+ from is `rtc_surface`, not the hostname. */
582
+ if (IS_DESKTOP) return `${SITE.ORIGIN}/app`;
583
+ return location.origin + location.pathname + location.search;
584
+ };
507
585
 
508
586
  export const GOOGLE_SRC = {
509
587
  gtag: (id) => `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,