@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.
- package/LICENSE +21 -0
- package/README.md +170 -2
- package/assets/boardclip-icon.png +0 -0
- package/bin/boardclip.js +43 -0
- package/editor-preload.js +71 -0
- package/editor.html +220 -0
- package/icon.png +0 -0
- package/icon@2x.png +0 -0
- package/iconTemplate.png +0 -0
- package/iconTemplate@2x.png +0 -0
- package/index.html +1339 -0
- package/lib/appearance.js +183 -0
- package/lib/auto-update.js +306 -0
- package/lib/backup-worker.js +46 -0
- package/lib/backup.js +275 -0
- package/lib/blob-field-store.js +275 -0
- package/lib/blob-store.js +68 -0
- package/lib/build-info.js +89 -0
- package/lib/clip-blob-store.js +161 -0
- package/lib/clip-revision.js +81 -0
- package/lib/clipboard-capture.js +95 -0
- package/lib/clipboard-follow.js +98 -0
- package/lib/clipboard-model.js +1235 -0
- package/lib/cloud-accounts.js +250 -0
- package/lib/conflict-model.js +186 -0
- package/lib/control-client.js +87 -0
- package/lib/control-server.js +149 -0
- package/lib/diagnostics.js +121 -0
- package/lib/drag-files.js +48 -0
- package/lib/ensure-directory.js +56 -0
- package/lib/fork-names.js +35 -0
- package/lib/fs-probe.js +132 -0
- package/lib/history-feed.js +48 -0
- package/lib/hmac-auth.js +78 -0
- package/lib/macos-hotkey.js +297 -0
- package/lib/macos-paste.js +112 -0
- package/lib/mcp-core.js +270 -0
- package/lib/mcp-installers.js +284 -0
- package/lib/mcp-paths.js +99 -0
- package/lib/p2p-crypto.js +54 -0
- package/lib/p2p-discovery.js +191 -0
- package/lib/quick-paste.js +187 -0
- package/lib/retention.js +43 -0
- package/lib/sync-delta.js +227 -0
- package/lib/sync-journal.js +181 -0
- package/lib/sync-paths.js +73 -0
- package/lib/tailscale.js +80 -0
- package/lib/text-blob-store.js +38 -0
- package/lib/windows-clipboard.js +213 -0
- package/lib/windows-console.js +63 -0
- package/lib/windows-dwm.js +183 -0
- package/lib/windows-hook-worker.js +236 -0
- package/lib/windows-hook.js +91 -0
- package/lib/windows-paste.js +185 -0
- package/main.js +7540 -0
- package/mcp/boardclip-mcp.js +255 -0
- package/mcp-approval-preload.js +22 -0
- package/mcp-approval.html +218 -0
- package/package.json +55 -4
- package/preload.js +106 -0
- package/site/index.html +1624 -0
- package/site/shared/clip-search.js +2330 -0
- package/site/shared/clipboard-popup.css +1046 -0
- package/site/shared/clipboard-tokens.css +326 -0
- package/site/shared/clipboard-ui-core.js +6921 -0
- package/site/shared/clipboard-window.css +36 -0
- package/site/shared/vendor/cm5/README.md +52 -0
- package/site/shared/vendor/cm5/codemirror.css +344 -0
- package/site/shared/vendor/cm5/codemirror.js +9884 -0
- package/site/shared/vendor/cm5/diff-match-patch.js +2228 -0
- package/site/shared/vendor/cm5/merge.css +119 -0
- package/site/shared/vendor/cm5/merge.js +1182 -0
- package/site/shared/vendor/fonts/README.md +19 -0
- package/site/shared/vendor/fonts/material-symbols-rounded.woff2 +0 -0
- package/viewer-preload.js +51 -0
- 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
|
+
};
|
package/lib/mcp-paths.js
ADDED
|
@@ -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
|
+
};
|