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/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, given the 30-letter alphabet.
13
+ * Bits of entropy in a key of this length: 6 chars ≈ 29.4, 10 chars ≈ 49.1.
20
14
  *
21
- * 6 chars ≈ 29.4 bits — the default. Convenient, and brute-forceable
22
- * offline by anyone who captured ciphertext.
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
- * This matters more than it looks: the room name is a hash of the key, and
39
- * "D75LV" and "d75lv" hash differently. Skipping this silently drops two users
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 exists so generated keys are unambiguous when read aloud or
51
- * retyped — it is a constraint on what we PRODUCE. Validation must accept
52
- * anything a peer might legitimately hand us, because:
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, estimated from the character classes it actually
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, which is the only assumption worth making about someone running an
72
- * offline attack. "123456" is counted as six digits, not six printable ASCII
73
- * characters, so the dialog reports ~20 bits and not a flattering ~39.
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
- The fragment
92
-
93
- A locked session's link carries a marker but never the PIN: `#!ABCDEF`.
94
- That a session is locked is not a secret — the PIN is — and the app has to
95
- know before it derives anything, because the marker is what decides which of
96
- two completely different derivations to run.
97
-
98
- Parsing happens BEFORE normalise(), and that ordering is load-bearing:
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
- /** Build one. The inverse of parseFragment, and tested as such. */
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
- /** Read the key from the URL fragment. The fragment is never sent to a server. */
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
- * For the one case where the session in the URL is not merely over but closed
135
- * to this device: it was locked by somebody else and we were removed from it
136
- * (main.js onEvicted). The fragment is what boot() reads first, so leaving it
137
- * in place means every reload rejoins a room we have been ejected from and
138
- * gets ejected again.
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
- * replaceState rather than `location.hash = ""`, which leaves a bare "#" on the
141
- * URL and pushes a history entry — so Back would put the dead key straight
142
- * back.
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
- * Six modules used to compute this themselves with `new URL("../…",
5
- * import.meta.url)` — each one hard-coding how deep its own file sits in the
6
- * tree. That works exactly as long as every module keeps its own file, and
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
- * A module's depth in the tree is not information any module should depend on.
15
- * `document.baseURI` is: it is what the browser already resolved every relative
16
- * href on the page against, so it is right whether the code arrives as forty
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
- * Lazy sheets stay OUT of the bundle on purpose — a QR modal's stylesheet has
52
- * no business in the critical path of an app most people never open it in.
53
- *
54
- * !! The directory is the whole contract. `styles/` is bundled into main.css
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. Modules mutate through the setters here and the
5
- * bus announces the change; nothing observes this object directly. That keeps
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
- * Locked session — a PIN outside the link (core/crypto.js).
18
- *
19
- * `verified` is a separate fact from `locked` and the difference is the whole
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 the first device into this room?
29
- *
30
- * `null` until the relay's `welcome` answers it — "not yet known" is a third
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
- syncMode: DEFAULT_SYNC_MODE, // live | manual — see config.js
50
- autowrite: true,
51
- autoread: true,
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: whatever we had proved about the old one does not
69
- // carry over, and claiming otherwise would leave a stale padlock on screen.
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
- * Record whether this device was the first one into the room.
82
- *
83
- * Fed from `welcome.existing` in main.js. Re-answered on every welcome, so a
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: there is nobody to be thrown out. With company, only the
99
- * device that opened the room, because locking is not a setting — it moves the
100
- * session to a different room and removes everybody else from it (see
101
- * LOCK.EVICT). A control that lets any arrival do that to the rest is a control
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
- * already holds the key, so a modified client could send the goodbye itself.
107
- * That is not a new power — it could equally read every clip — and the honest
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 ever moves false -> true within a session; setKey resets
121
- * it, because a different room is a different question.
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
- * Peer roster.
136
- *
137
- * Diffed rather than just counted, so arrivals can be announced. The key is a
138
- * bearer credential — a device appearing is the one observable moment that
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
- * The relay keeps rooms in process memory, so a changed instance id means we
171
- * may have landed on a different replica where our peers do not exist. Loud,
172
- * not silent — a quiet failure here looks exactly like "the network is slow".
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
+ }