realtimeclipboard 0.5.0 → 0.7.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.
@@ -25,6 +25,13 @@ const dec = new TextDecoder();
25
25
  /** Derivation is expensive (OI-8), so cache per key for the session. */
26
26
  let cached = { id: null, open: null };
27
27
 
28
+ /**
29
+ * Bumped by clearCache(). A derivation that was already in flight when the user
30
+ * left the room resolves afterwards, and storing its result would put the AES
31
+ * key of a room they walked out of back into the cache the purge just emptied.
32
+ */
33
+ let generation = 0;
34
+
28
35
  /**
29
36
  * Both outputs of an open session, from one PBKDF2.
30
37
  *
@@ -39,6 +46,7 @@ let cached = { id: null, open: null };
39
46
  */
40
47
  export async function deriveOpen(key) {
41
48
  if (cached.id === `open:${key}` && cached.open) return cached.open;
49
+ const gen = generation;
42
50
 
43
51
  const material = await crypto.subtle.importKey(
44
52
  "raw", enc.encode(key), "PBKDF2", false, ["deriveBits"]
@@ -51,7 +59,7 @@ export async function deriveOpen(key) {
51
59
  );
52
60
 
53
61
  const open = await expand(prk, CRYPTO.OPEN_INFO);
54
- cached = { id: `open:${key}`, open };
62
+ if (gen === generation) cached = { id: `open:${key}`, open };
55
63
  return open;
56
64
  }
57
65
 
@@ -158,7 +166,10 @@ export async function decrypt(aesKey, payloadB64, ivB64) {
158
166
  // Called on leaving a session and on rotating the key. This had no callers for
159
167
  // years, so the AES key of a room you walked out of stayed live until you opened
160
168
  // another one.
161
- export function clearCache() { cached = { id: null, open: null }; }
169
+ export function clearCache() {
170
+ generation++;
171
+ cached = { id: null, open: null };
172
+ }
162
173
 
163
174
  /* ---- hex helpers ---- */
164
175
  function hex(bytes) {
package/src/core/keys.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Share-key generation and normalisation. */
2
2
 
3
- import { KEY, LOCK } from "./config.js";
3
+ import { KEY, LOCK, SITE } from "./config.js";
4
+ import { IS_WEB } from "./native.js";
4
5
  import * as state from "./state.js";
5
6
 
6
7
  // Modulo bias: 256 does not divide 30, costing ~0.03 bits over a 6-char key.
@@ -42,11 +43,46 @@ export function normalise(raw) {
42
43
  * KEY.ALPHABET constrains what we PRODUCE. Validation must accept anything a
43
44
  * peer might legitimately hand us: a key from another build is still a valid
44
45
  * 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.
46
+ * "D75LVX9QR" contains an L, which the generator never emits, and still works.
47
+ *
48
+ * Permissive about the ALPHABET, not about the LENGTH — see KEY.MIN_LENGTH.
46
49
  */
47
50
  export function isValid(raw) {
51
+ return rejectReason(raw) === null;
52
+ }
53
+
54
+ /**
55
+ * WHY a key was refused, or null if it was not. `isValid()` answers the question
56
+ * the code asks; this answers the one the user asks.
57
+ *
58
+ * The distinction matters at boot. A key that is merely absent means "generate
59
+ * one" — the ordinary first visit. A key that is present and too short is a
60
+ * request we are declining, and declining it silently is what let `#F5H4` open
61
+ * a session with 19 bits behind it. See ui/shell/keyGate.js.
62
+ */
63
+ export function rejectReason(raw) {
48
64
  const k = normalise(raw);
49
- return k.length >= 4 && k.length <= 32;
65
+ if (!k.length) return "empty";
66
+ if (k.length < KEY.MIN_LENGTH) return "short";
67
+ if (k.length > KEY.MAX_LENGTH) return "long";
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * One sentence for a surface with no room for a card — the CLI's exit message
73
+ * and the extension's error. `null` when the key is fine.
74
+ *
75
+ * Here rather than at each call site so the two of them cannot drift from each
76
+ * other or from the floor: both said "is not a valid key" and left the reader
77
+ * to guess which of length, characters or typo they had hit.
78
+ */
79
+ export function rejectMessage(raw) {
80
+ const reason = rejectReason(raw);
81
+ if (!reason) return null;
82
+ const n = normalise(raw).length;
83
+ if (reason === "empty") return "it has no usable characters in it";
84
+ if (reason === "long") return `it is ${n} characters; the most a key may be is ${KEY.MAX_LENGTH}`;
85
+ return `it is ${n} character${n === 1 ? "" : "s"}; a key needs at least ${KEY.MIN_LENGTH}`;
50
86
  }
51
87
 
52
88
  /**
@@ -125,6 +161,38 @@ export function clearUrl() {
125
161
  history.replaceState(null, "", location.pathname + location.search);
126
162
  }
127
163
 
164
+ /**
165
+ * A link for the OTHER device to open, which is why it is not always this one's
166
+ * URL. Only a browser can hand out its own address — see SITE in config.js for
167
+ * the hosts that cannot.
168
+ */
128
169
  export function shareLink(key, locked = false) {
129
- return `${location.origin}${location.pathname}#${fragment(key, locked)}`;
170
+ const here = IS_WEB && typeof location !== "undefined"
171
+ ? location.origin + location.pathname
172
+ : SITE.APP_URL;
173
+ return `${here}#${fragment(key, locked)}`;
174
+ }
175
+
176
+ /**
177
+ * The share link, plus the flag that asks the app to open its QR immediately.
178
+ *
179
+ * Here rather than in the surface that wanted it, because there is exactly one
180
+ * rule to get wrong and it is not obvious: the query goes BEFORE the fragment.
181
+ * Everything after `#` is the key, so `#KEY?qr=1` is not a broken flag — it is a
182
+ * broken key, and the symptom is a room nobody else can join rather than an
183
+ * error anybody sees. One implementation, tested in tests/unit/sharelink.mjs.
184
+ */
185
+ export function qrLink(key, locked = false) {
186
+ const [base, fragment] = shareLink(key, locked).split("#");
187
+ const sep = base.includes("?") ? "&" : "?";
188
+ return `${base}${sep}${SITE.QR_PARAM}=1${fragment ? `#${fragment}` : ""}`;
130
189
  }
190
+
191
+ /**
192
+ * Did this visit ask for the code? The reader sits beside the writer so the two
193
+ * cannot drift, and it is strict: `?qr=0` and `?qr=false` are not requests, and
194
+ * a truthiness test on the string would have treated both as one.
195
+ */
196
+ export const qrRequested = () =>
197
+ typeof location !== "undefined"
198
+ && new URLSearchParams(location.search).get(SITE.QR_PARAM) === "1";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The one place that knows there is a native shell underneath.
2
+ * The one place that knows there is a native host underneath.
3
3
  *
4
4
  * Rank 0 and DOM-free, because the answer also decides the default key length in
5
5
  * config.js — and core/ is imported by the node tests and shipped in the npm
@@ -9,6 +9,11 @@
9
9
  * three failed at once: `withGlobalTauri` had never been switched on, so the
10
10
  * global did not exist, T0 never started, and the desktop app silently degraded
11
11
  * to a browser tab that cannot watch the clipboard.
12
+ *
13
+ * There are two hosts now — the Tauri shell, which announces itself through a
14
+ * global, and the VS Code extension, which registers itself through setHost().
15
+ * Both answer the same two questions (`invoke`, `listen`), so everything above
16
+ * this file sees one native boundary rather than a growing list of shells.
12
17
  */
13
18
 
14
19
  const g = globalThis;
@@ -22,34 +27,73 @@ const IN_TAURI = typeof g.__TAURI_INTERNALS__ === "object"
22
27
  /** `document` first: jsdom has both, and the DOM suites want to be "web". */
23
28
  const IN_NODE = typeof document === "undefined" && !!g.process?.versions?.node;
24
29
 
25
- export const SURFACE = IN_TAURI ? "desktop" : IN_NODE ? "cli" : "web";
30
+ /**
31
+ * A host that cannot be detected declares itself, before this module is first
32
+ * imported. Declared rather than sniffed on purpose: `process.env.VSCODE_PID` is
33
+ * set inside VS Code's *integrated terminal* as well as inside its extension
34
+ * host, so `realtimeclipboard watch …` typed at that terminal would claim to be
35
+ * the extension.
36
+ */
37
+ const DECLARED = typeof g.__REALTIMECLIPBOARD_SURFACE__ === "string"
38
+ ? g.__REALTIMECLIPBOARD_SURFACE__
39
+ : null;
40
+
41
+ export const SURFACE = DECLARED ?? (IN_TAURI ? "desktop" : IN_NODE ? "cli" : "web");
26
42
  export const IS_DESKTOP = SURFACE === "desktop";
27
43
  export const IS_WEB = SURFACE === "web";
28
44
  export const IS_CLI = SURFACE === "cli";
29
45
 
30
- /** Call a command in desktop/src-tauri/src/main.rs. Null anywhere else. */
46
+ /**
47
+ * Surfaces the user installed rather than visited. What this actually governs is
48
+ * key length: an installed surface keeps its session across restarts and is worth
49
+ * the longer key, a visited one is usually typing it to somebody.
50
+ */
51
+ export const IS_INSTALLED = IS_DESKTOP || SURFACE === "vscode";
52
+
53
+ let host = null;
54
+
55
+ /**
56
+ * Register the process-side half of a native shell. Called once, before anything
57
+ * that depends on it — which is why the three consumers below all ask through a
58
+ * function rather than reading a const captured at import time.
59
+ */
60
+ export function setHost(h) { host = h; }
61
+
62
+ /** Is there something underneath that owns the OS clipboard? */
63
+ export const hasNativeClipboard = () => IN_TAURI || host !== null;
64
+
65
+ /** How this host watches the clipboard, for the tier readout. */
66
+ export const hostNote = () => host?.note ?? "watching the system clipboard";
67
+
68
+ /**
69
+ * Call a command on the host — desktop/src-tauri/src/main.rs, or a registered
70
+ * host's own implementation. Null where there is no host.
71
+ */
31
72
  export async function invoke(command, args) {
32
73
  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
- }
74
+ return run(host?.invoke ?? core?.invoke?.bind(core), "invoke", command, args);
40
75
  }
41
76
 
42
77
  /**
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.
78
+ * Subscribe to an event the host emits. Resolves to an unsubscribe function, or
79
+ * null where there is no host, so callers can treat "no shell" as ordinary.
80
+ *
81
+ * The Tauri API hands the listener an envelope; a registered host calls back with
82
+ * the payload. Unwrapped here so `clipboard://text` looks the same to capture.js
83
+ * whichever shell is underneath.
45
84
  */
46
85
  export async function listen(event, fn) {
47
86
  const api = g.__TAURI__?.event;
48
- if (!api?.listen) return null;
87
+ return run(host?.listen ?? (api?.listen && ((e, cb) => api.listen(e, m => cb(m.payload)))),
88
+ "listen", event, fn);
89
+ }
90
+
91
+ async function run(fn, what, a, b) {
92
+ if (!fn) return null;
49
93
  try {
50
- return await api.listen(event, ({ payload }) => fn(payload));
94
+ return await fn(a, b);
51
95
  } catch (err) {
52
- console.warn(`[realtimeclipboard] native listen "${event}" failed:`, err);
96
+ console.warn(`[realtimeclipboard] native ${what} "${a}" failed:`, err);
53
97
  return null;
54
98
  }
55
99
  }
package/src/core/state.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { emit, EV } from "./bus.js";
9
- import { DEFAULT_SYNC_MODE, SYNC_MODES, KEY, TEXT } from "./config.js";
9
+ import { DEFAULT_SYNC_MODE, DEFAULT_THEME, SYNC_MODES, KEY, TEXT } from "./config.js";
10
10
  import * as storage from "./storage.js";
11
11
 
12
12
  const state = {
@@ -53,6 +53,9 @@ const state = {
53
53
  // setting that decides whether a secret touches disk, so it is a switch and
54
54
  // it is named plainly in the UI. See storage.js saveLastKey.
55
55
  rememberKey: true,
56
+ // system | light | dark. "system" is not a third colour scheme, it is the
57
+ // absence of a choice — see config.js THEMES and ui/features/theme.js.
58
+ theme: DEFAULT_THEME,
56
59
  },
57
60
  };
58
61
 
@@ -70,6 +73,7 @@ export function restore() {
70
73
  if (saved) Object.assign(state.settings, saved);
71
74
 
72
75
  migrateReceiving(saved);
76
+ watchSettings();
73
77
 
74
78
  /**
75
79
  * Anyone who toggled any switch before this shipped has an explicit
@@ -106,10 +110,37 @@ function migrateReceiving(saved) {
106
110
  /** Change a setting and remember it. The only path that persists one. */
107
111
  export function saveSetting(name, value) {
108
112
  state.settings[name] = value;
109
- storage.saveSettings(state.settings);
113
+ // Merged into what is on disk rather than written wholesale. Two tabs of one
114
+ // session each hold their own copy of this object from boot, so a whole-object
115
+ // write reverted every setting the OTHER tab had changed since — including the
116
+ // sync rung, which silently put a device the user had taken offline back on
117
+ // the wire at the next launch, and `rememberKey`, which decides whether the
118
+ // share key touches disk at all.
119
+ storage.saveSettings({ ...(storage.loadSettings() ?? {}), [name]: value });
110
120
  emit(EV.SETTINGS_CHANGED, { name, value });
111
121
  }
112
122
 
123
+ let watching = false;
124
+
125
+ /**
126
+ * Adopt what another tab saved, so two windows of one session do not disagree
127
+ * about the rung. `external` marks these apart from this tab's own changes:
128
+ * the control that owns a setting has already applied it locally by the time it
129
+ * emits, and would double-apply on its own event.
130
+ */
131
+ function watchSettings() {
132
+ if (watching) return;
133
+ watching = true;
134
+ storage.onSettingsChanged(saved => {
135
+ if (!saved) return;
136
+ for (const [name, value] of Object.entries(saved)) {
137
+ if (state.settings[name] === value) continue;
138
+ state.settings[name] = value;
139
+ emit(EV.SETTINGS_CHANGED, { name, value, external: true });
140
+ }
141
+ });
142
+ }
143
+
113
144
  export function setKey({ key, roomHash, aesKey, locked = false, authToken = null }) {
114
145
  state.key = key;
115
146
  state.roomHash = roomHash ?? state.roomHash;
@@ -26,6 +26,22 @@ export function remove(name) {
26
26
  export const loadSettings = () => read("settings", null);
27
27
  export const saveSettings = s => write("settings", s);
28
28
 
29
+ /**
30
+ * Another tab saved the settings. localStorage delivers this event only to the
31
+ * tabs that did NOT write, which is exactly the set that needs to catch up.
32
+ *
33
+ * Guarded rather than assumed: this module is published in the npm package and
34
+ * `cli/` imports it, where there are no other tabs and no event target.
35
+ */
36
+ export function onSettingsChanged(cb) {
37
+ if (typeof addEventListener !== "function") return;
38
+ addEventListener("storage", e => {
39
+ if (e.key !== PREFIX + "settings") return;
40
+ try { cb(e.newValue === null ? null : JSON.parse(e.newValue)); }
41
+ catch { /* another tab wrote something we cannot read */ }
42
+ });
43
+ }
44
+
29
45
  /**
30
46
  * The relay this device talks to, when it is not the one the build ships with.
31
47
  * config.js reads this key directly at module evaluation, because the URL has to
@@ -0,0 +1,84 @@
1
+ /**
2
+ * What this surface is allowed to load, and what to call it in a report.
3
+ *
4
+ * Ads differ per surface by POLICY, not by preference. Google's programme
5
+ * policies forbid ads in software applications — "Google ads may not be
6
+ * integrated into a software application of any kind" — and enforcement is
7
+ * account-level, so an AdSense tag in the desktop shell or the extension would
8
+ * put the website's revenue at risk too. `adNetwork()` is the one place that
9
+ * decides, so the rule is greppable rather than spread across three tag
10
+ * modules. docs/decisions/0001 has the reasoning.
11
+ *
12
+ * A leaf on purpose: it imports from core/ and nothing in core/ imports it.
13
+ * `device.js` reaches `storage.js` which reaches `config.js` which reaches
14
+ * `native.js`, so folding this into any of those would close a cycle and the
15
+ * symptom would be a TDZ error at import time, not a legible one.
16
+ */
17
+
18
+ import { SURFACE } from "./native.js";
19
+ import { os } from "./device.js";
20
+ import { adsEnabled, analyticsEnabled } from "./config.js";
21
+
22
+ /**
23
+ * Running as an installed app rather than a browser tab. Guarded the way
24
+ * core/CLAUDE.md requires — this module is imported by the node tests and by
25
+ * the published npm package, where `matchMedia` does not exist.
26
+ *
27
+ * `fullscreen` is deliberately NOT in this list, though ui/features/install.js
28
+ * tests for it: F11 in an ordinary tab matches it, which is harmless when the
29
+ * question is "should I offer to install" and wrong when the question is "which
30
+ * surface produced this event" — it would file plain web traffic as web-pwa.
31
+ */
32
+ export function isStandalone() {
33
+ const mm = globalThis.matchMedia;
34
+ if (!mm) return false;
35
+ return ["standalone", "window-controls-overlay", "minimal-ui"]
36
+ .some(mode => mm.call(globalThis, `(display-mode: ${mode})`).matches)
37
+ || globalThis.navigator?.standalone === true; // iOS Safari's non-standard flag
38
+ }
39
+
40
+ /**
41
+ * The GA4 `rtc_surface` value. One vocabulary for every tag module.
42
+ *
43
+ * `desktop-other` exists so the function is total: Tauri ships exactly three
44
+ * desktop targets, so that value appearing in a report is a user-agent bug worth
45
+ * seeing rather than something to quietly fold into desktop-linux.
46
+ */
47
+ export function platform() {
48
+ if (SURFACE === "vscode") return "vscode";
49
+ if (SURFACE === "cli") return "cli";
50
+ if (SURFACE === "desktop") {
51
+ const name = os();
52
+ return name === "Windows" ? "desktop-windows"
53
+ : name === "macOS" ? "desktop-mac"
54
+ : name === "Linux" ? "desktop-linux"
55
+ : "desktop-other";
56
+ }
57
+ return isStandalone() ? "web-pwa" : "web-browser";
58
+ }
59
+
60
+ /**
61
+ * Which ad network this surface may load, if any.
62
+ *
63
+ * "none" for vscode and cli is unconditional — it does not depend on an ID being
64
+ * empty, because a rule that a configuration change can undo is not a rule.
65
+ * tests/unit/adpolicy.mjs asserts exactly that.
66
+ */
67
+ export function adNetwork() {
68
+ if (SURFACE === "web") return adsEnabled() ? "adsense" : "none";
69
+ if (SURFACE === "desktop") return "house";
70
+ return "none";
71
+ }
72
+
73
+ /**
74
+ * Only a surface with a document can carry a tag at all.
75
+ *
76
+ * The CLI and the VS Code extension are both Node hosts — the extension runs in
77
+ * the extension host and owns no webview, so ui/features/analytics.js is never
78
+ * even imported there. Saying "vscode reports" would describe something that
79
+ * cannot happen, and it would also mean answering to `vscode.env.isTelemetryEnabled`
80
+ * before it ever could. If the extension grows a webview, that opt-out is the
81
+ * prerequisite, not an afterthought. docs/decisions/0003.
82
+ */
83
+ export const analyticsOn = () =>
84
+ (SURFACE === "web" || SURFACE === "desktop") && analyticsEnabled();