@tobq/boardclip 0.0.0-stage → 2.1.108

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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -2
  3. package/assets/boardclip-icon.png +0 -0
  4. package/bin/boardclip.js +43 -0
  5. package/editor-preload.js +71 -0
  6. package/editor.html +220 -0
  7. package/icon.png +0 -0
  8. package/icon@2x.png +0 -0
  9. package/iconTemplate.png +0 -0
  10. package/iconTemplate@2x.png +0 -0
  11. package/index.html +1339 -0
  12. package/lib/appearance.js +183 -0
  13. package/lib/auto-update.js +306 -0
  14. package/lib/backup-worker.js +46 -0
  15. package/lib/backup.js +275 -0
  16. package/lib/blob-field-store.js +275 -0
  17. package/lib/blob-store.js +68 -0
  18. package/lib/build-info.js +89 -0
  19. package/lib/clip-blob-store.js +161 -0
  20. package/lib/clip-revision.js +81 -0
  21. package/lib/clipboard-capture.js +95 -0
  22. package/lib/clipboard-follow.js +98 -0
  23. package/lib/clipboard-model.js +1235 -0
  24. package/lib/cloud-accounts.js +250 -0
  25. package/lib/conflict-model.js +186 -0
  26. package/lib/control-client.js +87 -0
  27. package/lib/control-server.js +149 -0
  28. package/lib/diagnostics.js +121 -0
  29. package/lib/drag-files.js +48 -0
  30. package/lib/ensure-directory.js +56 -0
  31. package/lib/fork-names.js +35 -0
  32. package/lib/fs-probe.js +132 -0
  33. package/lib/history-feed.js +48 -0
  34. package/lib/hmac-auth.js +78 -0
  35. package/lib/macos-hotkey.js +297 -0
  36. package/lib/macos-paste.js +112 -0
  37. package/lib/mcp-core.js +270 -0
  38. package/lib/mcp-installers.js +284 -0
  39. package/lib/mcp-paths.js +99 -0
  40. package/lib/p2p-crypto.js +54 -0
  41. package/lib/p2p-discovery.js +191 -0
  42. package/lib/quick-paste.js +187 -0
  43. package/lib/retention.js +43 -0
  44. package/lib/sync-delta.js +227 -0
  45. package/lib/sync-journal.js +181 -0
  46. package/lib/sync-paths.js +73 -0
  47. package/lib/tailscale.js +80 -0
  48. package/lib/text-blob-store.js +38 -0
  49. package/lib/windows-clipboard.js +213 -0
  50. package/lib/windows-console.js +63 -0
  51. package/lib/windows-dwm.js +183 -0
  52. package/lib/windows-hook-worker.js +236 -0
  53. package/lib/windows-hook.js +91 -0
  54. package/lib/windows-paste.js +185 -0
  55. package/main.js +7540 -0
  56. package/mcp/boardclip-mcp.js +255 -0
  57. package/mcp-approval-preload.js +22 -0
  58. package/mcp-approval.html +218 -0
  59. package/package.json +55 -4
  60. package/preload.js +106 -0
  61. package/site/index.html +1624 -0
  62. package/site/shared/clip-search.js +2330 -0
  63. package/site/shared/clipboard-popup.css +1046 -0
  64. package/site/shared/clipboard-tokens.css +326 -0
  65. package/site/shared/clipboard-ui-core.js +6921 -0
  66. package/site/shared/clipboard-window.css +36 -0
  67. package/site/shared/vendor/cm5/README.md +52 -0
  68. package/site/shared/vendor/cm5/codemirror.css +344 -0
  69. package/site/shared/vendor/cm5/codemirror.js +9884 -0
  70. package/site/shared/vendor/cm5/diff-match-patch.js +2228 -0
  71. package/site/shared/vendor/cm5/merge.css +119 -0
  72. package/site/shared/vendor/cm5/merge.js +1182 -0
  73. package/site/shared/vendor/fonts/README.md +19 -0
  74. package/site/shared/vendor/fonts/material-symbols-rounded.woff2 +0 -0
  75. package/viewer-preload.js +51 -0
  76. package/viewer.html +129 -0
@@ -0,0 +1,284 @@
1
+ 'use strict';
2
+
3
+ // MCP client installers.
4
+ //
5
+ // Registers (and removes) the BoardClip MCP server into each local AI client's
6
+ // config. Almost every client uses the same shape - a JSON file with a map of
7
+ // server-name -> { command, args } - so that case is handled by ONE shared
8
+ // adapter factory; only Codex (TOML), VS Code (`servers` + type), and Zed
9
+ // (nested `command` object) need variants. No Electron dependency: pure fs/os so
10
+ // it is unit-testable and usable from the main process.
11
+ //
12
+ // All writes are idempotent and non-clobbering: other servers in the file are
13
+ // preserved; only the `boardclip` entry is added/updated/removed.
14
+
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+
19
+ const SERVER_NAME = 'boardclip';
20
+
21
+ function resolveRoots(overrides = {}) {
22
+ const home = overrides.home || os.homedir();
23
+ return {
24
+ home,
25
+ appData: overrides.appData || process.env.APPDATA || path.join(home, 'AppData', 'Roaming'),
26
+ xdgConfig: overrides.xdgConfig || process.env.XDG_CONFIG_HOME || path.join(home, '.config'),
27
+ library: overrides.library || path.join(home, 'Library', 'Application Support'),
28
+ };
29
+ }
30
+
31
+ // Per-OS location of an app's "config home" directory (where VS Code/Claude
32
+ // Desktop/etc. keep user config), given a base folder name.
33
+ function appConfigDir(roots, name) {
34
+ if (process.platform === 'win32') return path.join(roots.appData, name);
35
+ if (process.platform === 'darwin') return path.join(roots.library, name);
36
+ return path.join(roots.xdgConfig, name);
37
+ }
38
+
39
+ function readJson(file) {
40
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
41
+ }
42
+
43
+ function writeJsonAtomic(file, value) {
44
+ fs.mkdirSync(path.dirname(file), { recursive: true });
45
+ const tmp = `${file}.tmp`;
46
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
47
+ fs.renameSync(tmp, file);
48
+ }
49
+
50
+ function entryExists(file) {
51
+ try { return fs.existsSync(file); } catch { return false; }
52
+ }
53
+
54
+ // --- Shared JSON-map adapter -------------------------------------------------
55
+ // Covers clients that store servers as { [serversKey]: { name: entryValue } }.
56
+ function jsonMapAdapter({ id, label, dirFor, fileName, serversKey = 'mcpServers', detectDirFor, buildEntry }) {
57
+ const configPath = roots => path.join(dirFor(roots), fileName);
58
+ const detectDir = detectDirFor || dirFor;
59
+ return {
60
+ id,
61
+ label,
62
+ configPath,
63
+ detect(roots) { return entryExists(detectDir(roots)); },
64
+ status(roots) {
65
+ const file = configPath(roots);
66
+ const data = readJson(file);
67
+ const enabled = !!(data && data[serversKey] && data[serversKey][SERVER_NAME]);
68
+ return { id, label, detected: this.detect(roots), enabled, configPath: file };
69
+ },
70
+ enable(command, roots) {
71
+ const file = configPath(roots);
72
+ const data = readJson(file) || {};
73
+ if (!data[serversKey] || typeof data[serversKey] !== 'object') data[serversKey] = {};
74
+ data[serversKey][SERVER_NAME] = buildEntry(command);
75
+ writeJsonAtomic(file, data);
76
+ },
77
+ disable(roots) {
78
+ const file = configPath(roots);
79
+ const data = readJson(file);
80
+ if (data && data[serversKey] && data[serversKey][SERVER_NAME]) {
81
+ delete data[serversKey][SERVER_NAME];
82
+ writeJsonAtomic(file, data);
83
+ }
84
+ },
85
+ };
86
+ }
87
+
88
+ // Standard { command, args, env } entry used by most clients.
89
+ function stdEntry(command) {
90
+ const entry = { command: command.command, args: command.args.slice() };
91
+ if (command.env && Object.keys(command.env).length) entry.env = { ...command.env };
92
+ return entry;
93
+ }
94
+
95
+ // --- Codex (TOML) ------------------------------------------------------------
96
+ // Minimal targeted edit: strip any existing [mcp_servers.boardclip] table and
97
+ // append a fresh one, preserving the rest of the file (comments, other servers).
98
+ function tomlString(value) {
99
+ return JSON.stringify(String(value)); // TOML basic-string escaping == JSON for these cases
100
+ }
101
+
102
+ function codexBlock(command) {
103
+ const lines = [`[mcp_servers.${SERVER_NAME}]`, `command = ${tomlString(command.command)}`];
104
+ lines.push(`args = [${command.args.map(tomlString).join(', ')}]`);
105
+ if (command.env && Object.keys(command.env).length) {
106
+ const pairs = Object.entries(command.env).map(([k, v]) => `${k} = ${tomlString(v)}`);
107
+ lines.push(`env = { ${pairs.join(', ')} }`);
108
+ }
109
+ return lines.join('\n');
110
+ }
111
+
112
+ function stripCodexBlock(text) {
113
+ const lines = String(text || '').split(/\r?\n/);
114
+ const out = [];
115
+ let skipping = false;
116
+ for (const line of lines) {
117
+ if (/^\s*\[mcp_servers\.boardclip\]\s*$/.test(line)) { skipping = true; continue; }
118
+ if (skipping) {
119
+ if (/^\s*\[/.test(line)) skipping = false; // next table header ends the block
120
+ else continue;
121
+ }
122
+ out.push(line);
123
+ }
124
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '');
125
+ }
126
+
127
+ function codexAdapter() {
128
+ const dir = roots => path.join(roots.home, '.codex');
129
+ const configPath = roots => path.join(dir(roots), 'config.toml');
130
+ return {
131
+ id: 'codex',
132
+ label: 'Codex',
133
+ configPath,
134
+ detect(roots) { return entryExists(dir(roots)); },
135
+ status(roots) {
136
+ const text = (() => { try { return fs.readFileSync(configPath(roots), 'utf8'); } catch { return ''; } })();
137
+ return { id: 'codex', label: 'Codex', detected: this.detect(roots), enabled: /\[mcp_servers\.boardclip\]/.test(text), configPath: configPath(roots) };
138
+ },
139
+ enable(command, roots) {
140
+ const file = configPath(roots);
141
+ fs.mkdirSync(path.dirname(file), { recursive: true });
142
+ let text = '';
143
+ try { text = fs.readFileSync(file, 'utf8'); } catch {}
144
+ const stripped = stripCodexBlock(text);
145
+ const next = `${stripped ? `${stripped}\n\n` : ''}${codexBlock(command)}\n`;
146
+ const tmp = `${file}.tmp`;
147
+ fs.writeFileSync(tmp, next);
148
+ fs.renameSync(tmp, file);
149
+ },
150
+ disable(roots) {
151
+ const file = configPath(roots);
152
+ let text;
153
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return; }
154
+ const stripped = stripCodexBlock(text);
155
+ const tmp = `${file}.tmp`;
156
+ fs.writeFileSync(tmp, stripped ? `${stripped}\n` : '');
157
+ fs.renameSync(tmp, file);
158
+ },
159
+ };
160
+ }
161
+
162
+ // --- Zed (nested command object) ---------------------------------------------
163
+ function zedAdapter() {
164
+ return jsonMapAdapter({
165
+ id: 'zed',
166
+ label: 'Zed',
167
+ dirFor: roots => path.join(roots.xdgConfig, 'zed'),
168
+ fileName: 'settings.json',
169
+ serversKey: 'context_servers',
170
+ buildEntry: command => ({ command: { path: command.command, args: command.args.slice(), env: command.env || {} } }),
171
+ });
172
+ }
173
+
174
+ // --- Adapter registry --------------------------------------------------------
175
+ function buildAdapters() {
176
+ return [
177
+ jsonMapAdapter({
178
+ id: 'claude-code', label: 'Claude Code',
179
+ dirFor: roots => roots.home, fileName: '.claude.json',
180
+ detectDirFor: roots => path.join(roots.home, '.claude'),
181
+ buildEntry: stdEntry,
182
+ }),
183
+ codexAdapter(),
184
+ jsonMapAdapter({
185
+ id: 'claude-desktop', label: 'Claude Desktop',
186
+ dirFor: roots => appConfigDir(roots, 'Claude'), fileName: 'claude_desktop_config.json',
187
+ buildEntry: stdEntry,
188
+ }),
189
+ jsonMapAdapter({
190
+ id: 'vscode', label: 'VS Code',
191
+ dirFor: roots => path.join(appConfigDir(roots, 'Code'), 'User'), fileName: 'mcp.json',
192
+ serversKey: 'servers',
193
+ buildEntry: command => ({ type: 'stdio', ...stdEntry(command) }),
194
+ }),
195
+ jsonMapAdapter({
196
+ id: 'vscode-insiders', label: 'VS Code Insiders',
197
+ dirFor: roots => path.join(appConfigDir(roots, 'Code - Insiders'), 'User'), fileName: 'mcp.json',
198
+ serversKey: 'servers',
199
+ buildEntry: command => ({ type: 'stdio', ...stdEntry(command) }),
200
+ }),
201
+ jsonMapAdapter({
202
+ id: 'cursor', label: 'Cursor',
203
+ dirFor: roots => path.join(roots.home, '.cursor'), fileName: 'mcp.json',
204
+ buildEntry: stdEntry,
205
+ }),
206
+ jsonMapAdapter({
207
+ id: 'windsurf', label: 'Windsurf',
208
+ dirFor: roots => path.join(roots.home, '.codeium', 'windsurf'), fileName: 'mcp_config.json',
209
+ buildEntry: stdEntry,
210
+ }),
211
+ jsonMapAdapter({
212
+ id: 'cline', label: 'Cline',
213
+ dirFor: roots => path.join(appConfigDir(roots, 'Code'), 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings'),
214
+ fileName: 'cline_mcp_settings.json',
215
+ buildEntry: stdEntry,
216
+ }),
217
+ jsonMapAdapter({
218
+ id: 'roo', label: 'Roo Code',
219
+ dirFor: roots => path.join(appConfigDir(roots, 'Code'), 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings'),
220
+ fileName: 'mcp_settings.json',
221
+ buildEntry: stdEntry,
222
+ }),
223
+ zedAdapter(),
224
+ // NOTE: Continue is intentionally omitted. It configures MCP via a YAML list
225
+ // (~/.continue/config.yaml `mcpServers:` or .continue/mcpServers/*.yaml), not a
226
+ // JSON `mcpServers` map, so the shared adapter would write a file Continue
227
+ // ignores. Add a dedicated YAML adapter when supporting it for real.
228
+ ];
229
+ }
230
+
231
+ const ADAPTERS = buildAdapters();
232
+
233
+ function byId(id) { return ADAPTERS.find(a => a.id === id) || null; }
234
+
235
+ // --- Public API --------------------------------------------------------------
236
+ function statuses(rootsOverride) {
237
+ const roots = resolveRoots(rootsOverride);
238
+ return ADAPTERS.map(a => a.status(roots));
239
+ }
240
+
241
+ function enable(id, command, rootsOverride) {
242
+ const adapter = byId(id);
243
+ if (!adapter) throw new Error(`unknown client: ${id}`);
244
+ adapter.enable(command, resolveRoots(rootsOverride));
245
+ }
246
+
247
+ function disable(id, rootsOverride) {
248
+ const adapter = byId(id);
249
+ if (!adapter) throw new Error(`unknown client: ${id}`);
250
+ adapter.disable(resolveRoots(rootsOverride));
251
+ }
252
+
253
+ // Install into every DETECTED client. Returns the ids touched.
254
+ function enableDetected(command, rootsOverride) {
255
+ const roots = resolveRoots(rootsOverride);
256
+ const touched = [];
257
+ for (const adapter of ADAPTERS) {
258
+ if (!adapter.detect(roots)) continue;
259
+ try { adapter.enable(command, roots); touched.push(adapter.id); } catch {}
260
+ }
261
+ return touched;
262
+ }
263
+
264
+ // Remove from every client (used on uninstall / disable-all).
265
+ function disableAll(rootsOverride) {
266
+ const roots = resolveRoots(rootsOverride);
267
+ for (const adapter of ADAPTERS) {
268
+ try { adapter.disable(roots); } catch {}
269
+ }
270
+ }
271
+
272
+ module.exports = {
273
+ SERVER_NAME,
274
+ ADAPTERS,
275
+ resolveRoots,
276
+ statuses,
277
+ enable,
278
+ disable,
279
+ enableDetected,
280
+ disableAll,
281
+ // exported for tests
282
+ stripCodexBlock,
283
+ codexBlock,
284
+ };
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ // Shared filesystem locations for the MCP feature, used by BOTH the Electron app
4
+ // and the standalone stdio helper (which is not Electron and cannot call
5
+ // app.getPath). The app writes a discovery file on launch; the helper reads it to
6
+ // locate the data dir + control pipe + auth secret. If the app never ran, the
7
+ // helper falls back to the per-platform default data dir for read-only context.
8
+
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const fs = require('fs');
12
+
13
+ const HOME = os.homedir();
14
+ const CONFIG_DIR = path.join(HOME, '.boardclip');
15
+ // BOARDCLIP_MCP_DISCOVERY overrides the discovery file location (used by tests
16
+ // and unusual installs); otherwise it lives at ~/.boardclip/mcp.json.
17
+ const DISCOVERY_FILE = process.env.BOARDCLIP_MCP_DISCOVERY || path.join(CONFIG_DIR, 'mcp.json');
18
+
19
+ // Mirrors Electron's app.getPath('userData') for productName "BoardClip".
20
+ function defaultDataDir() {
21
+ if (process.platform === 'win32') {
22
+ const appData = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming');
23
+ return path.join(appData, 'BoardClip');
24
+ }
25
+ if (process.platform === 'darwin') {
26
+ return path.join(HOME, 'Library', 'Application Support', 'BoardClip');
27
+ }
28
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(HOME, '.config');
29
+ return path.join(xdg, 'BoardClip');
30
+ }
31
+
32
+ // A stable per-user tag so concurrent OS users don't collide on one address.
33
+ // BOARDCLIP_MCP_PIPE_TAG overrides it (test seam): an isolated QA instance can
34
+ // run its own control channel beside the user's live app instead of colliding
35
+ // on the per-user pipe with EADDRINUSE.
36
+ function userTag() {
37
+ if (process.env.BOARDCLIP_MCP_PIPE_TAG) return String(process.env.BOARDCLIP_MCP_PIPE_TAG).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 24) || 'u';
38
+ try {
39
+ const info = os.userInfo();
40
+ if (typeof info.uid === 'number' && info.uid >= 0) return String(info.uid);
41
+ if (info.username) return String(info.username).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 24) || 'u';
42
+ } catch {}
43
+ return 'u';
44
+ }
45
+
46
+ // Stable, PER-USER control-channel address. The app records the actual bound path
47
+ // in the discovery file, so the helper never recomputes this; it is only the
48
+ // app's default choice.
49
+ function defaultPipePath() {
50
+ if (process.platform === 'win32') {
51
+ // Per-user named pipe so concurrent Windows sessions (RDP / fast-user-switch)
52
+ // don't fight over one global pipe.
53
+ return `\\\\.\\pipe\\boardclip-mcp-${userTag()}`;
54
+ }
55
+ if (process.platform === 'darwin') {
56
+ // macOS $TMPDIR is already per-user; keep the short path (UDS ~104-char cap).
57
+ return path.join(os.tmpdir(), 'boardclip-mcp.sock');
58
+ }
59
+ // Linux/other unix: /tmp is shared + sticky, so scope the socket per user.
60
+ // Prefer XDG_RUNTIME_DIR (per-user, mode 0700, the canonical place for sockets).
61
+ const base = process.env.XDG_RUNTIME_DIR || os.tmpdir();
62
+ return path.join(base, `boardclip-mcp-${userTag()}.sock`);
63
+ }
64
+
65
+ function ensureConfigDir() {
66
+ try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); } catch {}
67
+ }
68
+
69
+ function writeDiscovery(data) {
70
+ ensureConfigDir();
71
+ const tmp = `${DISCOVERY_FILE}.tmp`;
72
+ const json = JSON.stringify(data, null, 2);
73
+ fs.writeFileSync(tmp, json, { mode: 0o600 });
74
+ fs.renameSync(tmp, DISCOVERY_FILE);
75
+ try { fs.chmodSync(DISCOVERY_FILE, 0o600); } catch {}
76
+ }
77
+
78
+ function readDiscovery() {
79
+ try {
80
+ return JSON.parse(fs.readFileSync(DISCOVERY_FILE, 'utf8'));
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function clearDiscovery() {
87
+ try { fs.rmSync(DISCOVERY_FILE, { force: true }); } catch {}
88
+ }
89
+
90
+ module.exports = {
91
+ CONFIG_DIR,
92
+ DISCOVERY_FILE,
93
+ defaultDataDir,
94
+ defaultPipePath,
95
+ ensureConfigDir,
96
+ writeDiscovery,
97
+ readDiscovery,
98
+ clearDiscovery,
99
+ };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ // Body encryption for P2P sync v2. Every /delta request and response body is
4
+ // AES-256-GCM sealed with a key derived (HKDF-SHA256) from the shared
5
+ // `p2p_secret`, so clipboard contents are unreadable to anyone sharing the Wi-Fi.
6
+ // The HMAC request signature (lib/hmac-auth.js) is unchanged and is computed
7
+ // over the SEALED bytes: authentication of the request stays exactly as v1, the
8
+ // GCM tag additionally binds the plaintext.
9
+ //
10
+ // Wire shape: iv(12) | tag(16) | ciphertext. A fresh random IV per message.
11
+
12
+ const crypto = require('crypto');
13
+
14
+ const IV_BYTES = 12;
15
+ const TAG_BYTES = 16;
16
+ const KEY_BYTES = 32;
17
+ const HKDF_SALT = 'boardclip';
18
+ const HKDF_INFO = 'boardclip-p2p-v2-aes-256-gcm';
19
+
20
+ function deriveKey(secret) {
21
+ const material = String(secret || '');
22
+ if (!material) throw new Error('p2p-crypto: empty secret');
23
+ return Buffer.from(crypto.hkdfSync('sha256', material, HKDF_SALT, HKDF_INFO, KEY_BYTES));
24
+ }
25
+
26
+ function seal(key, plain) {
27
+ const iv = crypto.randomBytes(IV_BYTES);
28
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
29
+ const body = Buffer.isBuffer(plain) ? plain : Buffer.from(String(plain), 'utf8');
30
+ const encrypted = Buffer.concat([cipher.update(body), cipher.final()]);
31
+ return Buffer.concat([iv, cipher.getAuthTag(), encrypted]);
32
+ }
33
+
34
+ function open(key, sealed) {
35
+ if (!Buffer.isBuffer(sealed) || sealed.length < IV_BYTES + TAG_BYTES) {
36
+ throw new Error('p2p-crypto: sealed body too short');
37
+ }
38
+ const iv = sealed.subarray(0, IV_BYTES);
39
+ const tag = sealed.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
40
+ const encrypted = sealed.subarray(IV_BYTES + TAG_BYTES);
41
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
42
+ decipher.setAuthTag(tag);
43
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]);
44
+ }
45
+
46
+ function sealJson(key, value) {
47
+ return seal(key, Buffer.from(JSON.stringify(value), 'utf8'));
48
+ }
49
+
50
+ function openJson(key, sealed) {
51
+ return JSON.parse(open(key, sealed).toString('utf8'));
52
+ }
53
+
54
+ module.exports = { deriveKey, seal, open, sealJson, openJson, IV_BYTES, TAG_BYTES };
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ // LAN peer discovery for P2P sync: ONE UDP socket, joined to the multicast group
4
+ // on EVERY real IPv4 interface and announcing once per interface.
5
+ //
6
+ // Why per-interface: `socket.addMembership(group)` with no interface lets the OS
7
+ // pick ONE adapter. On a Windows box with Hyper-V / WSL / VMware virtual
8
+ // switches that pick is effectively random (2026-09-03: the live app joined on
9
+ // the Hyper-V "Default Switch" 172.21.224.1 while the Mac's announcements were
10
+ // arriving on Wi-Fi 192.168.1.182, so the two devices never paired and every
11
+ // copy took the cloud path: 30-90 s instead of instant). Joining and sending on
12
+ // each interface makes discovery independent of that pick, and re-enumerating
13
+ // interfaces every 30 s covers Wi-Fi joining after launch and VPNs coming up.
14
+ //
15
+ // Unicast re-announce: the same announcement is also sent directly to every
16
+ // address the caller passes (peers already heard from, pinned peers, tailnet
17
+ // addresses). Multicast is often one-directional or filtered on APs; a peer that
18
+ // heard us once, or that we know the address of, keeps hearing us regardless.
19
+ //
20
+ // Pure with respect to the socket + interface table (both injectable) so the
21
+ // join/announce matrix is unit-tested without touching the network.
22
+
23
+ const os = require('os');
24
+ const dgram = require('dgram');
25
+
26
+ // Tailscale hands out 100.64.0.0/10 (CGNAT). Multicast never crosses the tailnet
27
+ // and link-local addresses are not routable peers, so neither gets a join.
28
+ const CGNAT_RE = /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./;
29
+ const LINK_LOCAL_RE = /^169\.254\./;
30
+
31
+ function isTailscaleIp(ip) {
32
+ return CGNAT_RE.test(String(ip || ''));
33
+ }
34
+
35
+ function normalizeIp(ip) {
36
+ const value = String(ip || '');
37
+ return value.startsWith('::ffff:') ? value.slice(7) : value;
38
+ }
39
+
40
+ function transportFor(ip) {
41
+ return isTailscaleIp(normalizeIp(ip)) ? 'tailnet' : 'lan';
42
+ }
43
+
44
+ // Every non-internal IPv4 address that can carry LAN multicast, with its
45
+ // adapter name. `table` is `os.networkInterfaces()` (injectable for tests).
46
+ function lanInterfaces(table = os.networkInterfaces()) {
47
+ const out = [];
48
+ for (const [name, addrs] of Object.entries(table || {})) {
49
+ for (const addr of Array.isArray(addrs) ? addrs : []) {
50
+ if (!addr || addr.internal || !addr.address) continue;
51
+ if (addr.family !== 'IPv4' && addr.family !== 4) continue;
52
+ if (LINK_LOCAL_RE.test(addr.address) || isTailscaleIp(addr.address)) continue;
53
+ out.push({ name, address: addr.address });
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function createDiscovery({
60
+ group,
61
+ port,
62
+ ttl = 1,
63
+ interfaces = lanInterfaces,
64
+ createSocket = () => dgram.createSocket({ type: 'udp4', reuseAddr: true }),
65
+ onMessage = () => {},
66
+ onError = () => {},
67
+ log = () => {},
68
+ refreshIntervalMs = 30 * 1000,
69
+ setInterval: schedule = setInterval,
70
+ clearInterval: unschedule = clearInterval,
71
+ } = {}) {
72
+ if (!group || !port) throw new Error('createDiscovery needs group + port');
73
+ let socket = null;
74
+ let refreshTimer = null;
75
+ const joined = new Map(); // interface ip -> adapter name
76
+
77
+ async function start() {
78
+ if (socket) return;
79
+ socket = createSocket();
80
+ socket.on('message', (message, rinfo) => onMessage(message, rinfo));
81
+ socket.on('error', error => onError(error));
82
+ await new Promise((resolve, reject) => {
83
+ socket.once('error', reject);
84
+ socket.bind(port, () => {
85
+ socket.off('error', reject);
86
+ resolve();
87
+ });
88
+ });
89
+ try { socket.setMulticastTTL(ttl); } catch {}
90
+ // Two instances on one host (the hermetic two-instance QA) must hear each
91
+ // other; loopback is the default but make it explicit.
92
+ try { socket.setMulticastLoopback(true); } catch {}
93
+ refresh();
94
+ refreshTimer = schedule(refresh, refreshIntervalMs);
95
+ if (refreshTimer && refreshTimer.unref) refreshTimer.unref();
96
+ }
97
+
98
+ // (Re)join the group on every current interface; leave interfaces that went
99
+ // away. Returns what changed so callers can log it.
100
+ function refresh() {
101
+ if (!socket) return { joined: [], added: [], dropped: [] };
102
+ const wanted = new Map(interfaces().map(iface => [iface.address, iface.name]));
103
+ const added = [];
104
+ const dropped = [];
105
+ for (const [ip, name] of wanted) {
106
+ if (joined.has(ip)) continue;
107
+ try {
108
+ socket.addMembership(group, ip);
109
+ joined.set(ip, name);
110
+ added.push({ name, address: ip });
111
+ } catch (error) {
112
+ log('join.error', { name, address: ip, error: error && error.message });
113
+ }
114
+ }
115
+ for (const ip of [...joined.keys()]) {
116
+ if (wanted.has(ip)) continue;
117
+ try { socket.dropMembership(group, ip); } catch {}
118
+ dropped.push({ name: joined.get(ip), address: ip });
119
+ joined.delete(ip);
120
+ }
121
+ if (added.length || dropped.length) {
122
+ log('interfaces', { joined: joinedInterfaces(), added, dropped });
123
+ }
124
+ return { joined: [...joined.keys()], added, dropped };
125
+ }
126
+
127
+ function sendTo(body, targetPort, host, label) {
128
+ socket.send(body, 0, body.length, targetPort, host, error => {
129
+ if (error) log('send.error', { target: label, host, port: targetPort, error: error && error.message });
130
+ });
131
+ }
132
+
133
+ // Multicast once per joined interface, then unicast to every explicit target
134
+ // ({ host, port? }). Returns how many sends were issued.
135
+ function announce(body, { unicast = [] } = {}) {
136
+ if (!socket) return 0;
137
+ let sent = 0;
138
+ for (const ip of joined.keys()) {
139
+ try {
140
+ socket.setMulticastInterface(ip);
141
+ sendTo(body, port, group, `multicast via ${ip}`);
142
+ sent++;
143
+ } catch (error) {
144
+ log('send.error', { target: `multicast via ${ip}`, error: error && error.message });
145
+ }
146
+ }
147
+ if (!joined.size) {
148
+ // No usable interface (yet): let the OS pick, exactly the old behaviour.
149
+ try { sendTo(body, port, group, 'multicast default'); sent++; } catch {}
150
+ }
151
+ const seen = new Set();
152
+ for (const target of Array.isArray(unicast) ? unicast : []) {
153
+ const host = normalizeIp(target && target.host);
154
+ const targetPort = Number(target && target.port) || port;
155
+ if (!host || seen.has(`${host}:${targetPort}`)) continue;
156
+ seen.add(`${host}:${targetPort}`);
157
+ try { sendTo(body, targetPort, host, 'unicast'); sent++; } catch {}
158
+ }
159
+ return sent;
160
+ }
161
+
162
+ function joinedInterfaces() {
163
+ return [...joined.entries()].map(([address, name]) => ({ name, address }));
164
+ }
165
+
166
+ function stop() {
167
+ if (refreshTimer) unschedule(refreshTimer);
168
+ refreshTimer = null;
169
+ joined.clear();
170
+ const current = socket;
171
+ socket = null;
172
+ try { if (current) current.close(); } catch {}
173
+ }
174
+
175
+ return {
176
+ start,
177
+ stop,
178
+ refresh,
179
+ announce,
180
+ joinedInterfaces,
181
+ get running() { return !!socket; },
182
+ };
183
+ }
184
+
185
+ module.exports = {
186
+ createDiscovery,
187
+ lanInterfaces,
188
+ isTailscaleIp,
189
+ normalizeIp,
190
+ transportFor,
191
+ };