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/crypto.js
CHANGED
|
@@ -1,31 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* End-to-end encryption. All of it. No libraries.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* OPEN prk = PBKDF2(KEY, salt, 250k)
|
|
5
|
+
* aesKey = HKDF(prk, "…/aes")
|
|
6
|
+
* roomHash = HKDF(prk, "…/room") -> sent
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* LOCKED prk = PBKDF2(PIN, "realtimeclipboard-lock-v1:" + KEY, 600k)
|
|
9
|
+
* aesKey = HKDF(prk, "…/aes")
|
|
10
|
+
* roomHash = HKDF(prk, "…/room") -> sent
|
|
11
|
+
* authToken = HKDF(prk, "…/auth") -> sent, proves PIN knowledge
|
|
9
12
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* aesKey = HKDF(prk, "…/aes") -> AES-GCM-256
|
|
16
|
-
* roomHash = HKDF(prk, "…/room") -> sent, routes the room
|
|
17
|
-
* authToken = HKDF(prk, "…/auth") -> sent, proves PIN knowledge to the relay
|
|
18
|
-
*
|
|
19
|
-
* The room hash requiring the PIN is the load-bearing part: it is what turns
|
|
20
|
-
* "you cannot read it" into "you cannot find it". Someone holding the link but
|
|
21
|
-
* not the PIN computes a different room hash, lands in a different room, and
|
|
22
|
-
* never appears in the real one at all — no peer slot, no roster entry, no
|
|
23
|
-
* traffic metadata. Locked and unlocked rooms with the same key are likewise
|
|
24
|
-
* disjoint, so the two kinds of client can never meet and fail to decrypt each
|
|
25
|
-
* other.
|
|
26
|
-
*
|
|
27
|
-
* The relay cannot derive the key from the hash, so it cannot decrypt. It sees
|
|
28
|
-
* a room name and ciphertext, and nothing else. See PRD §7.3.
|
|
13
|
+
* The room hash requiring the PIN is the load-bearing part: it turns "you cannot
|
|
14
|
+
* read it" into "you cannot find it". Someone holding the link but not the PIN
|
|
15
|
+
* computes a different room hash and never appears in the real room at all — no
|
|
16
|
+
* peer slot, no roster entry, no traffic metadata. Locked and unlocked rooms of
|
|
17
|
+
* the same key are disjoint for the same reason. See PRD §7.3.
|
|
29
18
|
*/
|
|
30
19
|
|
|
31
20
|
import { CRYPTO } from "./config.js";
|
|
@@ -34,89 +23,76 @@ const enc = new TextEncoder();
|
|
|
34
23
|
const dec = new TextDecoder();
|
|
35
24
|
|
|
36
25
|
/** Derivation is expensive (OI-8), so cache per key for the session. */
|
|
37
|
-
let cached = { id: null,
|
|
38
|
-
|
|
39
|
-
export async function roomHash(key) {
|
|
40
|
-
const digest = await crypto.subtle.digest("SHA-256", enc.encode("realtimeclipboard:" + key));
|
|
41
|
-
return hex(new Uint8Array(digest).slice(0, CRYPTO.ROOM_HASH_BYTES));
|
|
42
|
-
}
|
|
26
|
+
let cached = { id: null, open: null };
|
|
43
27
|
|
|
44
28
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
29
|
+
* Both outputs of an open session, from one PBKDF2.
|
|
30
|
+
*
|
|
31
|
+
* Several hundred ms on a low-end Android, so call once per session and show an
|
|
32
|
+
* "unlocking" state — never per message. It is not a new cost: the room hash was
|
|
33
|
+
* always awaited alongside this derivation before the connection opened, so the
|
|
34
|
+
* slower of the two already set the pace.
|
|
35
|
+
*
|
|
36
|
+
* Returning them together is deliberate. Two exported functions is what let the
|
|
37
|
+
* room hash be computed by a route that never ran the PBKDF2, which is exactly
|
|
38
|
+
* how it ended up being a bare SHA-256 of the key.
|
|
47
39
|
*/
|
|
48
|
-
export async function
|
|
49
|
-
if (cached.id === `open:${key}` && cached.
|
|
40
|
+
export async function deriveOpen(key) {
|
|
41
|
+
if (cached.id === `open:${key}` && cached.open) return cached.open;
|
|
50
42
|
|
|
51
43
|
const material = await crypto.subtle.importKey(
|
|
52
|
-
"raw", enc.encode(key), "PBKDF2", false, ["
|
|
44
|
+
"raw", enc.encode(key), "PBKDF2", false, ["deriveBits"]
|
|
53
45
|
);
|
|
54
|
-
const
|
|
46
|
+
const prk = await crypto.subtle.deriveBits(
|
|
55
47
|
{ name: "PBKDF2", salt: enc.encode(CRYPTO.SALT),
|
|
56
48
|
iterations: CRYPTO.ITERATIONS, hash: "SHA-256" },
|
|
57
49
|
material,
|
|
58
|
-
|
|
59
|
-
false,
|
|
60
|
-
["encrypt", "decrypt"]
|
|
50
|
+
256
|
|
61
51
|
);
|
|
62
|
-
|
|
63
|
-
|
|
52
|
+
|
|
53
|
+
const open = await expand(prk, CRYPTO.OPEN_INFO);
|
|
54
|
+
cached = { id: `open:${key}`, open };
|
|
55
|
+
return open;
|
|
64
56
|
}
|
|
65
57
|
|
|
66
|
-
/*
|
|
67
|
-
Locked sessions
|
|
68
|
-
------------------------------------------------------------------- */
|
|
58
|
+
/* ---- locked sessions ---- */
|
|
69
59
|
|
|
70
60
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* A key is uppercased and stripped to [A-Z0-9] so that two people typing "the
|
|
75
|
-
* same key" land in the same room. A PIN is user-chosen prose, so:
|
|
61
|
+
* The exact opposite of what normalise() does to a key, and getting it backwards
|
|
62
|
+
* is silent.
|
|
76
63
|
*
|
|
77
|
-
* - NFC
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* -
|
|
82
|
-
*
|
|
83
|
-
* - Keep case and everything in the middle. Uppercasing would throw away
|
|
84
|
-
* about a bit per letter to buy nothing: unlike a key, a PIN is never read
|
|
85
|
-
* aloud off a screen and retyped from memory.
|
|
64
|
+
* - NFC is mandatory. "é" as one code point and as "e" + combining accent are
|
|
65
|
+
* different byte strings deriving different rooms, and nothing reports it —
|
|
66
|
+
* the second device is simply alone, looking like a wrong PIN.
|
|
67
|
+
* - Trim the ends: a pasted trailing space is invisible on screen.
|
|
68
|
+
* - Keep case and the middle. A PIN is never read aloud and retyped, so
|
|
69
|
+
* uppercasing would throw away a bit per letter to buy nothing.
|
|
86
70
|
*/
|
|
87
71
|
export function normalisePin(raw) {
|
|
88
72
|
return String(raw ?? "").normalize("NFC").trim();
|
|
89
73
|
}
|
|
90
74
|
|
|
91
75
|
/**
|
|
92
|
-
*
|
|
76
|
+
* One PBKDF2, three outputs. PBKDF2 reruns its full iteration count per 32-byte
|
|
77
|
+
* block, so asking it for 64 bytes would cost double; stretch once and expand
|
|
78
|
+
* with HKDF, which is a couple of HMACs.
|
|
93
79
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
80
|
+
* The share key as salt stops ONE table covering every session — the open path,
|
|
81
|
+
* with its single global salt, has exactly that weakness. It buys nothing
|
|
82
|
+
* against the attacker this feature is for: someone holding the link can compute
|
|
83
|
+
* the salt. Only PIN length and iteration count defend there, which is why the
|
|
84
|
+
* dialog reports entropy in bits rather than calling a short PIN "secure".
|
|
98
85
|
*
|
|
99
|
-
*
|
|
100
|
-
* and does not buy. It stops ONE table from covering every session — the
|
|
101
|
-
* open-session path, with its single global CRYPTO.SALT, has exactly that
|
|
102
|
-
* weakness today. It buys nothing at all against the attacker this feature is
|
|
103
|
-
* actually for: someone holding the link holds the key, so they can compute the
|
|
104
|
-
* salt themselves. Against them the only defence is the length of the PIN and
|
|
105
|
-
* the iteration count, which is why the dialog reports entropy in bits instead
|
|
106
|
-
* of calling a short PIN "secure".
|
|
107
|
-
*
|
|
108
|
-
* Returns `prk` as hex alongside the derived values so a page refresh can skip
|
|
109
|
-
* the 600k iterations. See storage.saveLock for why the PIN itself is never
|
|
110
|
-
* what gets stored.
|
|
86
|
+
* Returns `prk` as hex so a refresh can skip the 600k iterations.
|
|
111
87
|
*/
|
|
112
88
|
export async function deriveLocked(key, pin) {
|
|
113
89
|
const prk = await stretch(key, normalisePin(pin));
|
|
114
|
-
return { ...(await expand(prk)), prk: hex(new Uint8Array(prk)) };
|
|
90
|
+
return { ...(await expand(prk, CRYPTO.LOCK_INFO)), prk: hex(new Uint8Array(prk)) };
|
|
115
91
|
}
|
|
116
92
|
|
|
117
93
|
/** Same outputs, from a remembered prk. No PBKDF2, so this is instant. */
|
|
118
94
|
export async function deriveLockedFromPrk(prkHex) {
|
|
119
|
-
return { ...(await expand(unhex(prkHex))), prk: prkHex };
|
|
95
|
+
return { ...(await expand(unhex(prkHex), CRYPTO.LOCK_INFO)), prk: prkHex };
|
|
120
96
|
}
|
|
121
97
|
|
|
122
98
|
async function stretch(key, pin) {
|
|
@@ -131,37 +107,37 @@ async function stretch(key, pin) {
|
|
|
131
107
|
);
|
|
132
108
|
}
|
|
133
109
|
|
|
134
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Shared by both paths, which is the point: the open and locked sessions differ
|
|
112
|
+
* only in what they stretch and under which domain-separation strings, never in
|
|
113
|
+
* how the outputs are pulled off the PRK. `AUTH` is absent from OPEN_INFO — an
|
|
114
|
+
* unlocked room has no PIN to prove knowledge of — and its absence is what makes
|
|
115
|
+
* the token optional rather than a flag.
|
|
116
|
+
*/
|
|
117
|
+
async function expand(prk, labels) {
|
|
135
118
|
const ikm = await crypto.subtle.importKey("raw", prk, "HKDF", false, ["deriveBits", "deriveKey"]);
|
|
136
119
|
const info = i => ({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: enc.encode(i) });
|
|
120
|
+
const bits = i => crypto.subtle.deriveBits(info(i), ikm, CRYPTO.ROOM_HASH_BYTES * 8);
|
|
137
121
|
|
|
138
122
|
const [aesKey, room, auth] = await Promise.all([
|
|
139
123
|
crypto.subtle.deriveKey(
|
|
140
|
-
info(
|
|
124
|
+
info(labels.AES), ikm,
|
|
141
125
|
{ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
|
|
142
126
|
),
|
|
143
|
-
|
|
144
|
-
|
|
127
|
+
bits(labels.ROOM),
|
|
128
|
+
labels.AUTH ? bits(labels.AUTH) : null,
|
|
145
129
|
]);
|
|
146
130
|
|
|
147
131
|
return {
|
|
148
132
|
aesKey,
|
|
149
|
-
roomHash:
|
|
150
|
-
authToken: hex(new Uint8Array(auth)),
|
|
133
|
+
roomHash: hex(new Uint8Array(room)),
|
|
134
|
+
...(auth ? { authToken: hex(new Uint8Array(auth)) } : {}),
|
|
151
135
|
};
|
|
152
136
|
}
|
|
153
137
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
* The open-session cache exists because deriveKey() is called with the same key
|
|
158
|
-
* repeatedly. The locked path is not: it runs once per session, a reconnect
|
|
159
|
-
* reuses the room hash without re-deriving, and the only things that DO call it
|
|
160
|
-
* again — a corrected PIN, a rotated key — are the cases where the previous
|
|
161
|
-
* answer is exactly the one you must not reuse. The refresh path is covered by
|
|
162
|
-
* the stored prk instead, which is cheaper than a cache and survives the tab
|
|
163
|
-
* being reloaded.
|
|
164
|
-
*/
|
|
138
|
+
// No locked-session cache on purpose: the path runs once per session, and the
|
|
139
|
+
// things that DO call it again — a corrected PIN, a rotated key — are exactly
|
|
140
|
+
// where the previous answer must not be reused. The stored prk covers refresh.
|
|
165
141
|
|
|
166
142
|
/** -> {payload, iv} both base64. A fresh IV per message is mandatory for GCM. */
|
|
167
143
|
export async function encrypt(aesKey, plaintext) {
|
|
@@ -179,14 +155,10 @@ export async function decrypt(aesKey, payloadB64, ivB64) {
|
|
|
179
155
|
return dec.decode(buf);
|
|
180
156
|
}
|
|
181
157
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
* callers at all, which meant the AES key of a room you had deliberately walked
|
|
187
|
-
* out of stayed live in this module until you happened to open another one.
|
|
188
|
-
*/
|
|
189
|
-
export function clearCache() { cached = { id: null, aesKey: null }; }
|
|
158
|
+
// Called on leaving a session and on rotating the key. This had no callers for
|
|
159
|
+
// years, so the AES key of a room you walked out of stayed live until you opened
|
|
160
|
+
// another one.
|
|
161
|
+
export function clearCache() { cached = { id: null, open: null }; }
|
|
190
162
|
|
|
191
163
|
/* ---- hex helpers ---- */
|
|
192
164
|
function hex(bytes) {
|
package/src/core/device.js
CHANGED
|
@@ -1,33 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A human-readable name for this device, shown in the peer list.
|
|
2
|
+
* A human-readable name for this device, shown in the peer list. Derived from
|
|
3
|
+
* the user agent, which is unreliable by design — a label to help someone
|
|
4
|
+
* recognise their own laptop in a list of three, not an identity.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the encrypted envelope, never to the relay in the clear" — and the wire has
|
|
10
|
-
* never agreed with it: `protocol.hello()` puts `name` in a plaintext field,
|
|
11
|
-
* and the relay stores it and rebroadcasts it in every roster
|
|
12
|
-
* (backend/main.py `_adopt_identity`, `_roster`).
|
|
13
|
-
*
|
|
14
|
-
* So keep it a label. "Chrome · Windows" is fine; a name is not the place for
|
|
15
|
-
* anything you would mind the relay operator reading. This is one of the things
|
|
16
|
-
* a locked session does NOT hide — see PRD §7.5 and OI-20.
|
|
6
|
+
* IT IS SENT IN THE CLEAR: `protocol.hello()` puts `name` in a plaintext field,
|
|
7
|
+
* and the relay stores and rebroadcasts it in every roster. So keep it a label —
|
|
8
|
+
* "Chrome · Windows" is fine, and a name is not the place for anything you would
|
|
9
|
+
* mind the relay operator reading. One of the things a locked session does NOT
|
|
10
|
+
* hide, see PRD §7.5 and OI-20.
|
|
17
11
|
*/
|
|
18
12
|
|
|
19
13
|
import { read, write } from "./storage.js";
|
|
20
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Windows | Android | iOS | macOS | Linux | Unknown. Exported because the
|
|
17
|
+
* desktop guide describes the tray, which behaves differently enough per
|
|
18
|
+
* platform that one paragraph covering all three would be wrong on two.
|
|
19
|
+
*/
|
|
20
|
+
export function os() {
|
|
21
|
+
const ua = globalThis.navigator?.userAgent ?? "";
|
|
22
|
+
return /Windows/i.test(ua) ? "Windows" :
|
|
23
|
+
/Android/i.test(ua) ? "Android" :
|
|
24
|
+
/iPhone|iPad|iPod/i.test(ua) ? "iOS" :
|
|
25
|
+
/Mac OS X|Macintosh/i.test(ua) ? "macOS" :
|
|
26
|
+
/Linux/i.test(ua) ? "Linux" : "Unknown";
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
function detect() {
|
|
22
30
|
const ua = navigator.userAgent;
|
|
23
31
|
|
|
24
|
-
const os =
|
|
25
|
-
/Windows/i.test(ua) ? "Windows" :
|
|
26
|
-
/Android/i.test(ua) ? "Android" :
|
|
27
|
-
/iPhone|iPad|iPod/i.test(ua) ? "iOS" :
|
|
28
|
-
/Mac OS X|Macintosh/i.test(ua) ? "macOS" :
|
|
29
|
-
/Linux/i.test(ua) ? "Linux" : "Unknown";
|
|
30
|
-
|
|
31
32
|
// Order matters: Edge and Opera both contain "Chrome", Chrome contains "Safari".
|
|
32
33
|
const browser =
|
|
33
34
|
/Edg\//i.test(ua) ? "Edge" :
|
|
@@ -36,7 +37,7 @@ function detect() {
|
|
|
36
37
|
/Chrome\//i.test(ua) ? "Chrome" :
|
|
37
38
|
/Safari\//i.test(ua) ? "Safari" : "Browser";
|
|
38
39
|
|
|
39
|
-
return `${browser} · ${os}`;
|
|
40
|
+
return `${browser} · ${os()}`;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/** Persisted so a device keeps its name across reloads, and stays renameable. */
|
package/src/core/history.js
CHANGED
|
@@ -1,35 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* In-session clip history — PRD FR-2.9 (last 20 clips, one-click copy).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* PRIVACY INVARIANT: persists to **sessionStorage only, never localStorage**.
|
|
5
|
+
* Clipboard content is passwords, tokens, 2FA codes and private URLs; it must
|
|
6
|
+
* not survive the browser session, be readable by the next person to open the
|
|
7
|
+
* laptop, or leak between rooms. The sessionStorage twin lives here rather than
|
|
8
|
+
* in core/storage.js precisely so a future edit cannot quietly move clips onto
|
|
9
|
+
* disk — that module is the localStorage one, for preferences.
|
|
6
10
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* browser session, must not be readable by the next person to open the laptop,
|
|
10
|
-
* and must not leak between rooms. sessionStorage is scoped to the tab and dies
|
|
11
|
-
* with it, which is exactly the lifetime we want.
|
|
12
|
-
*
|
|
13
|
-
* core/storage.js is the localStorage wrapper and is explicitly documented as
|
|
14
|
-
* "clipboard *content* never comes near this — only preferences". So the
|
|
15
|
-
* sessionStorage twin lives here rather than being bolted onto that module: the
|
|
16
|
-
* two stores have different lifetimes for a reason, and keeping them in separate
|
|
17
|
-
* files is what stops a future edit from quietly moving clips onto disk.
|
|
18
|
-
*
|
|
19
|
-
* The same reasoning drives the key-change behaviour: a different share key is a
|
|
20
|
-
* different room and a different set of people. History never crosses that line.
|
|
21
|
-
*
|
|
22
|
-
* Node-testable on purpose — this file imports only core/bus.js, and every
|
|
23
|
-
* sessionStorage call is wrapped, so it degrades to memory-only where the API is
|
|
24
|
-
* missing (node, private mode, storage disabled).
|
|
11
|
+
* Node-testable on purpose: imports only core/bus.js, and every sessionStorage
|
|
12
|
+
* call is wrapped, so it degrades to memory-only where the API is missing.
|
|
25
13
|
*/
|
|
26
14
|
|
|
27
15
|
import { on, emit, EV } from "./bus.js";
|
|
28
16
|
|
|
29
17
|
/**
|
|
30
|
-
* PRD FR-2.9 caps history at 20.
|
|
31
|
-
* limits; it
|
|
32
|
-
* the two land together.
|
|
18
|
+
* PRD FR-2.9 caps history at 20. Belongs in core/config.js with the other
|
|
19
|
+
* limits; it is here because importing config.js would cost node testability.
|
|
33
20
|
*/
|
|
34
21
|
export const MAX_CLIPS = 20;
|
|
35
22
|
|
|
@@ -48,14 +35,8 @@ let roomKey = null; // share key the current list belongs to; null until fi
|
|
|
48
35
|
let started = false;
|
|
49
36
|
let seq = 0;
|
|
50
37
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
* Mirrors the shape of core/storage.js (read / write / remove, wrapped so a
|
|
54
|
-
* disabled-storage browser degrades instead of throwing) but targets
|
|
55
|
-
* sessionStorage. try/catch also swallows the ReferenceError under node, which
|
|
56
|
-
* is what makes this module testable outside a browser.
|
|
57
|
-
*/
|
|
58
|
-
|
|
38
|
+
// Mirrors core/storage.js against sessionStorage. try/catch also swallows the
|
|
39
|
+
// ReferenceError under node, which is what makes this module testable.
|
|
59
40
|
function readStore() {
|
|
60
41
|
try {
|
|
61
42
|
const raw = sessionStorage.getItem(STORE_KEY);
|
|
@@ -73,22 +54,19 @@ function removeStore() {
|
|
|
73
54
|
}
|
|
74
55
|
|
|
75
56
|
/**
|
|
76
|
-
* A
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* than losing the user's clipboard.
|
|
57
|
+
* A refused write keeps going in memory rather than dropping the clip: 20 clips
|
|
58
|
+
* of 50k characters can push a megabyte, and losing persistence across a reload
|
|
59
|
+
* is a smaller failure than losing the user's clipboard.
|
|
80
60
|
*/
|
|
81
61
|
function persist() {
|
|
82
62
|
if (!clips.length && roomKey === null) return removeStore();
|
|
83
63
|
writeStore({ key: roomKey, clips });
|
|
84
64
|
}
|
|
85
65
|
|
|
86
|
-
/* ------------------------------------------------------------------- model */
|
|
87
|
-
|
|
88
66
|
const nextId = () => `h${Date.now().toString(36)}${(++seq).toString(36)}`;
|
|
89
67
|
|
|
90
|
-
|
|
91
|
-
|
|
68
|
+
// Local, deliberately not core/keys.js — that pulls in config.js and its
|
|
69
|
+
// top-level `location` read, which breaks node testability.
|
|
92
70
|
const normKey = k => String(k ?? "").trim().toUpperCase();
|
|
93
71
|
|
|
94
72
|
function announce(reason) {
|
|
@@ -107,13 +85,10 @@ export function get(id) {
|
|
|
107
85
|
export const size = () => clips.length;
|
|
108
86
|
|
|
109
87
|
/**
|
|
110
|
-
* Record a clip. Returns the new entry, or null if it was ignored
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* fire twice for one copy (paste event + poll), and a peer echoing our own clip
|
|
115
|
-
* back arrives with the same text under a different direction. Consecutive
|
|
116
|
-
* duplicates are noise in every one of those cases.
|
|
88
|
+
* Record a clip. Returns the new entry, or null if it was ignored — empty text,
|
|
89
|
+
* or identical to the most recent entry. Consecutive dedupe matters more than it
|
|
90
|
+
* looks: capture tiers can fire twice for one copy (paste event + poll), and a
|
|
91
|
+
* peer echoing our own clip back arrives with the same text.
|
|
117
92
|
*/
|
|
118
93
|
export function add({ text, direction }) {
|
|
119
94
|
const value = String(text ?? "");
|
|
@@ -141,13 +116,8 @@ export function clear(reason = "clear") {
|
|
|
141
116
|
return had;
|
|
142
117
|
}
|
|
143
118
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Rehydrate whatever this tab had before a reload. The stored key is not known
|
|
148
|
-
* to be the current room yet — the first KEY_CHANGED decides whether to keep or
|
|
149
|
-
* discard this (see onKeyChanged).
|
|
150
|
-
*/
|
|
119
|
+
// The stored key is not known to be the current room yet — the first
|
|
120
|
+
// KEY_CHANGED decides whether to keep or discard this.
|
|
151
121
|
function hydrate() {
|
|
152
122
|
const saved = readStore();
|
|
153
123
|
if (!saved || !Array.isArray(saved.clips)) return;
|
|
@@ -166,13 +136,10 @@ function hydrate() {
|
|
|
166
136
|
}
|
|
167
137
|
|
|
168
138
|
/**
|
|
169
|
-
* A new share key is a new room, new peers, and a new privacy context.
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* during startup for the key we already had. Comparing against the key stored
|
|
174
|
-
* alongside the clips is what tells the two apart, and is why the key is
|
|
175
|
-
* persisted with the list rather than held only in memory.
|
|
139
|
+
* A new share key is a new room, new peers, and a new privacy context. The first
|
|
140
|
+
* KEY_CHANGED after boot is not a rotation, though — main.js emits one for the
|
|
141
|
+
* key we already had, and comparing against the key stored alongside the clips
|
|
142
|
+
* is what tells the two apart.
|
|
176
143
|
*/
|
|
177
144
|
function onKeyChanged({ key }) {
|
|
178
145
|
const next = normKey(key);
|
|
@@ -189,11 +156,7 @@ function onKeyChanged({ key }) {
|
|
|
189
156
|
persist();
|
|
190
157
|
}
|
|
191
158
|
|
|
192
|
-
/**
|
|
193
|
-
* Subscribe to the bus. Idempotent — ui/historyPanel.js calls this so the
|
|
194
|
-
* feature is one init() line in main.js, but calling it from main.js directly is
|
|
195
|
-
* equally fine.
|
|
196
|
-
*/
|
|
159
|
+
/** Subscribe to the bus. Idempotent. */
|
|
197
160
|
export function init() {
|
|
198
161
|
if (started) return;
|
|
199
162
|
started = true;
|