realtimeclipboard 0.3.0 → 0.4.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # RealtimeClipboard — an end-to-end encrypted online clipboard that syncs text between devices
2
2
 
3
3
  RealtimeClipboard is a free, open-source online clipboard: open it on two devices, type the
4
- same five-character key, and whatever you copy on one is ready to paste on the
4
+ same short key, and whatever you copy on one is ready to paste on the
5
5
  other. No account, no install, no database. Files travel peer-to-peer and never
6
6
  touch the server.
7
7
 
@@ -28,7 +28,8 @@ is proven, and [Known limitations](#known-limitations) for what is not.
28
28
 
29
29
  - **Sync clipboard text between devices** — Windows, macOS, Android, ChromeOS and Linux
30
30
  - **Works across different networks**, not just the same Wi-Fi, and not just the same LAN
31
- - **No account, no sign-up, no email** — a five-character key is the whole identity of a session
31
+ - **No account, no sign-up, no email** — a short key is the whole identity of a session
32
+ (six characters on the web, ten in the installed apps, and either works on both)
32
33
  - **End-to-end encrypted** in the browser with AES-GCM; `PBKDF2` derives the key, `SHA-256` routes the room
33
34
  - **Peer-to-peer file transfer** over a WebRTC data channel, 5 MB per file
34
35
  - **Copy and paste images** — a screenshot copied on one machine previews on the other
@@ -40,7 +41,7 @@ is proven, and [Known limitations](#known-limitations) for what is not.
40
41
 
41
42
  Moving a snippet between a work laptop, a desktop and a phone is
42
43
  disproportionately annoying. The alternatives want an account, an install with
43
- admin rights, or an email to yourself. This wants a five-character key.
44
+ admin rights, or an email to yourself. This wants a short key.
44
45
 
45
46
  ## How RealtimeClipboard compares to Snapdrop, PairDrop, LocalSend and AirDrop
46
47
 
@@ -85,7 +86,7 @@ with a short key, and share a single clipboard between them.
85
86
 
86
87
  ### How do I sync my clipboard between my phone and my PC?
87
88
 
88
- Open RealtimeClipboard on both, type the same five-character key on each, and copy
89
+ Open RealtimeClipboard on both, type the same short key on each, and copy
89
90
  something. It arrives on the other device ready to paste. Nothing to install, so
90
91
  it works on a machine where you do not have admin rights.
91
92
 
@@ -55,7 +55,14 @@ const VERSION = JSON.parse(
55
55
  /* ------------------------------------------------------------------ args -- */
56
56
 
57
57
  function parse(args) {
58
- const opts = { relay: null, pin: null, once: false, json: false, timeout: 0, quiet: false };
58
+ const opts = {
59
+ relay: null, pin: null, once: false, json: false, timeout: 0, quiet: false,
60
+ // A CLI key gets pasted into shells, scripts and CI configs, where length is
61
+ // friction with no QR code to fall back on — so unlike the installed desktop
62
+ // app this stays short unless asked. REALTIMECLIPBOARD_LONG is the way to
63
+ // ask once, for a machine that generates a lot of them.
64
+ long: !!process.env.REALTIMECLIPBOARD_LONG,
65
+ };
59
66
  const rest = [];
60
67
  for (let i = 0; i < args.length; i++) {
61
68
  const a = args[i];
@@ -86,6 +93,8 @@ Options
86
93
  --relay <url> relay to use (default ${DEFAULT_RELAY_URL})
87
94
  or set REALTIMECLIPBOARD_RELAY
88
95
  --pin <pin> join a locked session (or set REALTIMECLIPBOARD_PIN)
96
+ --long new: a 10-character key (~49 bits) instead of 6 (~29 bits)
97
+ or set REALTIMECLIPBOARD_LONG
89
98
  --once watch: exit after the first clip
90
99
  --json one JSON object per line instead of raw text
91
100
  --timeout <s> give up after this many seconds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtimeclipboard",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Live clipboard sharing. Static frontend of native ES modules — development needs no build, and `npm run build` exists only to assemble the deploy. Nothing here is needed to READ the app.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,7 +34,7 @@
34
34
  "node": ">=22"
35
35
  },
36
36
  "scripts": {
37
- "verify": "node tests/unit/static-check.mjs && node tests/unit/relay-url.mjs && node tests/unit/lock.mjs && node tests/unit/files.mjs && node tests/unit/transfer.mjs && node tests/unit/clipsize.mjs && node tests/dom/dialog.mjs && node tests/dom/whatsnew.mjs && node tests/dom/tiles.mjs",
37
+ "verify": "node tests/unit/static-check.mjs && node tests/unit/relay-url.mjs && node tests/unit/lock.mjs && node tests/unit/files.mjs && node tests/unit/transfer.mjs && node tests/unit/clipsize.mjs && node tests/unit/syncmode.mjs && node tests/dom/dialog.mjs && node tests/dom/whatsnew.mjs && node tests/dom/tiles.mjs && node tests/dom/guide.mjs && node tests/dom/editor.mjs && node tests/dom/offer.mjs",
38
38
  "test": "npm run verify && node tests/dom/bundle.mjs && node tests/live/e2e.mjs && node tests/live/boot.mjs && node tests/live/boot.mjs --locked && node tests/live/fallback.mjs",
39
39
  "test:static": "node tests/unit/static-check.mjs",
40
40
  "test:lock": "node tests/unit/lock.mjs && node tests/dom/dialog.mjs",
package/src/core/bus.js CHANGED
@@ -31,8 +31,18 @@ export function emit(event, payload) {
31
31
  /** Canonical event names. Typos here are silent bugs, so use the constants. */
32
32
  export const EV = {
33
33
  // clipboard
34
- TEXT_CAPTURED: "text:captured", // {text, how} — local capture, needs sending
35
- TEXT_RECEIVED: "text:received", // {text, from} — arrived from a peer
34
+ TEXT_CAPTURED: "text:captured", // {text, how} — a clip settled here, needs sending
35
+ TEXT_RECEIVED: "text:received", // {text, from} — a clip arrived from a peer
36
+ /**
37
+ * Typing, not clips. {text, caret} out, {text, caret, name, from} in.
38
+ *
39
+ * The pair above is the COMMIT channel: a clip that settled, bound for
40
+ * history, the OS clipboard and the size cap. This pair is the VIEW channel —
41
+ * what the text looks like mid-keystroke. Keeping them apart is what stops a
42
+ * streamed sentence becoming six history entries and six clipboard writes.
43
+ */
44
+ TEXT_TYPED: "text:typed",
45
+ TEXT_STREAMED: "text:streamed",
36
46
  TIER_CHANGED: "clipboard:tier", // {tier, note}
37
47
  PENDING_CLIP: "clipboard:pending",// {pending, text} — arrived while unfocused
38
48
  CLIP_OFFERED: "clipboard:offered",// {text} — arrived, but the editor has unsent work
@@ -70,6 +80,13 @@ export const EV = {
70
80
  * way; the bus does no namespacing of its own and a collision is silent.
71
81
  */
72
82
  LOCK_STATE: "session:lockstate",
83
+ /**
84
+ * {required} — a locked link is open and its PIN has not been given, so there
85
+ * is no session and nothing behind the UI is connected to anything. Reported
86
+ * separately from LOCK_STATE because `state.locked` is still false at that
87
+ * point: no session was ever opened to be locked.
88
+ */
89
+ LOCK_REQUIRED: "session:lockrequired",
73
90
  FOUNDER: "session:founder", // {founder} — first into this room? null = not yet known
74
91
 
75
92
  // files
@@ -3,6 +3,8 @@
3
3
  * if you find a magic number elsewhere, it belongs here.
4
4
  */
5
5
 
6
+ import { IS_DESKTOP } from "./native.js";
7
+
6
8
  /**
7
9
  * Relay endpoint.
8
10
  *
@@ -153,6 +155,46 @@ export const TEXT = {
153
155
  MAX_BYTES: CLIP_MAX_BYTES, // the wire limit — authoritative
154
156
  MAX_CHARS: Math.floor(CLIP_MAX_BYTES / 1000) * 1000, // the friendly one, for the counter
155
157
  SUPPRESS_MS: 1500, // loop-suppression window after applying a remote clip (FR-2.6)
158
+
159
+ /**
160
+ * Typing is streamed for the far editor to render; a CLIP is still a discrete
161
+ * thing, made when the text settles. The split is what keeps history, the OS
162
+ * clipboard write, the dedupe and the size cap resting on whole thoughts
163
+ * instead of on `h`, `he`, `hel`.
164
+ *
165
+ * COMMIT_IDLE_MS is generous because the commit is invisible: the text is
166
+ * already on the far screen via the stream, so this only decides when it
167
+ * becomes a clip. A mid-sentence thinking pause is 300-500 ms, and anything
168
+ * under that would fragment one sentence into six history entries.
169
+ */
170
+ COMMIT_IDLE_MS: 800,
171
+
172
+ /** 10 sends/sec — half the relay's 20/s `stream` budget, as cursors.js does. */
173
+ STREAM_THROTTLE_MS: 100,
174
+
175
+ /**
176
+ * Above this, typing is not streamed at all and the text syncs on commit only.
177
+ *
178
+ * A stream frame carries the WHOLE text, which is what lets this feature exist
179
+ * without diffs, positions or operational transform. That trade only holds
180
+ * while the text is small: re-sending 20 KB per keystroke would be 200 KB/s
181
+ * through the relay for one person editing a stack trace.
182
+ *
183
+ * The degradation is invisible because big text is pasted, never typed —
184
+ * nobody collaboratively types a stack trace — so the case streaming is for
185
+ * (a URL, a token, a sentence) is entirely inside this bound.
186
+ */
187
+ STREAM_MAX_BYTES: 4096,
188
+
189
+ /**
190
+ * How long a local copy outranks an arriving clip.
191
+ *
192
+ * Two devices in Clipboard mode both being used by a human means every copy on
193
+ * one overwrites the other's system clipboard, and the moment that actually
194
+ * hurts is "I copied something, went to paste it, and it was gone". Inside
195
+ * this window the arriving clip is queued and offered instead of written.
196
+ */
197
+ LOCAL_COPY_GRACE_MS: 10_000,
156
198
  };
157
199
 
158
200
  export const FILES = {
@@ -204,8 +246,19 @@ export const FILES = {
204
246
  export const KEY = {
205
247
  // Crockford-ish: no 0/O, no 1/I/L. Ambiguity here becomes a support ticket.
206
248
  ALPHABET: "23456789ABCDEFGHJKMNPQRSTVWXYZ",
207
- LENGTH: 6, // PRD D3
208
- LONG_LENGTH: 10, // "high security" option
249
+ LENGTH: 6, // PRD D3 — the web default
250
+ LONG_LENGTH: 10, // "high security" option, PRD §7.3
251
+
252
+ /**
253
+ * Installed builds default to the long key; the web keeps six.
254
+ *
255
+ * PRD §7.3's argument for six characters is convenience, and the convenience
256
+ * is a phone keyboard. An installed app links a device by QR or by a copied
257
+ * link, so it is the one surface that does not pay the typing cost the short
258
+ * key buys — and it is the surface running unattended all day reading every
259
+ * copy, which is the case §7.3 says should take the 10-character option.
260
+ */
261
+ DEFAULT_LONG: IS_DESKTOP,
209
262
  };
210
263
 
211
264
  export const CRYPTO = {
@@ -351,25 +404,38 @@ export const NET = {
351
404
  export const POLL_OPTIONS = { "Off": 0, "500ms": 500, "1s": 1000, "2s": 2000 };
352
405
 
353
406
  /**
354
- * How much of the OS clipboard the app takes on itself.
407
+ * How far the sync reaches on THIS device. One ladder, three rungs, each adding
408
+ * exactly one thing to the one below it:
409
+ *
410
+ * off — nothing leaves and nothing arrives. The editor is private again.
411
+ * manual — the session syncs in the app window. The OS clipboard is neither
412
+ * read nor written; the app is a window onto the room.
413
+ * live — the OS clipboard is wired to the room in both directions. What you
414
+ * copy anywhere goes out; what arrives is copied here.
355
415
  *
356
- * live — anything you copy anywhere is picked up when this window has
357
- * focus, and sent. The default, and what "shared clipboard" means.
358
- * manual nothing leaves this machine until you paste it in here or press
359
- * Send. Receiving is unaffected.
416
+ * The room itself is unconditional: every connected device sees every clip in
417
+ * the app view whatever rung it is on. The rung only decides how deep into the
418
+ * machine the connection goes, which is why the labels name the destination
419
+ * (Off / App / Clipboard) rather than a manner of working.
360
420
  *
361
- * Manual exists because "live" means every password, token and private
362
- * message you copy for any reason goes to every device in the session. That is
363
- * the point of the product, and it is also a lot of trust to extend
364
- * permanently someone on a shared or work machine may want the sharing to be
365
- * a deliberate act.
421
+ * The stored strings are a compatibility surface. "live" and "manual" are on
422
+ * disk for every existing user under STORAGE_PREFIX, and relabelling the UI is
423
+ * free while renaming these silently resets the one preference where being
424
+ * wrong matters most. Change the labels in ui/features/syncMode.js instead.
366
425
  */
367
426
  export const SYNC_MODES = {
368
- LIVE: "live",
427
+ OFF: "off",
369
428
  MANUAL: "manual",
429
+ LIVE: "live",
370
430
  };
371
431
  export const DEFAULT_SYNC_MODE = SYNC_MODES.LIVE;
372
432
 
433
+ /** Does this rung let the OS clipboard be read or written? */
434
+ export const bindsClipboard = (mode) => mode === SYNC_MODES.LIVE;
435
+
436
+ /** Does this rung put anything on the wire at all? */
437
+ export const sharesSession = (mode) => mode !== SYNC_MODES.OFF;
438
+
373
439
  /**
374
440
  * The project's own addresses: where the code is, and where it asks for help.
375
441
  *
@@ -406,6 +472,18 @@ export const LINKS = {
406
472
  SPONSOR: `https://github.com/sponsors/${REPO.OWNER}`,
407
473
  };
408
474
 
475
+ /**
476
+ * The app offer in the header — the desktop build and the PWA install, sharing
477
+ * one quiet row (ui/features/install.js).
478
+ *
479
+ * A minute is long enough to read a sentence you did not ask for, finish the
480
+ * paste you came here for, and come back to it; short enough that the header is
481
+ * the header again by the time you next look up. Only the × is remembered — a
482
+ * row that timed out has not been declined, so the offer returns on a later
483
+ * visit.
484
+ */
485
+ export const OFFER = { DISMISS_MS: 60_000 };
486
+
409
487
  export const IMAGES = {
410
488
  /** Clipboard image types we will read and share. */
411
489
  TYPES: ["image/png", "image/jpeg", "image/webp", "image/gif"],
@@ -18,16 +18,25 @@
18
18
 
19
19
  import { read, write } from "./storage.js";
20
20
 
21
+ /**
22
+ * Windows | Android | iOS | macOS | Linux | Unknown.
23
+ *
24
+ * Exported because the desktop guide describes the tray icon, and the tray
25
+ * behaves differently enough on each platform that one paragraph covering all
26
+ * three would be wrong on two of them.
27
+ */
28
+ export function os() {
29
+ const ua = globalThis.navigator?.userAgent ?? "";
30
+ return /Windows/i.test(ua) ? "Windows" :
31
+ /Android/i.test(ua) ? "Android" :
32
+ /iPhone|iPad|iPod/i.test(ua) ? "iOS" :
33
+ /Mac OS X|Macintosh/i.test(ua) ? "macOS" :
34
+ /Linux/i.test(ua) ? "Linux" : "Unknown";
35
+ }
36
+
21
37
  function detect() {
22
38
  const ua = navigator.userAgent;
23
39
 
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
40
  // Order matters: Edge and Opera both contain "Chrome", Chrome contains "Safari".
32
41
  const browser =
33
42
  /Edg\//i.test(ua) ? "Edge" :
@@ -36,7 +45,7 @@ function detect() {
36
45
  /Chrome\//i.test(ua) ? "Chrome" :
37
46
  /Safari\//i.test(ua) ? "Safari" : "Browser";
38
47
 
39
- return `${browser} · ${os}`;
48
+ return `${browser} · ${os()}`;
40
49
  }
41
50
 
42
51
  /** Persisted so a device keeps its name across reloads, and stays renameable. */
package/src/core/keys.js CHANGED
@@ -1,6 +1,7 @@
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
  /**
6
7
  * Cryptographically random key from the unambiguous alphabet.
@@ -32,6 +33,17 @@ export function entropyBits(length) {
32
33
 
33
34
  export const LENGTHS = { NORMAL: KEY.LENGTH, LONG: KEY.LONG_LENGTH };
34
35
 
36
+ /**
37
+ * How long the NEXT key should be.
38
+ *
39
+ * One implementation, because there were three and two of them ignored the
40
+ * setting entirely: the first-run key and the collision retry both emitted six
41
+ * characters however the app was configured — which is precisely the two places
42
+ * a key is generated FOR the user rather than at their request.
43
+ */
44
+ export const nextLength = () =>
45
+ state.get().settings.longKeys ? LENGTHS.LONG : LENGTHS.NORMAL;
46
+
35
47
  /**
36
48
  * Normalise before ANY use — hashing, comparison, display.
37
49
  *
@@ -0,0 +1,59 @@
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
5
+ * in config.js — and core/ is imported by the node tests and shipped in the npm
6
+ * package that cli/ runs on.
7
+ *
8
+ * Before this module existed, three files feature-tested `globalThis.__TAURI__`
9
+ * independently and all three failed the same way at once: `withGlobalTauri`
10
+ * had never been switched on in tauri.conf.json, so the global did not exist,
11
+ * T0 never started, and the desktop app silently degraded to a browser tab that
12
+ * cannot watch the clipboard. One owner, one feature test, one place to look.
13
+ */
14
+
15
+ const g = globalThis;
16
+
17
+ /**
18
+ * `__TAURI_INTERNALS__` is injected into every Tauri webview; `__TAURI__` only
19
+ * when `withGlobalTauri` is on. Both are checked so the SURFACE answer stays
20
+ * correct even if that flag is ever turned off again — which is exactly the
21
+ * failure this module was written after.
22
+ */
23
+ const IN_TAURI = typeof g.__TAURI_INTERNALS__ === "object"
24
+ || typeof g.__TAURI__ === "object";
25
+
26
+ /** `document` first: jsdom has both, and the DOM suites want to be "web". */
27
+ const IN_NODE = typeof document === "undefined" && !!g.process?.versions?.node;
28
+
29
+ export const SURFACE = IN_TAURI ? "desktop" : IN_NODE ? "cli" : "web";
30
+ export const IS_DESKTOP = SURFACE === "desktop";
31
+ export const IS_WEB = SURFACE === "web";
32
+ export const IS_CLI = SURFACE === "cli";
33
+
34
+ /** Call a command in desktop/src-tauri/src/main.rs. Null anywhere else. */
35
+ export async function invoke(command, args) {
36
+ const core = g.__TAURI__?.core;
37
+ if (!core?.invoke) return null;
38
+ try {
39
+ return await core.invoke(command, args);
40
+ } catch (err) {
41
+ console.warn(`[realtimeclipboard] native "${command}" failed:`, err);
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Subscribe to an event the shell emits. Resolves to an unsubscribe function,
48
+ * or null off the desktop so callers can treat "no shell" as ordinary.
49
+ */
50
+ export async function listen(event, fn) {
51
+ const api = g.__TAURI__?.event;
52
+ if (!api?.listen) return null;
53
+ try {
54
+ return await api.listen(event, ({ payload }) => fn(payload));
55
+ } catch (err) {
56
+ console.warn(`[realtimeclipboard] native listen "${event}" failed:`, err);
57
+ return null;
58
+ }
59
+ }
package/src/core/state.js CHANGED
@@ -7,7 +7,8 @@
7
7
  */
8
8
 
9
9
  import { emit, EV } from "./bus.js";
10
- import { DEFAULT_SYNC_MODE } from "./config.js";
10
+ import { DEFAULT_SYNC_MODE, SYNC_MODES, KEY, TEXT } from "./config.js";
11
+ import * as storage from "./storage.js";
11
12
 
12
13
  const state = {
13
14
  key: null,
@@ -45,20 +46,89 @@ const state = {
45
46
  tier: "T1", // clipboard capture tier, see clipboard/capture.js
46
47
  lastSent: "", // dedupe guard (FR-2.7)
47
48
  suppressUntil: 0, // loop-suppression deadline (FR-2.6)
49
+ lastLocalCopyAt: 0, // a local copy outranks an arriving clip — see recentLocalCopy()
48
50
  settings: {
49
- syncMode: DEFAULT_SYNC_MODE, // live | manual — see config.js
50
- autowrite: true,
51
- autoread: true,
51
+ // off | manual | live one ladder, see config.js SYNC_MODES. Reading and
52
+ // writing the OS clipboard are both derived from this and neither has a
53
+ // switch of its own: two controls over one behaviour eventually disagree,
54
+ // and then the app looks like it is ignoring its own setting.
55
+ syncMode: DEFAULT_SYNC_MODE,
52
56
  autoaccept: false,
53
57
  thumbs: true,
54
58
  images: true,
55
59
  cursors: true,
60
+ longKeys: KEY.DEFAULT_LONG, // installed builds default to 10 chars
61
+ closeToTray: true, // desktop only; the X button hides, Quit is in the tray
56
62
  poll: "1s",
57
63
  },
58
64
  };
59
65
 
60
66
  export const get = () => state;
61
67
 
68
+ /**
69
+ * Seed settings from storage.
70
+ *
71
+ * Runs first in boot(), and the ordering is the point: resolveKey() asks how
72
+ * long the next key should be, and used to be answered from the defaults
73
+ * because the panel that restored preferences had not initialised yet.
74
+ *
75
+ * A key absent from the saved object keeps its default above. That is what
76
+ * lets a setting be ADDED without silently turning it off for everyone who
77
+ * saved their preferences under an earlier build.
78
+ */
79
+ export function restore() {
80
+ const saved = storage.loadSettings();
81
+ if (saved) Object.assign(state.settings, saved);
82
+
83
+ migrateReceiving(saved);
84
+
85
+ /**
86
+ * One-time move to the long key on installed builds.
87
+ *
88
+ * Anyone who toggled any switch before this shipped has an explicit
89
+ * `longKeys: false` they never chose — persist() writes the whole settings
90
+ * object, so the old default was recorded as a decision. Gated on a marker so
91
+ * it happens once and a later deliberate "off" stands.
92
+ *
93
+ * Nothing existing is invalidated: this only changes the NEXT key generated.
94
+ */
95
+ if (KEY.DEFAULT_LONG && !storage.read("longKeyDefault")) {
96
+ state.settings.longKeys = true;
97
+ storage.write("longKeyDefault", KEY.LONG_LENGTH);
98
+ storage.saveSettings(state.settings);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Receiving used to be its own switch, defaulting on and independent of the
104
+ * mode, so Manual stopped this machine SENDING while incoming clips still
105
+ * landed on its system clipboard. Both directions are the mode's now.
106
+ *
107
+ * Anyone who turned that switch off asked for exactly one thing: nothing
108
+ * arriving should touch their clipboard. On the new ladder only Manual honours
109
+ * that, so they are moved there rather than silently promoted to a Live rung
110
+ * that does the thing they opted out of. The dead keys go with them, which is
111
+ * also what makes this run once — after the next save they are not on disk.
112
+ */
113
+ function migrateReceiving(saved) {
114
+ if (!saved) return;
115
+ if (!("autowrite" in saved) && !("autoread" in saved)) return;
116
+
117
+ if (saved.autowrite === false && state.settings.syncMode === SYNC_MODES.LIVE) {
118
+ state.settings.syncMode = SYNC_MODES.MANUAL;
119
+ }
120
+ delete state.settings.autowrite;
121
+ delete state.settings.autoread;
122
+ storage.saveSettings(state.settings);
123
+ }
124
+
125
+ /** Change a setting and remember it. The only path that persists one. */
126
+ export function saveSetting(name, value) {
127
+ state.settings[name] = value;
128
+ storage.saveSettings(state.settings);
129
+ emit(EV.SETTINGS_CHANGED, { name, value });
130
+ }
131
+
62
132
  export function setKey({ key, roomHash, aesKey, locked = false, authToken = null }) {
63
133
  state.key = key;
64
134
  state.roomHash = roomHash ?? state.roomHash;
@@ -191,3 +261,16 @@ export function setSetting(name, value) {
191
261
  /** Mute local capture briefly after applying a remote clip (FR-2.6). */
192
262
  export function suppress(ms) { state.suppressUntil = Date.now() + ms; }
193
263
  export function isSuppressed() { return Date.now() < state.suppressUntil; }
264
+
265
+ /**
266
+ * When this machine last put something on its own clipboard.
267
+ *
268
+ * Distinct from `suppressUntil`, which is about not echoing our own writes back
269
+ * to the room. This is about precedence: for a few seconds after you copy
270
+ * something here, what you copied is what you are about to paste, and a clip
271
+ * arriving from another device does not get to take it away.
272
+ */
273
+ export function markLocalCopy() { state.lastLocalCopyAt = Date.now(); }
274
+ export function recentLocalCopy() {
275
+ return Date.now() - state.lastLocalCopyAt < TEXT.LOCAL_COPY_GRACE_MS;
276
+ }
@@ -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,33 @@ 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, and the distinction from
39
+ * `clip` is the whole design:
40
+ *
41
+ * clip — a discrete thing that settled. Goes to history, to the OS
42
+ * clipboard, through the dedupe and against the size cap. Retained
43
+ * by the relay and replayed to late joiners (FR-3.3).
44
+ * stream — what the text looks like right now. Renders and nothing else. Not
45
+ * retained, not replayed, never written to anyone's clipboard.
46
+ *
47
+ * It carries the WHOLE text rather than a diff, which is what lets live typing
48
+ * exist here without positions or operational transform. That trade is bounded
49
+ * by TEXT.STREAM_MAX_BYTES — above it the editor stops streaming and the text
50
+ * syncs on commit only, because re-sending 20 KB per keystroke is not a thing
51
+ * anybody should do to a relay.
52
+ *
53
+ * `name` rides along for the same reason it does on a cursor frame: nothing
54
+ * replays these, so a device that joins mid-sentence would otherwise render an
55
+ * unattributed caret until the next roster frame happened to arrive.
56
+ *
57
+ * Everything but `t` and `originId` is sealed by main.js encryptFrame(), so the
58
+ * relay forwards a blob and never sees a keystroke.
59
+ */
60
+ export const stream = ({ text, caret, name, originId }) => ({
61
+ t: T.STREAM, text, caret, name, originId,
62
+ });
63
+
36
64
  export const ping = () => ({ t: T.PING });
37
65
 
38
66
  export const fileMeta = ({ id, name, size, type, thumb, originId }) => ({