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/keys.js
CHANGED
|
@@ -1,30 +1,19 @@
|
|
|
1
1
|
/** Share-key generation and normalisation. */
|
|
2
2
|
|
|
3
3
|
import { KEY, LOCK } from "./config.js";
|
|
4
|
+
import * as state from "./state.js";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
* Cryptographically random key from the unambiguous alphabet.
|
|
7
|
-
*
|
|
8
|
-
* Note the modulo bias: 256 does not divide 30, so the first 16 letters of the
|
|
9
|
-
* alphabet are very slightly likelier than the last 14. The effect is about
|
|
10
|
-
* 0.03 bits over a 6-character key — irrelevant next to the 30-bit total, and
|
|
11
|
-
* called out here so nobody has to rediscover it.
|
|
12
|
-
*/
|
|
6
|
+
// Modulo bias: 256 does not divide 30, costing ~0.03 bits over a 6-char key.
|
|
13
7
|
export function generate(length = KEY.LENGTH) {
|
|
14
8
|
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
|
15
9
|
return Array.from(bytes, b => KEY.ALPHABET[b % KEY.ALPHABET.length]).join("");
|
|
16
10
|
}
|
|
17
11
|
|
|
18
12
|
/**
|
|
19
|
-
* Bits of entropy in a key of this length,
|
|
13
|
+
* Bits of entropy in a key of this length: 6 chars ≈ 29.4, 10 chars ≈ 49.1.
|
|
20
14
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* 10 chars ≈ 49.1 bits — ~1.6 million times harder, still typeable.
|
|
24
|
-
*
|
|
25
|
-
* PBKDF2 at 250k iterations multiplies the cost of each guess, but it does not
|
|
26
|
-
* change the shape of the problem: short keys are a convenience decision, and
|
|
27
|
-
* this function exists so the UI can say so in numbers rather than adjectives.
|
|
15
|
+
* PBKDF2 multiplies the cost of each guess but does not change the shape of the
|
|
16
|
+
* problem. This exists so the UI can state the trade-off in numbers.
|
|
28
17
|
*/
|
|
29
18
|
export function entropyBits(length) {
|
|
30
19
|
return Math.log2(KEY.ALPHABET.length) * length;
|
|
@@ -32,13 +21,16 @@ export function entropyBits(length) {
|
|
|
32
21
|
|
|
33
22
|
export const LENGTHS = { NORMAL: KEY.LENGTH, LONG: KEY.LONG_LENGTH };
|
|
34
23
|
|
|
24
|
+
// One implementation: the first-run key and the collision retry both used to
|
|
25
|
+
// emit six characters however the app was configured.
|
|
26
|
+
export const nextLength = () =>
|
|
27
|
+
state.get().settings.longKeys ? LENGTHS.LONG : LENGTHS.NORMAL;
|
|
28
|
+
|
|
35
29
|
/**
|
|
36
30
|
* Normalise before ANY use — hashing, comparison, display.
|
|
37
31
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* into different rooms while both believe they typed the same key.
|
|
41
|
-
* Verified in docs/M0-RESULTS.md §6.
|
|
32
|
+
* The room name is a hash of the key, so "D75LV" and "d75lv" would drop two
|
|
33
|
+
* users into different rooms while both believe they typed the same thing.
|
|
42
34
|
*/
|
|
43
35
|
export function normalise(raw) {
|
|
44
36
|
return String(raw || "").trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
@@ -47,16 +39,10 @@ export function normalise(raw) {
|
|
|
47
39
|
/**
|
|
48
40
|
* Deliberately more permissive than the generation alphabet.
|
|
49
41
|
*
|
|
50
|
-
* KEY.ALPHABET
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* - a key shared from another build (or a future alphabet) is still valid;
|
|
55
|
-
* the room name is a hash, and a hash accepts any input
|
|
56
|
-
* - rejecting an in-use key strands the user with no way to join
|
|
57
|
-
*
|
|
58
|
-
* "D75LV" is the worked example throughout the docs and contains an L, which
|
|
59
|
-
* the generator will never emit. It still has to work.
|
|
42
|
+
* KEY.ALPHABET constrains what we PRODUCE. Validation must accept anything a
|
|
43
|
+
* peer might legitimately hand us: a key from another build is still a valid
|
|
44
|
+
* hash input, and rejecting an in-use key strands the user with no way to join.
|
|
45
|
+
* "D75LV" contains an L, which the generator never emits, and still has to work.
|
|
60
46
|
*/
|
|
61
47
|
export function isValid(raw) {
|
|
62
48
|
const k = normalise(raw);
|
|
@@ -64,17 +50,12 @@ export function isValid(raw) {
|
|
|
64
50
|
}
|
|
65
51
|
|
|
66
52
|
/**
|
|
67
|
-
* Bits of entropy in a PIN,
|
|
68
|
-
* uses rather than from its length alone.
|
|
53
|
+
* Bits of entropy in a PIN, from the character classes it actually uses.
|
|
69
54
|
*
|
|
70
55
|
* Deliberately pessimistic — it assumes an attacker who knows the alphabet you
|
|
71
|
-
* drew from,
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* The number matters more here than it does for a key. A key is guessed from
|
|
76
|
-
* nothing; a PIN is guessed by someone who may already hold the link, and at
|
|
77
|
-
* that point it is the entire remaining secret.
|
|
56
|
+
* drew from, so "123456" reports ~20 bits rather than a flattering ~39. A PIN is
|
|
57
|
+
* guessed by someone who may already hold the link, where it is the entire
|
|
58
|
+
* remaining secret.
|
|
78
59
|
*/
|
|
79
60
|
export function pinEntropyBits(pin) {
|
|
80
61
|
const p = String(pin ?? "");
|
|
@@ -87,61 +68,60 @@ export function pinEntropyBits(pin) {
|
|
|
87
68
|
return Math.log2(alphabet) * p.length;
|
|
88
69
|
}
|
|
89
70
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
normalise() strips everything outside [A-Z0-9], so running it first turns
|
|
100
|
-
"#!ABCDEF" into the perfectly valid, completely different key "ABCDEF".
|
|
101
|
-
|
|
102
|
-
An older build has no idea about any of this. It normalises the fragment,
|
|
103
|
-
drops the "!", and joins the UNLOCKED room named ABCDEF — a different room
|
|
104
|
-
from the locked one, which it cannot address. It finds an empty room and
|
|
105
|
-
learns nothing, which is the correct way for this to fail.
|
|
106
|
-
------------------------------------------------------------------- */
|
|
107
|
-
|
|
71
|
+
/**
|
|
72
|
+
* A locked session's link carries a marker but never the PIN: `#!ABCDEF`.
|
|
73
|
+
*
|
|
74
|
+
* Parsing happens BEFORE normalise(), and that order is load-bearing:
|
|
75
|
+
* normalise() strips everything outside [A-Z0-9], so running it first turns
|
|
76
|
+
* "#!ABCDEF" into the valid, completely different key "ABCDEF". That is also how
|
|
77
|
+
* an older build fails safely — it joins the empty unlocked room and learns
|
|
78
|
+
* nothing.
|
|
79
|
+
*/
|
|
108
80
|
export const LOCK_SIGIL = LOCK.SIGIL;
|
|
109
81
|
|
|
110
|
-
/** Split a raw fragment into its key and its lock flag. */
|
|
111
82
|
export function parseFragment(raw) {
|
|
112
83
|
const s = String(raw ?? "").trim();
|
|
113
84
|
const locked = s.startsWith(LOCK.SIGIL);
|
|
114
85
|
return { key: normalise(locked ? s.slice(LOCK.SIGIL.length) : s), locked };
|
|
115
86
|
}
|
|
116
87
|
|
|
117
|
-
/**
|
|
88
|
+
/** The inverse of parseFragment, and tested as such. */
|
|
118
89
|
export function fragment(key, locked = false) {
|
|
119
90
|
return (locked ? LOCK.SIGIL : "") + normalise(key);
|
|
120
91
|
}
|
|
121
92
|
|
|
122
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* The fragment is never sent to a server. Read once at boot and then cleared —
|
|
95
|
+
* see clearUrl(), which openSession() calls immediately afterwards.
|
|
96
|
+
*
|
|
97
|
+
* There is deliberately no inverse. A `toUrl()` existed and was what kept the
|
|
98
|
+
* key in the address bar for the whole session; it is gone rather than merely
|
|
99
|
+
* unused, so that "put the key back in the URL" is not one import away. A link
|
|
100
|
+
* to share is built by shareLink() from state, which never touches `location`.
|
|
101
|
+
*/
|
|
123
102
|
export function fromUrl() {
|
|
103
|
+
if (typeof location === "undefined") return { key: "", locked: false };
|
|
124
104
|
return parseFragment(location.hash.slice(1));
|
|
125
105
|
}
|
|
126
106
|
|
|
127
|
-
export function toUrl(key, locked = false) {
|
|
128
|
-
location.hash = fragment(key, locked);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
107
|
/**
|
|
132
108
|
* Drop the key out of the address bar without navigating.
|
|
133
109
|
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
110
|
+
* Called on every `openSession()`, so the key is in the URL only for the instant
|
|
111
|
+
* between the page loading and boot reading it — not for the life of the
|
|
112
|
+
* session, in every screenshot and screen share, and in reach of every script in
|
|
113
|
+
* the document. It is also what `onEvicted()` uses to make a reload stop
|
|
114
|
+
* rejoining a room this device was thrown out of.
|
|
115
|
+
*
|
|
116
|
+
* replaceState, not `location.hash = ""` — that leaves a bare "#" and pushes a
|
|
117
|
+
* history entry, so Back would restore the dead key.
|
|
139
118
|
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
119
|
+
* Guarded because this directory has no DOM (see CLAUDE.md): `cli/` imports this
|
|
120
|
+
* module, and a bare `history` here threw during boot the moment this stopped
|
|
121
|
+
* being an eviction-only path. Silent no-op — there is no address bar to clear.
|
|
143
122
|
*/
|
|
144
123
|
export function clearUrl() {
|
|
124
|
+
if (typeof history === "undefined" || typeof location === "undefined") return;
|
|
145
125
|
history.replaceState(null, "", location.pathname + location.search);
|
|
146
126
|
}
|
|
147
127
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that knows there is a native shell underneath.
|
|
3
|
+
*
|
|
4
|
+
* Rank 0 and DOM-free, because the answer also decides the default key length in
|
|
5
|
+
* config.js — and core/ is imported by the node tests and shipped in the npm
|
|
6
|
+
* package cli/ runs on.
|
|
7
|
+
*
|
|
8
|
+
* Three files used to feature-test `globalThis.__TAURI__` independently and all
|
|
9
|
+
* three failed at once: `withGlobalTauri` had never been switched on, so the
|
|
10
|
+
* global did not exist, T0 never started, and the desktop app silently degraded
|
|
11
|
+
* to a browser tab that cannot watch the clipboard.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const g = globalThis;
|
|
15
|
+
|
|
16
|
+
// `__TAURI_INTERNALS__` is injected into every Tauri webview; `__TAURI__` only
|
|
17
|
+
// when `withGlobalTauri` is on. Both are checked so the answer survives that
|
|
18
|
+
// flag being turned off again — the failure this module was written after.
|
|
19
|
+
const IN_TAURI = typeof g.__TAURI_INTERNALS__ === "object"
|
|
20
|
+
|| typeof g.__TAURI__ === "object";
|
|
21
|
+
|
|
22
|
+
/** `document` first: jsdom has both, and the DOM suites want to be "web". */
|
|
23
|
+
const IN_NODE = typeof document === "undefined" && !!g.process?.versions?.node;
|
|
24
|
+
|
|
25
|
+
export const SURFACE = IN_TAURI ? "desktop" : IN_NODE ? "cli" : "web";
|
|
26
|
+
export const IS_DESKTOP = SURFACE === "desktop";
|
|
27
|
+
export const IS_WEB = SURFACE === "web";
|
|
28
|
+
export const IS_CLI = SURFACE === "cli";
|
|
29
|
+
|
|
30
|
+
/** Call a command in desktop/src-tauri/src/main.rs. Null anywhere else. */
|
|
31
|
+
export async function invoke(command, args) {
|
|
32
|
+
const core = g.__TAURI__?.core;
|
|
33
|
+
if (!core?.invoke) return null;
|
|
34
|
+
try {
|
|
35
|
+
return await core.invoke(command, args);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.warn(`[realtimeclipboard] native "${command}" failed:`, err);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Subscribe to an event the shell emits. Resolves to an unsubscribe function, or
|
|
44
|
+
* null off the desktop so callers can treat "no shell" as ordinary.
|
|
45
|
+
*/
|
|
46
|
+
export async function listen(event, fn) {
|
|
47
|
+
const api = g.__TAURI__?.event;
|
|
48
|
+
if (!api?.listen) return null;
|
|
49
|
+
try {
|
|
50
|
+
return await api.listen(event, ({ payload }) => fn(payload));
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.warn(`[realtimeclipboard] native listen "${event}" failed:`, err);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/core/paths.js
CHANGED
|
@@ -1,45 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Where the app is served from, resolved once for the whole codebase.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* stops the moment the deploy bundles `src/ui/*.js` into `src/main.js`: every
|
|
8
|
-
* one of those paths shifts by a directory level, all at once, and nothing
|
|
9
|
-
* throws. `install.js` in particular resolved the app root to the GitHub Pages
|
|
10
|
-
* ROOT rather than to `/RealtimeClipboard/`, which silently breaks the service-worker
|
|
11
|
-
* scope and the PWA install criteria — PRD OI-9, the failure its own comment
|
|
12
|
-
* warned about.
|
|
4
|
+
* From `document.baseURI`, never `import.meta.url` — see core/CLAUDE.md. A
|
|
5
|
+
* module's depth in the tree is not information it may depend on, and bundling
|
|
6
|
+
* shifts every such path at once without throwing.
|
|
13
7
|
*
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
17
|
-
* modules or as one bundle.
|
|
18
|
-
*
|
|
19
|
-
* !! The app root is the ORIGIN root — `new URL("/", …)`, not `new URL(".", …)`.
|
|
20
|
-
* That is a deliberate narrowing: this used to resolve the directory the current
|
|
21
|
-
* page sits in, so the app could be served from a subpath like
|
|
22
|
-
* `user.github.io/RealtimeClipboard/`. Subpath hosting is no longer supported, because
|
|
23
|
-
* src/pages/ publishes its pages one level up from where they sit on disk and
|
|
24
|
-
* therefore links to them root-absolutely — and a root-absolute link is wrong
|
|
25
|
-
* under a subpath by construction. Supporting both would mean two link styles
|
|
26
|
-
* in one site, which is how the depth bugs above happened in the first place.
|
|
27
|
-
*
|
|
28
|
-
* What that buys: the app's HTML no longer has to live at the app root for this
|
|
29
|
-
* to be correct, so a page's depth stops being load-bearing anywhere. What it
|
|
30
|
-
* costs: hosting under a path prefix. docs/SELF-HOSTING.md says so. !!
|
|
8
|
+
* APP_ROOT is the ORIGIN root, which is what retired subpath hosting: pages in
|
|
9
|
+
* `src/pages/` link root-absolutely, and a root-absolute link cannot be correct
|
|
10
|
+
* under a path prefix.
|
|
31
11
|
*/
|
|
32
12
|
|
|
33
|
-
|
|
34
|
-
* Guarded the same way `config.js` guards `location`: `core/` is imported by
|
|
35
|
-
* the node-based tests, where there is no document, and a bare reference would
|
|
36
|
-
* throw at import time and take the whole module graph down with it.
|
|
37
|
-
*/
|
|
13
|
+
// core/ is imported by the node tests, where there is no document.
|
|
38
14
|
const BASE = typeof document !== "undefined" && document.baseURI
|
|
39
15
|
? document.baseURI
|
|
40
16
|
: "http://localhost/";
|
|
41
17
|
|
|
42
|
-
/** The origin root, with trailing slash — `https://realtimeclipboard.com/`. */
|
|
43
18
|
export const APP_ROOT = new URL("/", BASE);
|
|
44
19
|
|
|
45
20
|
/** A file sitting beside `app.html`: `sw.js`, `manifest.webmanifest`, `changelog.json`. */
|
|
@@ -48,21 +23,9 @@ export const atRoot = name => new URL(name, APP_ROOT).href;
|
|
|
48
23
|
/**
|
|
49
24
|
* A stylesheet under `src/styles/lazy/`, fetched on first open.
|
|
50
25
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* and `styles/lazy/` is copied verbatim, so which loader a sheet belongs to is
|
|
56
|
-
* a fact about where it sits rather than about who happens to reference it.
|
|
57
|
-
* tools/build/build.mjs used to recover that by grepping every module for calls to
|
|
58
|
-
* this function; it copies the directory now. Pointing this at `styles/` would
|
|
59
|
-
* make an eager sheet loadable twice, once bundled and once over the wire. !!
|
|
60
|
-
*
|
|
61
|
-
* A sheet cannot be moved into `lazy/` on payload grounds alone. An injected
|
|
62
|
-
* <link> lands AFTER main.css, and main.css ends with mobile.css, which
|
|
63
|
-
* overrides earlier sheets at equal specificity and relies on source order to
|
|
64
|
-
* win. So anything mobile.css restyles has to stay eager whatever it costs —
|
|
65
|
-
* qr.css and history.css share ten classes with it and are the standing
|
|
66
|
-
* example.
|
|
26
|
+
* A sheet cannot move into `lazy/` on payload grounds alone: an injected <link>
|
|
27
|
+
* lands after main.css, which ends with mobile.css and relies on source order to
|
|
28
|
+
* win at equal specificity. Anything mobile.css restyles stays eager — qr.css
|
|
29
|
+
* and history.css are the standing example.
|
|
67
30
|
*/
|
|
68
31
|
export const lazyStyleHref = name => new URL(`src/styles/lazy/${name}`, APP_ROOT).href;
|
package/src/core/state.js
CHANGED
|
@@ -1,39 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Single source of truth for session state.
|
|
3
3
|
*
|
|
4
|
-
* Deliberately not reactive
|
|
5
|
-
*
|
|
6
|
-
* the data flow one-directional and greppable.
|
|
4
|
+
* Deliberately not reactive: modules mutate through the setters here and the bus
|
|
5
|
+
* announces the change. Nothing observes this object directly.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import { emit, EV } from "./bus.js";
|
|
10
|
-
import { DEFAULT_SYNC_MODE } from "./config.js";
|
|
9
|
+
import { DEFAULT_SYNC_MODE, SYNC_MODES, KEY, TEXT } from "./config.js";
|
|
10
|
+
import * as storage from "./storage.js";
|
|
11
11
|
|
|
12
12
|
const state = {
|
|
13
13
|
key: null,
|
|
14
14
|
roomHash: null,
|
|
15
15
|
aesKey: null,
|
|
16
|
+
locked: false,
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* honesty of the feature. A wrong PIN does not fail loudly: it derives a
|
|
21
|
-
* different, empty room, which looks exactly like being the first one to
|
|
22
|
-
* arrive. `verified` means something in this room actually decrypted, so we
|
|
23
|
-
* KNOW the PIN is right rather than assuming it.
|
|
18
|
+
* A wrong PIN does not fail loudly — it derives a different, empty room, which
|
|
19
|
+
* looks exactly like being first to arrive. `verified` means something here
|
|
20
|
+
* actually decrypted, so we KNOW the PIN is right rather than assuming it.
|
|
24
21
|
*/
|
|
25
|
-
locked: false,
|
|
26
22
|
verified: false,
|
|
27
23
|
/**
|
|
28
|
-
* Were we
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* state and must not be spelled `false`, or the lock button would be refused
|
|
32
|
-
* for the fraction of a second before the room reports itself and refused
|
|
33
|
-
* again for the whole of an offline session.
|
|
34
|
-
*
|
|
35
|
-
* Only locking reads it (see canLock). The relay's `existing` count is the
|
|
36
|
-
* source: it is the peers already present at the moment we joined.
|
|
24
|
+
* Were we first into this room? `null` until the relay's `welcome` answers.
|
|
25
|
+
* "Not yet known" must not be spelled `false`, or the lock button is refused
|
|
26
|
+
* for a moment on connect and for the whole of an offline session.
|
|
37
27
|
*/
|
|
38
28
|
founder: null,
|
|
39
29
|
authToken: null, // proves PIN knowledge to the relay; not a secret
|
|
@@ -45,32 +35,90 @@ const state = {
|
|
|
45
35
|
tier: "T1", // clipboard capture tier, see clipboard/capture.js
|
|
46
36
|
lastSent: "", // dedupe guard (FR-2.7)
|
|
47
37
|
suppressUntil: 0, // loop-suppression deadline (FR-2.6)
|
|
38
|
+
lastLocalCopyAt: 0, // a local copy outranks an arriving clip — see recentLocalCopy()
|
|
48
39
|
settings: {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
// Reading and writing the OS clipboard are both derived from this rung and
|
|
41
|
+
// neither gets a switch of its own — see config.js SYNC_MODES.
|
|
42
|
+
syncMode: DEFAULT_SYNC_MODE,
|
|
52
43
|
autoaccept: false,
|
|
53
44
|
thumbs: true,
|
|
54
45
|
images: true,
|
|
55
46
|
cursors: true,
|
|
47
|
+
longKeys: KEY.DEFAULT_LONG, // installed builds default to the longer key
|
|
48
|
+
closeToTray: true, // desktop only; the X button hides, Quit is in the tray
|
|
56
49
|
poll: "1s",
|
|
50
|
+
// Whether the share key may be written to localStorage so a relaunch offers
|
|
51
|
+
// the room back (FR-1.7). Defaults on because that is the behaviour that
|
|
52
|
+
// shipped and losing it silently would be a regression — but it is the one
|
|
53
|
+
// setting that decides whether a secret touches disk, so it is a switch and
|
|
54
|
+
// it is named plainly in the UI. See storage.js saveLastKey.
|
|
55
|
+
rememberKey: true,
|
|
57
56
|
},
|
|
58
57
|
};
|
|
59
58
|
|
|
60
59
|
export const get = () => state;
|
|
61
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Seed settings from storage. Runs first in boot(): resolveKey() asks how long
|
|
63
|
+
* the next key should be, and used to be answered from the defaults.
|
|
64
|
+
*
|
|
65
|
+
* A key absent from the saved object keeps its default above, which is what lets
|
|
66
|
+
* a setting be ADDED without turning it off for everyone who already saved.
|
|
67
|
+
*/
|
|
68
|
+
export function restore() {
|
|
69
|
+
const saved = storage.loadSettings();
|
|
70
|
+
if (saved) Object.assign(state.settings, saved);
|
|
71
|
+
|
|
72
|
+
migrateReceiving(saved);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Anyone who toggled any switch before this shipped has an explicit
|
|
76
|
+
* `longKeys: false` they never chose — persist() writes the whole object, so
|
|
77
|
+
* the old default was recorded as a decision. Gated on a marker so a later
|
|
78
|
+
* deliberate "off" stands. Only affects the NEXT key generated.
|
|
79
|
+
*/
|
|
80
|
+
if (KEY.DEFAULT_LONG && !storage.read("longKeyDefault")) {
|
|
81
|
+
state.settings.longKeys = true;
|
|
82
|
+
storage.write("longKeyDefault", KEY.LONG_LENGTH);
|
|
83
|
+
storage.saveSettings(state.settings);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Receiving used to be its own switch, defaulting on independently of the mode,
|
|
89
|
+
* so Manual stopped this machine SENDING while arriving clips still landed on
|
|
90
|
+
* its clipboard. Anyone who turned it off asked for one thing: nothing arriving
|
|
91
|
+
* touches their clipboard. Only Manual honours that, so they move there rather
|
|
92
|
+
* than being promoted to a rung that does what they opted out of.
|
|
93
|
+
*/
|
|
94
|
+
function migrateReceiving(saved) {
|
|
95
|
+
if (!saved) return;
|
|
96
|
+
if (!("autowrite" in saved) && !("autoread" in saved)) return;
|
|
97
|
+
|
|
98
|
+
if (saved.autowrite === false && state.settings.syncMode === SYNC_MODES.LIVE) {
|
|
99
|
+
state.settings.syncMode = SYNC_MODES.MANUAL;
|
|
100
|
+
}
|
|
101
|
+
delete state.settings.autowrite;
|
|
102
|
+
delete state.settings.autoread;
|
|
103
|
+
storage.saveSettings(state.settings);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Change a setting and remember it. The only path that persists one. */
|
|
107
|
+
export function saveSetting(name, value) {
|
|
108
|
+
state.settings[name] = value;
|
|
109
|
+
storage.saveSettings(state.settings);
|
|
110
|
+
emit(EV.SETTINGS_CHANGED, { name, value });
|
|
111
|
+
}
|
|
112
|
+
|
|
62
113
|
export function setKey({ key, roomHash, aesKey, locked = false, authToken = null }) {
|
|
63
114
|
state.key = key;
|
|
64
115
|
state.roomHash = roomHash ?? state.roomHash;
|
|
65
116
|
state.aesKey = aesKey ?? state.aesKey;
|
|
66
117
|
state.locked = locked;
|
|
67
118
|
state.authToken = authToken;
|
|
68
|
-
// A new key is a new room
|
|
69
|
-
//
|
|
119
|
+
// A new key is a new room, so both of these are re-asked. Left standing, the
|
|
120
|
+
// founder of one session would carry the right to lock into the next.
|
|
70
121
|
state.verified = false;
|
|
71
|
-
// Likewise "we were first" — asked and answered per room. Left standing, the
|
|
72
|
-
// founder of one session would carry the right to lock into the next one it
|
|
73
|
-
// walked into, which is precisely the device that must not have it.
|
|
74
122
|
state.founder = null;
|
|
75
123
|
emit(EV.KEY_CHANGED, { key, locked });
|
|
76
124
|
emit(EV.LOCK_STATE, { locked, verified: false });
|
|
@@ -78,12 +126,9 @@ export function setKey({ key, roomHash, aesKey, locked = false, authToken = null
|
|
|
78
126
|
}
|
|
79
127
|
|
|
80
128
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* relay restart — which empties every room (OI-13) — hands the title to
|
|
85
|
-
* whoever reconnects into the empty room first, rather than to whoever held it
|
|
86
|
-
* before the room stopped existing.
|
|
129
|
+
* Fed from `welcome.existing`. Re-answered on every welcome, so a relay restart
|
|
130
|
+
* — which empties every room (OI-13) — hands the title to whoever reconnects
|
|
131
|
+
* first rather than to whoever held it before the room stopped existing.
|
|
87
132
|
*/
|
|
88
133
|
export function setFounder(first) {
|
|
89
134
|
const next = first === null ? null : !!first;
|
|
@@ -95,17 +140,14 @@ export function setFounder(first) {
|
|
|
95
140
|
/**
|
|
96
141
|
* May THIS device lock the session?
|
|
97
142
|
*
|
|
98
|
-
* Alone, anyone may
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* for taking a session over, and the person who started it is the one who
|
|
103
|
-
* chose to share the key in the first place.
|
|
143
|
+
* Alone, anyone may. With company, only the device that opened the room, because
|
|
144
|
+
* locking moves the session to a different room and evicts everybody else
|
|
145
|
+
* (LOCK.EVICT) — a control that lets any arrival do that is a control for taking
|
|
146
|
+
* a session over.
|
|
104
147
|
*
|
|
105
148
|
* A rule the UI keeps, not one the relay enforces: every device in the room
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* description of this is "the app will not help you do it", not "you cannot".
|
|
149
|
+
* holds the key, so a modified client could send the goodbye itself. The honest
|
|
150
|
+
* description is "the app will not help you", not "you cannot".
|
|
109
151
|
*/
|
|
110
152
|
export function canLock() {
|
|
111
153
|
if (state.locked) return false;
|
|
@@ -114,11 +156,9 @@ export function canLock() {
|
|
|
114
156
|
}
|
|
115
157
|
|
|
116
158
|
/**
|
|
117
|
-
* Record that this device can actually read this room.
|
|
118
|
-
*
|
|
119
159
|
* Set from the first thing that decrypts — the beacon replayed in `welcome`, or
|
|
120
|
-
* any real frame. Only
|
|
121
|
-
*
|
|
160
|
+
* any real frame. Only moves false -> true; setKey resets it, because a
|
|
161
|
+
* different room is a different question.
|
|
122
162
|
*/
|
|
123
163
|
export function setVerified() {
|
|
124
164
|
if (!state.locked || state.verified) return;
|
|
@@ -132,15 +172,10 @@ export function setConnection(connection, detail = "") {
|
|
|
132
172
|
}
|
|
133
173
|
|
|
134
174
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* tells you someone else has it, and a count quietly going 2 → 3 is not
|
|
140
|
-
* something anyone notices.
|
|
141
|
-
*
|
|
142
|
-
* The first roster after connecting is not announced: those devices were
|
|
143
|
-
* already there, and greeting them as arrivals would cry wolf on every reload.
|
|
175
|
+
* Diffed rather than counted, so arrivals can be announced: the key is a bearer
|
|
176
|
+
* credential, and a device appearing is the one observable moment telling you
|
|
177
|
+
* someone else has it. The first roster after connecting is not announced —
|
|
178
|
+
* those devices were already there, and greeting them would cry wolf on reload.
|
|
144
179
|
*/
|
|
145
180
|
let roster = null;
|
|
146
181
|
|
|
@@ -167,9 +202,9 @@ export function setPeers(count, list = []) {
|
|
|
167
202
|
export function resetRoster() { roster = null; }
|
|
168
203
|
|
|
169
204
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
205
|
+
* A changed instance id means we may have landed on a different replica where
|
|
206
|
+
* our peers do not exist. Loud, not silent — a quiet failure here looks exactly
|
|
207
|
+
* like "the network is slow".
|
|
173
208
|
*/
|
|
174
209
|
export function setInstance(instance) {
|
|
175
210
|
const previous = state.instance;
|
|
@@ -191,3 +226,13 @@ export function setSetting(name, value) {
|
|
|
191
226
|
/** Mute local capture briefly after applying a remote clip (FR-2.6). */
|
|
192
227
|
export function suppress(ms) { state.suppressUntil = Date.now() + ms; }
|
|
193
228
|
export function isSuppressed() { return Date.now() < state.suppressUntil; }
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Precedence, not loop-suppression: for a few seconds after you copy something
|
|
232
|
+
* here, that is what you are about to paste, and an arriving clip does not get
|
|
233
|
+
* to take it away.
|
|
234
|
+
*/
|
|
235
|
+
export function markLocalCopy() { state.lastLocalCopyAt = Date.now(); }
|
|
236
|
+
export function recentLocalCopy() {
|
|
237
|
+
return Date.now() - state.lastLocalCopyAt < TEXT.LOCAL_COPY_GRACE_MS;
|
|
238
|
+
}
|