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.
- package/LICENSE +1 -1
- package/README.md +16 -9
- package/cli/realtimeclipboard.mjs +57 -9
- package/package.json +5 -2
- package/src/core/CLAUDE.md +9 -2
- package/src/core/bus.js +18 -24
- package/src/core/config.js +286 -197
- package/src/core/crypto.js +73 -101
- package/src/core/device.js +23 -22
- package/src/core/history.js +28 -65
- package/src/core/keys.js +52 -72
- package/src/core/native.js +55 -0
- package/src/core/paths.js +11 -48
- package/src/core/state.js +105 -60
- package/src/core/storage.js +89 -70
- package/src/core/text.js +51 -0
- package/src/transport/protocol.js +25 -9
- package/src/transport/relay.js +61 -110
- package/src/transport/sse.js +41 -72
- package/src/transport/ws.js +4 -8
package/src/core/storage.js
CHANGED
|
@@ -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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
81
|
+
* The key for THIS tab, so a reload survives the fragment being cleared.
|
|
72
82
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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;
|
package/src/core/text.js
ADDED
|
@@ -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
|
|
58
|
-
//
|
|
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
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
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
|
};
|
package/src/transport/relay.js
CHANGED
|
@@ -1,32 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The relay connection: protocol, liveness, and which transport carries it.
|
|
3
3
|
*
|
|
4
|
-
* Everything above
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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
|
-
*
|
|
149
|
-
*
|
|
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
|
-
//
|
|
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
|
|
181
|
-
//
|
|
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
|
|
193
|
-
//
|
|
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
|
-
//
|
|
215
|
-
//
|
|
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
|
|
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
|
|
247
|
-
//
|
|
248
|
-
//
|
|
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
|
-
//
|
|
272
|
-
//
|
|
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) —
|
|
304
|
-
//
|
|
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"
|
|
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
|
|
346
|
-
*
|
|
347
|
-
*
|
|
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
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
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
|
|
392
|
-
//
|
|
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
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
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
|
|