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.
- package/README.md +4 -4
- package/cli/realtimeclipboard.mjs +32 -70
- package/cli/session.mjs +134 -0
- package/package.json +14 -5
- package/src/core/README.md +1 -1
- package/src/core/bus.js +5 -1
- package/src/core/config.js +283 -205
- package/src/core/crypto.js +13 -2
- package/src/core/keys.js +72 -4
- package/src/core/native.js +59 -15
- package/src/core/state.js +33 -2
- package/src/core/storage.js +16 -0
- package/src/core/surface.js +84 -0
package/src/core/config.js
CHANGED
|
@@ -1,44 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Every tunable constant.
|
|
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:
|
|
9
|
-
//
|
|
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
|
|
15
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* a
|
|
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
|
-
//
|
|
41
|
-
//
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
*
|
|
58
|
-
* origin and the default relay, so
|
|
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
|
|
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 —
|
|
83
|
-
* sse —
|
|
84
|
-
*
|
|
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
|
|
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
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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
|
-
*
|
|
115
|
-
*
|
|
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
|
|
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
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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.
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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 —
|
|
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
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* a
|
|
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
|
|
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
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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
|
|
205
|
-
*
|
|
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
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
|
239
|
-
* 10 chars 5.9e14 keys
|
|
240
|
-
* 16 chars 5.3e23 keys
|
|
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
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
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
|
-
|
|
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
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
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
|
-
*
|
|
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
|
|
317
|
-
*
|
|
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
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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 —
|
|
330
|
-
*
|
|
331
|
-
*
|
|
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
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
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
|
|
353
|
-
*
|
|
354
|
-
* no
|
|
355
|
-
*
|
|
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,
|
|
379
|
-
*
|
|
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.
|
|
382
|
-
* manual — the session syncs in the app window; the OS clipboard is
|
|
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:
|
|
387
|
-
*
|
|
388
|
-
*
|
|
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
|
-
*
|
|
407
|
-
*
|
|
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
|
-
*
|
|
410
|
-
*
|
|
411
|
-
|
|
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:
|
|
420
|
-
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/,
|
|
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:
|
|
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
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
|
|
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
|
-
* !!
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
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
|
|
454
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
488
|
-
*
|
|
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
|
|
502
|
-
*
|
|
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"
|
|
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)}`,
|