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.
@@ -28,15 +28,12 @@ export const saveSettings = s => write("settings", s);
28
28
 
29
29
  /**
30
30
  * The relay this device talks to, when it is not the one the build ships with.
31
+ * config.js reads this key directly at module evaluation, because the URL has to
32
+ * resolve before anything imports it; this pair is for changing it afterwards.
31
33
  *
32
- * A preference, not session content, so it belongs here — nothing about a clip
33
- * or a key is being written. core/config.js READS this key directly at module
34
- * evaluation, because the URL has to be resolved before anything imports it;
35
- * this pair is for changing it afterwards.
36
- *
37
- * Normalised on the way in as well as on the way out. A value that got into
38
- * storage malformed would otherwise be re-read as malformed on every launch,
39
- * and the symptom — every connection refused — looks nothing like its cause.
34
+ * Normalised on the way in as well as out — a malformed stored value would
35
+ * otherwise be re-read as malformed on every launch, and the symptom (every
36
+ * connection refused) looks nothing like its cause.
40
37
  */
41
38
  export const loadRelayUrl = () => normaliseRelay(read("relayUrl", null));
42
39
 
@@ -48,14 +45,11 @@ export function saveRelayUrl(url) {
48
45
  }
49
46
 
50
47
  /**
51
- * The last room, so a relaunch can offer it back (FR-1.7, OI-10).
52
- *
53
- * Stores `{key, locked}` since locked sessions exist; it used to be a bare
54
- * string and still reads one, because an upgrade must not strand somebody in
55
- * "no room at all" on their first load of the new build.
48
+ * The last room, so a relaunch can offer it back (FR-1.7, OI-10). Stores
49
+ * `{key, locked}` and still reads the bare string it used to be, because an
50
+ * upgrade must not strand somebody in "no room at all".
56
51
  *
57
- * The lock FLAG is remembered here, in localStorage. The PIN is not, and never
58
- * will be — see saveLock below.
52
+ * The lock FLAG is remembered here. The PIN is not — see saveLock.
59
53
  */
60
54
  export function loadLastKey() {
61
55
  const saved = read("lastKey", null);
@@ -65,16 +59,61 @@ export function loadLastKey() {
65
59
  : { key: saved.key ?? null, locked: !!saved.locked };
66
60
  }
67
61
 
68
- export const saveLastKey = (key, locked = false) => write("lastKey", { key, locked });
62
+ /**
63
+ * !! This is the share key in plain text, on disk, and it is the one store in
64
+ * this file that holds a secret rather than a preference. Anything running in
65
+ * the document reads it — including a third-party tag, which is why enabling
66
+ * AdSense is gated on this being opt-in rather than automatic. See
67
+ * docs/ARCHITECTURE.md §5. !!
68
+ *
69
+ * `remember` is the user's setting. False removes the record rather than merely
70
+ * declining to write one: turning the switch off has to forget the key already
71
+ * saved, or the setting only governs sessions you have not had yet.
72
+ */
73
+ export function saveLastKey(key, locked = false, remember = true) {
74
+ if (!remember) { remove("lastKey"); return false; }
75
+ return write("lastKey", { key, locked });
76
+ }
77
+
78
+ export const forgetLastKey = () => remove("lastKey");
69
79
 
70
80
  /**
71
- * Which transport last worked (see transport/relay.js).
81
+ * The key for THIS tab, so a reload survives the fragment being cleared.
72
82
  *
73
- * Remembered because probing costs the user real seconds of "Connecting…" on
74
- * every load behind a proxy that blocks WebSockets, and the answer there is the
75
- * same every time. Expired rather than permanent because it is a fact about the
76
- * *network*, not the device: a laptop that leaves the office should go back to
77
- * the faster transport on its own, without anyone knowing there was a setting.
83
+ * The fragment used to be the memory: the key sat in `location.hash` for the
84
+ * lifetime of the session and boot() read it back. That put it in the address
85
+ * bar, in screen shares, in screenshots, and — the reason it moved — in reach of
86
+ * every script in the document, which now includes tags this repository does not
87
+ * write. main.js clears the fragment the moment it has been read, and this is
88
+ * what makes a refresh still work.
89
+ *
90
+ * sessionStorage, not localStorage: a reload is the same session, a new tab is
91
+ * not, and closing the tab should end it. Surviving a browser restart is what
92
+ * `lastKey` above is for, and that one is the user's choice.
93
+ */
94
+ export function loadSessionKey() {
95
+ try {
96
+ const saved = JSON.parse(sessionStorage.getItem(PREFIX + "sessionKey") || "null");
97
+ return saved?.key ? { key: saved.key, locked: !!saved.locked } : null;
98
+ } catch { return null; }
99
+ }
100
+
101
+ export function saveSessionKey(key, locked = false) {
102
+ try {
103
+ sessionStorage.setItem(PREFIX + "sessionKey", JSON.stringify({ key, locked }));
104
+ return true;
105
+ } catch { return false; }
106
+ }
107
+
108
+ export function clearSessionKey() {
109
+ try { sessionStorage.removeItem(PREFIX + "sessionKey"); } catch { /* nothing to do */ }
110
+ }
111
+
112
+ /**
113
+ * Which transport last worked (transport/relay.js). Remembered because probing
114
+ * costs real seconds of "Connecting…" behind a proxy that blocks WebSockets.
115
+ * Expired rather than permanent because it is a fact about the *network*: a
116
+ * laptop leaving the office should return to the faster transport on its own.
78
117
  */
79
118
  export function loadTransport() {
80
119
  const saved = read("transport", null);
@@ -88,12 +127,11 @@ export function saveTransport(mode) {
88
127
  }
89
128
 
90
129
  /**
91
- * A transport the user picked by hand, which outranks anything measured.
92
- *
93
- * Kept apart from the remembered-working one above because they answer
94
- * different questions — "what worked last time" is an observation and expires;
95
- * "use HTTP" is an instruction and does not. Merging them would let a
96
- * successful automatic connection quietly overwrite a deliberate choice.
130
+ * A transport the user picked by hand, which outranks anything measured. Kept
131
+ * apart from the remembered-working one because they answer different questions:
132
+ * "what worked last time" is an observation and expires, "use HTTP" is an
133
+ * instruction and does not. Merged, a successful auto-connect would quietly
134
+ * overwrite a deliberate choice.
97
135
  */
98
136
  export const loadTransportChoice = () => read("transportChoice", null);
99
137
 
@@ -102,20 +140,15 @@ export function saveTransportChoice(mode) {
102
140
  write("transportChoice", mode);
103
141
  }
104
142
 
105
- /* ------------------------------------------------------------------
106
- Session-scoped file allowances
107
-
108
- sessionStorage, not localStorage, and this is the whole point of them: "allow
109
- everything from this device" is a decision about the session you are in, and
110
- it has to die with the tab. A permanent version of this setting is a standing
111
- grant to whoever holds the share key, made once and then forgotten about.
112
-
113
- Scoped to a room as well as a tab. The key is a bearer credential and rooms
114
- are named after it, so an allowance granted in one session must not survive
115
- into another — rotating the key is how someone throws a device out, and it
116
- would be worth nothing if the allowance came along.
117
- ------------------------------------------------------------------- */
118
-
143
+ /**
144
+ * Session-scoped file allowances.
145
+ *
146
+ * sessionStorage is the whole point: "allow everything from this device" is a
147
+ * decision about the session you are in and has to die with the tab, or it is a
148
+ * standing grant to whoever holds the share key. Scoped to a room as well —
149
+ * rotating the key is how someone throws a device out, and it would be worth
150
+ * nothing if the allowance came along.
151
+ */
119
152
  export function loadAllowances(room) {
120
153
  if (!room) return [];
121
154
  try {
@@ -132,36 +165,22 @@ export function saveAllowances(room, peers) {
132
165
  } catch { return false; }
133
166
  }
134
167
 
135
- /* ------------------------------------------------------------------
136
- The locked-session unlock, scoped to this tab
137
-
138
- Two decisions here, both deliberate.
139
-
140
- WHAT is stored is the PBKDF2 output, never the PIN. It unlocks exactly the
141
- same room, so this is not a security improvement in itself — the point is
142
- that a PIN is a human-chosen secret and humans reuse them. The string the
143
- user typed should not be sitting in a browser store waiting to be read by
144
- the next thing that gets to run in this origin. The derived value is useless
145
- anywhere else, and reading it back skips 600k PBKDF2 iterations, so a refresh
146
- is instant instead of a second of "Unlocking…".
147
-
148
- WHERE is sessionStorage, for the same reason the file allowances above use
149
- it: a refresh is the same session and must not re-prompt, but the tab closing
150
- is the end of it. localStorage would put the unlock on disk next to the
151
- plaintext key — at which point the link and the PIN are stored together and
152
- the second secret has bought nothing.
153
-
154
- Scoped to the share key, so rotating the key invalidates it automatically.
155
- ------------------------------------------------------------------- */
156
-
157
168
  /**
158
- * The remembered unlock for a share key, or null.
169
+ * The locked-session unlock, scoped to this tab.
170
+ *
171
+ * WHAT is the PBKDF2 output, never the PIN. It unlocks the same room, so this is
172
+ * not a security win in itself — the point is that PINs are human-chosen and
173
+ * reused, and the typed string should not sit in a browser store. Reading it
174
+ * back skips 600k iterations, so a refresh is instant.
175
+ *
176
+ * WHERE is sessionStorage: a refresh is the same session and must not re-prompt,
177
+ * but the tab closing ends it. localStorage would put the unlock on disk beside
178
+ * the plaintext key, at which point the second secret has bought nothing.
159
179
  *
160
- * Matched on the KEY rather than on the room, and that is not interchangeable.
161
- * The room hash of a locked session is derived from the stretched PIN alone, so
162
- * a record left behind by a previous key would still look perfectly
163
- * self-consistent and would silently reconnect this tab to a room the current
164
- * link does not name. The key is the thing that has to agree.
180
+ * Matched on the KEY, not the room, and those are not interchangeable: a locked
181
+ * room hash derives from the stretched PIN alone, so a record left by a previous
182
+ * key would look self-consistent and silently reconnect this tab to a room the
183
+ * current link does not name.
165
184
  */
166
185
  export function loadLock(key) {
167
186
  if (!key) return null;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Characters that let a string lie about what it is.
3
+ *
4
+ * One definition, because there are three places that need it and a security
5
+ * character class that drifts between them is worse than one that is wrong
6
+ * everywhere — at least the second is visible. The callers:
7
+ *
8
+ * clipboard/guard.js an arriving clip, before it reaches the OS clipboard
9
+ * files/registry.js a peer-supplied filename, before it reaches a download
10
+ * cli/ clip text, before it reaches a terminal (which adds
11
+ * escape SEQUENCES on top of these single characters)
12
+ *
13
+ * `clipboard/` and `files/` are peers and may not import each other, and `cli/`
14
+ * ships only `src/core/` and `src/transport/`. Rank 0 is the one place all
15
+ * three can reach.
16
+ *
17
+ * The ranges, and why each is here:
18
+ *
19
+ * U+0000-0008, U+000B-001F, U+007F-009F C0/C1 controls. A bare CR (U+000D)
20
+ * returns a terminal's cursor to the start of the line, so what is
21
+ * rendered is the tail of a string written over the head of it. NUL
22
+ * truncates in anything that reaches C.
23
+ * U+200E/200F, U+202A-202E, U+2066-2069 bidi marks, overrides and isolates.
24
+ * These reverse rendered order: `invoice[RLO]txt.exe` displays as
25
+ * `invoiceexe.txt`, and the extension you see is not the one that runs.
26
+ *
27
+ * TAB (U+0009) and LF (U+000A) fall inside those ranges and are deliberately
28
+ * excluded: both are ordinary text and neither hides anything. A filename has no
29
+ * business containing either, but that is a filename rule, not a text rule —
30
+ * files/registry.js applies it.
31
+ *
32
+ * Written as escapes, never as literals. A literal control character in source
33
+ * is invisible in a diff, which is the property being defended against.
34
+ */
35
+ const INVISIBLE = "[\\u0000-\\u0008\\u000B-\\u001F\\u007F-\\u009F"
36
+ + "\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]";
37
+
38
+ /** The class itself, for a caller building a larger pattern around it. */
39
+ export const INVISIBLE_SOURCE = INVISIBLE;
40
+
41
+ // Two objects, and the second has no /g on purpose: test() on a global regex
42
+ // advances lastIndex, so it answers differently every other call. That bug is
43
+ // silent, intermittent, and exactly the kind a security check must not have.
44
+ const STRIP = new RegExp(INVISIBLE, "g");
45
+ const DETECT = new RegExp(INVISIBLE);
46
+
47
+ export const stripInvisible = s =>
48
+ (typeof s === "string" ? s.replace(STRIP, "") : "");
49
+
50
+ export const hasInvisible = s =>
51
+ typeof s === "string" && DETECT.test(s);
@@ -8,6 +8,7 @@
8
8
  export const T = {
9
9
  HELLO: "hello",
10
10
  CLIP: "clip",
11
+ STREAM: "stream",
11
12
  PING: "ping",
12
13
  PONG: "pong",
13
14
  WELCOME: "welcome",
@@ -33,6 +34,24 @@ export const clip = ({ payload, iv, originId }) => ({
33
34
  t: T.CLIP, payload, iv, originId, ts: Date.now(),
34
35
  });
35
36
 
37
+ /**
38
+ * Typing, for the far editor to render — a VIEW frame:
39
+ *
40
+ * clip — a discrete thing that settled. History, OS clipboard, dedupe, size
41
+ * cap. Retained by the relay and replayed to late joiners (FR-3.3).
42
+ * stream — what the text looks like right now. Renders and nothing else.
43
+ *
44
+ * Carries the WHOLE text rather than a diff, which is what lets live typing
45
+ * exist without positions or operational transform, bounded by
46
+ * TEXT.STREAM_MAX_BYTES. `name` rides along because nothing replays these, so a
47
+ * device joining mid-sentence would otherwise render an unattributed caret.
48
+ *
49
+ * Everything but `t` and `originId` is sealed by main.js encryptFrame().
50
+ */
51
+ export const stream = ({ text, caret, name, originId }) => ({
52
+ t: T.STREAM, text, caret, name, originId,
53
+ });
54
+
36
55
  export const ping = () => ({ t: T.PING });
37
56
 
38
57
  export const fileMeta = ({ id, name, size, type, thumb, originId }) => ({
@@ -54,15 +73,12 @@ export const ERRORS = {
54
73
  ROOM_FULL: "This session already has the maximum number of devices",
55
74
  BAD_JSON: "The relay sent something unreadable",
56
75
  NO_STREAM: "The connection expired — reconnecting",
57
- // Only ever shown after the transport has given up retrying: the usual cause
58
- // is our own just-closed connection still holding the name, which clears on
59
- // its own. See relay.js reclaimIdentity().
76
+ // Only shown after the transport gives up retrying — the usual cause is our
77
+ // own just-closed connection still holding the name. See reclaimIdentity().
60
78
  PEER_ID_TAKEN: "Another device in this session is using the same id — files may not reach this one",
61
- // Locked sessions. In practice unreachable from a correct client: a locked
62
- // room's name is derived from the PIN, so a device that got the PIN wrong is
63
- // addressing a different room entirely rather than being turned away from
64
- // this one. It fires if a client's room derivation and key derivation ever
65
- // disagree — which is a bug, and this is how it surfaces instead of a
66
- // session that connects and then reads nothing.
79
+ // Unreachable from a correct client: a wrong PIN addresses a different room
80
+ // entirely rather than being turned away from this one. It fires if room and
81
+ // key derivation ever disagree, which is a bug, and this is how it surfaces
82
+ // instead of a session that connects and then reads nothing.
67
83
  AUTH_FAILED: "This session's PIN does not match the one already in use here",
68
84
  };
@@ -1,32 +1,20 @@
1
1
  /**
2
2
  * The relay connection: protocol, liveness, and which transport carries it.
3
3
  *
4
- * Everything above this file (main.js, and through it the whole app) sees one
5
- * interface — connect / send / close / isOpen / setFrameHandler — and never
6
- * learns how the bytes actually leave the machine. That boundary was designed
7
- * in from the start (PRD §4.3, docs/ARCHITECTURE.md §3) against the risk that
8
- * the deployed relay would not pass WebSocket upgrades. It turned out to be
9
- * needed for a different reason: the *client's* network.
10
- *
11
- * On a managed corporate network — the primary deployment context (PRD §5.4) —
12
- * a TLS-inspecting proxy may refuse the HTTP Upgrade that a WebSocket needs, or
13
- * accept the connection and then swallow it, leaving a socket that never opens
14
- * and never closes. So the transport is not a build-time decision:
4
+ * Everything above sees one interface — connect / send / close / isOpen /
5
+ * setFrameHandler — and never learns how the bytes leave the machine. Designed
6
+ * against the risk that the deployed relay would not pass WebSocket upgrades; it
7
+ * turned out to be needed for the *client's* network instead, where a
8
+ * TLS-inspecting proxy may refuse the Upgrade or accept and then swallow it.
15
9
  *
16
10
  * 1. Try WebSocket. Give it NET.PROBE_MS to become usable.
17
11
  * 2. Two consecutive attempts that never become usable is a policy, not a
18
- * flaky moment — switch to SSE+POST, which is plain HTTP and needs no
19
- * Upgrade, and say so out loud.
20
- * 3. If that is blocked too, keep alternating rather than giving up on one:
21
- * a network that refuses both is a different problem, and the user is told
22
- * exactly that instead of watching "Reconnecting" forever.
23
- *
24
- * The choice is remembered (storage.js), so someone behind that proxy pays the
25
- * probe once rather than on every load, and re-probes when the memory expires
26
- * or they move to another network.
12
+ * flaky moment — switch to SSE+POST and say so out loud.
13
+ * 3. If that is blocked too, keep alternating: a network refusing both is a
14
+ * different problem, and the user is told that rather than watching
15
+ * "Reconnecting" forever.
27
16
  *
28
- * The two channels live in ws.js and sse.js and implement one contract. All the
29
- * protocol handling, heartbeat and backoff is here, once, for both.
17
+ * The choice is remembered, so someone behind that proxy pays the probe once.
30
18
  */
31
19
 
32
20
  import { RELAY_URL, NET, TRANSPORT } from "../core/config.js";
@@ -72,14 +60,11 @@ let probe = null;
72
60
  let pingSentAt = 0;
73
61
 
74
62
  /**
75
- * Which attempt is the current one.
76
- *
77
- * Rejoining, rotating the key and recovering from a collision all close the
78
- * connection and open a new one — while a reconnect timer from the old one may
79
- * still be pending. Without a generation to compare against, that timer wakes
80
- * up, sees `wantOpen`, and opens a second connection alongside the live one:
81
- * the user is in the room twice, sees their own clips echoed back, and one of
82
- * the two sockets is unreachable by anything. Every callback and timer below
63
+ * Which attempt is the current one. Rejoin, rotate and collision recovery all
64
+ * close and reopen while a reconnect timer from the old attempt may still be
65
+ * pending — without a generation to compare, that timer wakes, sees `wantOpen`,
66
+ * and opens a second connection alongside the live one: the user is in the room
67
+ * twice and sees their own clips echoed back. Every callback and timer below
83
68
  * captures this and does nothing if it has moved on.
84
69
  */
85
70
  let epoch = 0;
@@ -88,10 +73,9 @@ let epoch = 0;
88
73
  export const transport = () => mode;
89
74
 
90
75
  /**
91
- * `auth` is a locked session's admission token (core/crypto.js `deriveLocked`).
92
- * It is HKDF output, not the PIN and not the key — the relay can check that two
93
- * peers agree without being able to reverse it into either. Absent for open
94
- * sessions, and a relay that predates it ignores the parameter.
76
+ * `auth` is a locked session's admission token — HKDF output, not the PIN and
77
+ * not the key, so the relay can check that two peers agree without being able to
78
+ * reverse it into either. A relay that predates it ignores the parameter.
95
79
  */
96
80
  export function connect({ roomHash, intent = "join", url = RELAY_URL, name = "", auth = null }) {
97
81
  channel?.close(1000); // a second connect() replaces, never stacks
@@ -118,22 +102,13 @@ export function close() {
118
102
 
119
103
  export const isOpen = () => channel?.isOpen() === true;
120
104
 
121
- /* ------------------------------------------------------------------
122
- transport selection
123
- ------------------------------------------------------------------- */
105
+ /* ---- transport selection ---- */
124
106
 
125
107
  /**
126
- * Where to start.
127
- *
128
- * A hand-picked transport wins outright, and never expires — it is an
129
- * instruction, not a measurement. Failing over would undo it, so a forced
130
- * transport keeps retrying itself and the status bar says it is locked.
131
- *
132
- * Failing that, a remembered choice: on a network that blocks WebSockets,
133
- * re-probing costs NET.PROBE_MS of "Connecting…" on every single load and the
134
- * answer is the same every time. storage.js expires that memory on its own, so
135
- * moving off that network re-probes rather than pinning the user to the slower
136
- * transport forever.
108
+ * A hand-picked transport wins outright and never expires — it is an
109
+ * instruction, not a measurement. Failing that, a remembered choice: re-probing
110
+ * costs NET.PROBE_MS of "Connecting…" on every load and the answer is the same
111
+ * every time. storage.js expires that memory, so moving network re-probes.
137
112
  */
138
113
  function preferred() {
139
114
  if (forced && CHANNELS[forced]?.available()) return forced;
@@ -143,12 +118,10 @@ function preferred() {
143
118
  }
144
119
 
145
120
  /**
146
- * Pin the transport, or hand it back to the app.
147
- *
148
- * Exists because "it works but it is on the slow one" and "it should be on the
149
- * slow one and is not" both need an answer a user can act on, and because
150
- * reproducing a blocked network to test the fallback otherwise means finding a
151
- * blocked network. `null` means auto.
121
+ * Pin the transport, or hand it back to the app. Exists because "it works but it
122
+ * is on the slow one" and "it should be on the slow one and is not" both need an
123
+ * answer a user can act on — and because reproducing a blocked network to test
124
+ * the fallback otherwise means finding a blocked network. `null` means auto.
152
125
  */
153
126
  export function setTransport(choice) {
154
127
  const next = choice === TRANSPORT.WS || choice === TRANSPORT.SSE ? choice : null;
@@ -160,8 +133,8 @@ export function setTransport(choice) {
160
133
 
161
134
  if (!wantOpen || !session) return announce();
162
135
 
163
- // Reconnect now rather than at the next drop: the point of choosing is to
164
- // see the choice take effect.
136
+ // Reconnect now rather than at the next drop: the point of choosing is to see
137
+ // the choice take effect.
165
138
  channel?.close(1000);
166
139
  channel = null;
167
140
  stopTimers();
@@ -177,9 +150,8 @@ export function setTransport(choice) {
177
150
  export const transportChoice = () => forced;
178
151
 
179
152
  function switchTransport() {
180
- // A forced transport does not fail over. Someone asked for this one
181
- // specifically, and silently moving them off it is exactly the behaviour the
182
- // switch was added to be able to override.
153
+ // A forced transport does not fail over: silently moving someone off it is
154
+ // exactly what the switch was added to override.
183
155
  if (forced) return;
184
156
 
185
157
  const next = OTHER[mode];
@@ -189,9 +161,8 @@ function switchTransport() {
189
161
  failures[next] = 0;
190
162
  backoff = NET.BACKOFF_MIN_MS; // the switch is a fresh start, not a retry
191
163
 
192
- // Loud, never silent — the same rule the file layer follows when a P2P
193
- // transfer falls back to the relay (FR-7.6). The fallback is slower and the
194
- // user is entitled to know which pipe their clipboard is going down.
164
+ // Loud, never silent — the same rule the file layer follows on P2P fallback
165
+ // (FR-7.6). The user is entitled to know which pipe their clipboard goes down.
195
166
  emit(EV.TOAST, next === TRANSPORT.SSE
196
167
  ? "WebSocket blocked — switched to the HTTP fallback"
197
168
  : "Retrying the WebSocket connection");
@@ -211,20 +182,16 @@ function announce() {
211
182
  mode: stuck ? null : mode,
212
183
  label: CHANNELS[mode].LABEL,
213
184
  blocked: stuck,
214
- // What the picker in the status bar shows as selected. `mode` is what is
215
- // actually carrying frames right now; this is what the user asked for, and
216
- // on auto they are not the same question.
185
+ // `mode` is what carries frames now; this is what the user asked for. On
186
+ // auto they are not the same question.
217
187
  forced,
218
188
  // "the relay is old" and "the network is blocking us" present identically
219
- // and have opposite fixes, so the UI is given the difference rather than a
220
- // single sentence that has to cover both.
189
+ // and have opposite fixes.
221
190
  unsupported,
222
191
  });
223
192
  }
224
193
 
225
- /* ------------------------------------------------------------------
226
- one attempt
227
- ------------------------------------------------------------------- */
194
+ /* ---- one attempt ---- */
228
195
 
229
196
  function start() {
230
197
  stopTimers();
@@ -243,11 +210,9 @@ function start() {
243
210
  onDown: current(down),
244
211
  });
245
212
 
246
- // A blocked transport does not always fail — it hangs. An intercepting proxy
247
- // can accept the connection, drop the Upgrade (or buffer the event stream)
248
- // and then do nothing at all, with no error to react to. Without this timer
249
- // the app sits on "Connecting…" indefinitely and the fallback never runs,
250
- // which is the whole failure this file exists to handle.
213
+ // A blocked transport hangs rather than fails, so there is no error to react
214
+ // to — only this timer notices. See transport/CLAUDE.md; do not replace it
215
+ // with an error handler.
251
216
  probe = setTimeout(() => {
252
217
  if (opened || gen !== epoch) return;
253
218
  channel?.close(4000); // deliberate: the channel will not call down()
@@ -268,9 +233,8 @@ function up() {
268
233
  failures[mode] = 0;
269
234
 
270
235
  // Only an automatic success is evidence. A forced transport working proves
271
- // nothing about what this network allows — it proves someone clicked it — and
272
- // recording it would mean pinning HTTP once left "Automatic" choosing HTTP
273
- // for the next twelve hours, which is the opposite of handing control back.
236
+ // someone clicked it, not what the network allows — recording it would mean
237
+ // pinning HTTP once left "Automatic" choosing HTTP for the next twelve hours.
274
238
  if (!forced) storage.saveTransport(mode);
275
239
 
276
240
  state.setConnection("connected", detail());
@@ -300,15 +264,12 @@ function down({ code, reason }) {
300
264
  }
301
265
 
302
266
  // 1012 = "service restart". Every push to main redeploys the relay and closes
303
- // every live socket with this code (OI-13) — observed for real during the M0
304
- // idle test. It is a planned, short outage rather than a fault, so reset the
305
- // backoff and come back promptly instead of treating it like a flaky network
306
- // and waiting out a doubled delay.
267
+ // every live socket with this code (OI-13) — a planned, short outage rather
268
+ // than a fault, so come back promptly instead of waiting out a doubled delay.
307
269
  if (code === 1012) backoff = NET.BACKOFF_MIN_MS;
308
270
 
309
271
  // Read before switching: switchTransport() zeroes the incoming transport's
310
- // counter, so asking afterwards always says "not blocked" and the user would
311
- // never be told that nothing at all is getting through.
272
+ // counter, so asking afterwards always says "not blocked".
312
273
  if (!opened && failures[mode] >= NET.SWITCH_AFTER) {
313
274
  if (blocked()) {
314
275
  stuck = true;
@@ -317,8 +278,7 @@ function down({ code, reason }) {
317
278
  switchTransport();
318
279
  }
319
280
 
320
- // Jitter keeps a restart from producing a synchronised reconnect stampede
321
- // from every client at the same instant.
281
+ // Jitter keeps a restart from producing a synchronised reconnect stampede.
322
282
  const wait = Math.round(backoff * (0.8 + Math.random() * 0.4));
323
283
  backoff = Math.min(backoff * 2, NET.BACKOFF_MAX_MS);
324
284
 
@@ -342,30 +302,24 @@ function stopTimers() {
342
302
  }
343
303
 
344
304
  /**
345
- * Status-bar text: whatever the state wants to say, plus the transport.
346
- *
347
- * "locked" is not decoration. A pinned transport does not fail over, so a user
348
- * who pinned WebSocket on a network that blocks it would otherwise watch an
349
- * endless reconnect with no hint that they are the reason.
305
+ * Status-bar text. "locked" is not decoration: a pinned transport does not fail
306
+ * over, so a user who pinned WebSocket on a network that blocks it would
307
+ * otherwise watch an endless reconnect with no hint that they are the reason.
350
308
  */
351
309
  const detail = (extra = "") =>
352
310
  [extra, NOTE[mode], forced ? "locked" : ""].filter(Boolean).join(" · ");
353
311
 
354
- /* ------------------------------------------------------------------
355
- protocol
356
- ------------------------------------------------------------------- */
312
+ /* ---- protocol ---- */
357
313
 
358
314
  let idRetries = 0;
359
315
 
360
316
  /**
361
- * Our own id is still held — by the connection we just closed.
362
- *
363
- * A rejoin, a key rotation or a collision recovery reconnects within
364
- * milliseconds, and the relay only drops a peer once it *notices* the old
365
- * transport is gone. Reconnect faster than that and we meet our own ghost: the
366
- * relay keeps us under a provisional id, while the files layer goes on
367
- * addressing frames to our originId — so transfers to this device quietly stop
368
- * resolving. Ask again a moment later, by which time the ghost has been reaped.
317
+ * Our own id is still held — by the connection we just closed. A rejoin or
318
+ * rotation reconnects within milliseconds, and the relay only drops a peer once
319
+ * it notices the old transport is gone. Reconnect faster and we meet our own
320
+ * ghost: the relay keeps us under a provisional id while the files layer goes on
321
+ * addressing frames to our originId, so transfers to this device quietly stop
322
+ * resolving. Ask again once the ghost has been reaped.
369
323
  */
370
324
  function reclaimIdentity() {
371
325
  if (idRetries >= 3) return emit(EV.TOAST, proto.ERRORS.PEER_ID_TAKEN);
@@ -388,16 +342,13 @@ function handle(msg) {
388
342
  emit(EV.KEY_COLLISION, { existing: msg.existing });
389
343
  return;
390
344
  }
391
- // A room's last clip is replayed to late joiners, so a device that
392
- // arrives mid-session is immediately in sync (FR-3.3).
345
+ // A room's last clip is replayed to late joiners, so a device arriving
346
+ // mid-session is immediately in sync (FR-3.3).
393
347
  if (msg.last) onFrame(msg.last);
394
348
 
395
- // What the room looked like at the moment we arrived. main.js needs both
396
- // halves and can infer neither: a locked session plants its beacon only
397
- // into a room that is empty AND has no retained clip, because the beacon
398
- // is a clip and would otherwise overwrite the very thing the line above
399
- // exists to deliver. Emitted after the replay so a listener that reacts
400
- // to it cannot race the last clip.
349
+ // Both halves, because main.js can infer neither: a locked session plants
350
+ // its beacon only into a room that is empty AND has no retained clip.
351
+ // Emitted after the replay so a listener cannot race the last clip.
401
352
  emit(EV.ROOM_STATE, { existing: msg.existing ?? 0, hasLast: msg.last != null });
402
353
  break;
403
354