@tobq/boardclip 0.0.0-stage → 2.1.107
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,250 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { execFile } = require('child_process');
|
|
5
|
+
const { probePath } = require('./fs-probe');
|
|
6
|
+
const { promisify } = require('util');
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
// Cloud mounts (DriveFS drive letters, ~/Library/CloudStorage, OneDrive) are
|
|
10
|
+
// the one place discovery can HANG instead of failing, so every existence check
|
|
11
|
+
// here carries a deadline. See lib/fs-probe.js for the incident this prevents.
|
|
12
|
+
const MOUNT_PROBE_TIMEOUT_MS = 2000;
|
|
13
|
+
const exists = target => probePath(target, { timeoutMs: MOUNT_PROBE_TIMEOUT_MS });
|
|
14
|
+
const readdirBounded = async (dir) => {
|
|
15
|
+
if (!(await exists(dir))) return [];
|
|
16
|
+
try { return await fs.promises.readdir(dir); } catch { return []; }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const EMAIL_RE_SOURCE = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}';
|
|
20
|
+
const EMAIL_RE = new RegExp(`^${EMAIL_RE_SOURCE}$`, 'i');
|
|
21
|
+
|
|
22
|
+
function addAccount(accounts, seen, provider, label, basePath) {
|
|
23
|
+
const drivePath = path.join(basePath, 'clipboard-tray');
|
|
24
|
+
if (seen.has(drivePath)) return;
|
|
25
|
+
seen.add(drivePath);
|
|
26
|
+
accounts.push({ provider, label, email: label, path: drivePath });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeDriveLetter(value) {
|
|
30
|
+
const match = String(value || '').trim().match(/^([A-Z]):?\\?$/i);
|
|
31
|
+
return match ? match[1].toUpperCase() : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function setEmailByDriveLetter(map, letter, email) {
|
|
35
|
+
const normalizedLetter = normalizeDriveLetter(letter);
|
|
36
|
+
const normalizedEmail = String(email || '').trim();
|
|
37
|
+
if (!normalizedLetter || !EMAIL_RE.test(normalizedEmail)) return;
|
|
38
|
+
map.set(normalizedLetter, normalizedEmail);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mergeMissingDriveEmails(target, source) {
|
|
42
|
+
for (const [letter, email] of source) {
|
|
43
|
+
if (!target.has(letter)) target.set(letter, email);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function driveFsBaseDir() {
|
|
48
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
49
|
+
return path.join(localAppData, 'Google', 'DriveFS');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getDriveEmailsFromPreferenceCache() {
|
|
53
|
+
const emails = new Map();
|
|
54
|
+
const baseDir = driveFsBaseDir();
|
|
55
|
+
const googleDriveNameRe = new RegExp(
|
|
56
|
+
`(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?` +
|
|
57
|
+
`(${EMAIL_RE_SOURCE}) - Google(?: Drive|\\.\\.\\.).{0,16}?([A-Z]):\\\\`,
|
|
58
|
+
'gi'
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
for (const name of ['root_preference_sqlite.db', 'root_preference_sqlite.db-wal']) {
|
|
62
|
+
try {
|
|
63
|
+
const text = fs.readFileSync(path.join(baseDir, name)).toString('latin1');
|
|
64
|
+
for (const match of text.matchAll(googleDriveNameRe)) {
|
|
65
|
+
setEmailByDriveLetter(emails, match[2], match[1]);
|
|
66
|
+
}
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return emails;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getDriveEmailsFromRecentLogs() {
|
|
74
|
+
const emails = new Map();
|
|
75
|
+
const logDir = path.join(driveFsBaseDir(), 'Logs');
|
|
76
|
+
let files = [];
|
|
77
|
+
try {
|
|
78
|
+
files = fs.readdirSync(logDir)
|
|
79
|
+
.filter(name => /^drive_fs(?:_\d+)?\.txt$/i.test(name))
|
|
80
|
+
.map(name => {
|
|
81
|
+
const filePath = path.join(logDir, name);
|
|
82
|
+
return { filePath, mtimeMs: fs.statSync(filePath).mtimeMs };
|
|
83
|
+
})
|
|
84
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs)
|
|
85
|
+
.slice(-8);
|
|
86
|
+
} catch {
|
|
87
|
+
return emails;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const logMountRe = new RegExp(
|
|
91
|
+
`name:\\s*(${EMAIL_RE_SOURCE}) - Google(?: Drive|\\.\\.\\.).*?mount_point(?:\\(raw\\))?:\\s*"?([A-Z]):\\\\`,
|
|
92
|
+
'gi'
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
for (const { filePath } of files) {
|
|
96
|
+
try {
|
|
97
|
+
const buf = fs.readFileSync(filePath);
|
|
98
|
+
const tail = buf.length > 2 * 1024 * 1024 ? buf.subarray(buf.length - 2 * 1024 * 1024) : buf;
|
|
99
|
+
const text = tail.toString('utf-8');
|
|
100
|
+
for (const match of text.matchAll(logMountRe)) {
|
|
101
|
+
setEmailByDriveLetter(emails, match[2], match[1]);
|
|
102
|
+
}
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return emails;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function getWindowsMountLetters() {
|
|
110
|
+
const letters = new Set();
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const { stdout } = await execFileAsync(
|
|
114
|
+
'reg.exe',
|
|
115
|
+
['query', 'HKCU\\Software\\Google\\DriveFS', '/v', 'PerAccountPreferences'],
|
|
116
|
+
{ windowsHide: true, timeout: 3000 }
|
|
117
|
+
);
|
|
118
|
+
const match = stdout.match(/REG_\w+\s+(.+)/);
|
|
119
|
+
if (match) {
|
|
120
|
+
const prefs = JSON.parse(match[1].trim());
|
|
121
|
+
for (const acct of prefs.per_account_preferences || []) {
|
|
122
|
+
const letter = normalizeDriveLetter(acct.value && acct.value.mount_point_path);
|
|
123
|
+
if (letter) letters.add(letter);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
|
|
128
|
+
// Probed in PARALLEL, each with a deadline: a wedged letter costs 2 s once
|
|
129
|
+
// instead of blocking the main thread for ever.
|
|
130
|
+
const probedLetters = await Promise.all([...'GHIJKLMNOPQRSTUVWXYZ'].map(
|
|
131
|
+
async letter => (await exists(`${letter}:\\My Drive`) ? letter : null)
|
|
132
|
+
));
|
|
133
|
+
for (const letter of probedLetters) {
|
|
134
|
+
if (letter) letters.add(letter);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return letters;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function getPsDriveInfo() {
|
|
141
|
+
const letters = new Set();
|
|
142
|
+
const emails = new Map();
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const { stdout } = await execFileAsync(
|
|
146
|
+
'powershell.exe',
|
|
147
|
+
[
|
|
148
|
+
'-NoProfile',
|
|
149
|
+
'-Command',
|
|
150
|
+
'Get-PSDrive -PSProvider FileSystem | ForEach-Object { "$($_.Name)|$($_.Description)" }',
|
|
151
|
+
],
|
|
152
|
+
{ windowsHide: true, timeout: 5000 }
|
|
153
|
+
);
|
|
154
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
155
|
+
const [name, desc] = line.split('|');
|
|
156
|
+
const letter = normalizeDriveLetter(name);
|
|
157
|
+
if (!letter) continue;
|
|
158
|
+
letters.add(letter);
|
|
159
|
+
|
|
160
|
+
const emailMatch = String(desc || '').match(new RegExp(`(${EMAIL_RE_SOURCE})`, 'i'));
|
|
161
|
+
if (emailMatch) setEmailByDriveLetter(emails, name, emailMatch[1]);
|
|
162
|
+
}
|
|
163
|
+
} catch {}
|
|
164
|
+
|
|
165
|
+
return { letters, emails };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function getMacAccounts(accounts, seen) {
|
|
169
|
+
const cloudBase = path.join(os.homedir(), 'Library', 'CloudStorage');
|
|
170
|
+
try {
|
|
171
|
+
for (const entry of await readdirBounded(cloudBase)) {
|
|
172
|
+
if (entry === 'GoogleDrive') {
|
|
173
|
+
const myDrive = path.join(cloudBase, entry, 'My Drive');
|
|
174
|
+
if (await exists(myDrive)) addAccount(accounts, seen, 'google', 'Google Drive', myDrive);
|
|
175
|
+
} else if (entry.startsWith('GoogleDrive-')) {
|
|
176
|
+
const myDrive = path.join(cloudBase, entry, 'My Drive');
|
|
177
|
+
if (await exists(myDrive)) {
|
|
178
|
+
addAccount(accounts, seen, 'google', entry.replace('GoogleDrive-', '').replace(/_/g, '.'), myDrive);
|
|
179
|
+
}
|
|
180
|
+
} else if (entry === 'OneDrive' || entry.startsWith('OneDrive-')) {
|
|
181
|
+
const label = entry === 'OneDrive' ? 'OneDrive' : entry.replace('OneDrive-', 'OneDrive - ').replace(/_/g, '.');
|
|
182
|
+
addAccount(accounts, seen, 'onedrive', label, path.join(cloudBase, entry));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch {}
|
|
186
|
+
|
|
187
|
+
const iCloud = path.join(os.homedir(), 'Library', 'Mobile Documents', 'com~apple~CloudDocs');
|
|
188
|
+
if (await exists(iCloud)) addAccount(accounts, seen, 'icloud', 'iCloud Drive', iCloud);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function addWindowsOneDriveAccounts(accounts, seen) {
|
|
192
|
+
const envCandidates = [
|
|
193
|
+
['OneDrive Business', process.env.OneDriveCommercial],
|
|
194
|
+
['OneDrive Personal', process.env.OneDriveConsumer],
|
|
195
|
+
['OneDrive', process.env.OneDrive],
|
|
196
|
+
];
|
|
197
|
+
for (const [name, dir] of envCandidates) {
|
|
198
|
+
if (dir && await exists(dir)) addAccount(accounts, seen, 'onedrive', name, dir);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const oneDriveRoot = path.join(os.homedir(), 'OneDrive');
|
|
203
|
+
if (await exists(oneDriveRoot)) addAccount(accounts, seen, 'onedrive', 'OneDrive', oneDriveRoot);
|
|
204
|
+
} catch {}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function addWindowsICloudAccounts(accounts, seen) {
|
|
208
|
+
for (const dir of [
|
|
209
|
+
path.join(os.homedir(), 'iCloudDrive'),
|
|
210
|
+
path.join(os.homedir(), 'iCloud Drive'),
|
|
211
|
+
]) {
|
|
212
|
+
if (await exists(dir)) addAccount(accounts, seen, 'icloud', 'iCloud Drive', dir);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function getWindowsAccounts(accounts, seen) {
|
|
217
|
+
const letters = await getWindowsMountLetters();
|
|
218
|
+
const psDrive = await getPsDriveInfo();
|
|
219
|
+
const emailByLetter = psDrive.emails;
|
|
220
|
+
|
|
221
|
+
mergeMissingDriveEmails(emailByLetter, getDriveEmailsFromPreferenceCache());
|
|
222
|
+
mergeMissingDriveEmails(emailByLetter, getDriveEmailsFromRecentLogs());
|
|
223
|
+
|
|
224
|
+
for (const letter of emailByLetter.keys()) {
|
|
225
|
+
if (psDrive.letters.has(letter)) letters.add(letter);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const letter of [...letters].sort()) {
|
|
229
|
+
const myDrive = `${letter}:\\My Drive`;
|
|
230
|
+
addAccount(accounts, seen, 'google', emailByLetter.get(letter) || `Google Drive (${letter}:)`, myDrive);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await addWindowsOneDriveAccounts(accounts, seen);
|
|
234
|
+
await addWindowsICloudAccounts(accounts, seen);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function getCloudAccounts() {
|
|
238
|
+
const accounts = [];
|
|
239
|
+
const seen = new Set();
|
|
240
|
+
|
|
241
|
+
if (process.platform === 'darwin') {
|
|
242
|
+
await getMacAccounts(accounts, seen);
|
|
243
|
+
} else if (process.platform === 'win32') {
|
|
244
|
+
await getWindowsAccounts(accounts, seen);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return accounts;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = getCloudAccounts;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const CONFLICT_TOMBSTONE_MAX_AGE_MS = 30 * 86400 * 1000;
|
|
6
|
+
|
|
7
|
+
function timestamp(value) {
|
|
8
|
+
const n = Number(value) || 0;
|
|
9
|
+
return Number.isFinite(n) ? n : 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function cleanTitle(value) {
|
|
13
|
+
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, 240);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function cleanText(value) {
|
|
17
|
+
return String(value == null ? '' : value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function uniqueId(parts) {
|
|
21
|
+
return crypto.createHash('sha256').update(parts.map(part => String(part == null ? '' : part)).join('\0'), 'utf8').digest('hex').slice(0, 24);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeSnapshot(snapshot) {
|
|
25
|
+
const raw = snapshot && typeof snapshot === 'object' ? snapshot : {};
|
|
26
|
+
const groups = Array.isArray(raw.groups)
|
|
27
|
+
? [...new Set(raw.groups.map(group => String(group || '').trim()).filter(Boolean))]
|
|
28
|
+
: [];
|
|
29
|
+
return {
|
|
30
|
+
id: String(raw.id || ''),
|
|
31
|
+
type: raw.type === 'image' ? 'image' : 'text',
|
|
32
|
+
title: cleanTitle(raw.title),
|
|
33
|
+
text: cleanText(raw.text),
|
|
34
|
+
groups,
|
|
35
|
+
ts: timestamp(raw.ts),
|
|
36
|
+
updatedAt: timestamp(raw.updatedAt),
|
|
37
|
+
titleUpdatedAt: timestamp(raw.titleUpdatedAt),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function defaultResult(record) {
|
|
42
|
+
const right = normalizeSnapshot(record && record.right);
|
|
43
|
+
const left = normalizeSnapshot(record && record.left);
|
|
44
|
+
return {
|
|
45
|
+
title: right.title || left.title,
|
|
46
|
+
text: right.text || left.text,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeConflictRecord(record, now = Date.now()) {
|
|
51
|
+
if (!record || typeof record !== 'object') return null;
|
|
52
|
+
const kind = ['editor', 'title', 'merge'].includes(record.kind) ? record.kind : 'merge';
|
|
53
|
+
const createdAt = timestamp(record.createdAt) || timestamp(record.updatedAt) || now;
|
|
54
|
+
const updatedAt = timestamp(record.updatedAt) || createdAt;
|
|
55
|
+
const left = normalizeSnapshot(record.left);
|
|
56
|
+
const right = normalizeSnapshot(record.right);
|
|
57
|
+
const base = record.base ? normalizeSnapshot(record.base) : null;
|
|
58
|
+
const targetId = String(record.targetId || right.id || left.id || '');
|
|
59
|
+
const resultSource = record.result && typeof record.result === 'object' ? record.result : defaultResult({ left, right });
|
|
60
|
+
const id = String(record.id || `conf:${kind}:${uniqueId([targetId, left.id, left.title, left.text, right.id, right.title, right.text])}`);
|
|
61
|
+
return {
|
|
62
|
+
id,
|
|
63
|
+
kind,
|
|
64
|
+
status: record.status === 'resolved' ? 'resolved' : 'unresolved',
|
|
65
|
+
source: String(record.source || ''),
|
|
66
|
+
targetId,
|
|
67
|
+
createdAt,
|
|
68
|
+
updatedAt,
|
|
69
|
+
base,
|
|
70
|
+
left,
|
|
71
|
+
right,
|
|
72
|
+
result: {
|
|
73
|
+
title: cleanTitle(resultSource.title),
|
|
74
|
+
text: cleanText(resultSource.text),
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeTombstones(list, now = Date.now()) {
|
|
80
|
+
const cutoff = now - CONFLICT_TOMBSTONE_MAX_AGE_MS;
|
|
81
|
+
const byId = new Map();
|
|
82
|
+
for (const tombstone of Array.isArray(list) ? list : []) {
|
|
83
|
+
if (!tombstone || !tombstone.id) continue;
|
|
84
|
+
const deletedAt = timestamp(tombstone.deletedAt);
|
|
85
|
+
if (deletedAt < cutoff) continue;
|
|
86
|
+
const existing = byId.get(tombstone.id);
|
|
87
|
+
if (!existing || deletedAt > existing.deletedAt) byId.set(tombstone.id, { id: String(tombstone.id), deletedAt });
|
|
88
|
+
}
|
|
89
|
+
return [...byId.values()];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeConflictState(state, now = Date.now()) {
|
|
93
|
+
const raw = state && typeof state === 'object' ? state : {};
|
|
94
|
+
const tombstones = normalizeTombstones(raw.tombstones, now);
|
|
95
|
+
const tombstoneById = new Map(tombstones.map(t => [t.id, t.deletedAt]));
|
|
96
|
+
const recordsById = new Map();
|
|
97
|
+
const records = Array.isArray(raw.records)
|
|
98
|
+
? raw.records
|
|
99
|
+
: Array.isArray(raw.conflicts)
|
|
100
|
+
? raw.conflicts
|
|
101
|
+
: [];
|
|
102
|
+
for (const rawRecord of records) {
|
|
103
|
+
const record = normalizeConflictRecord(rawRecord, now);
|
|
104
|
+
if (!record) continue;
|
|
105
|
+
const deletedAt = tombstoneById.get(record.id) || 0;
|
|
106
|
+
if (deletedAt) continue;
|
|
107
|
+
const existing = recordsById.get(record.id);
|
|
108
|
+
if (!existing || record.updatedAt >= existing.updatedAt) recordsById.set(record.id, record);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
version: 1,
|
|
112
|
+
records: [...recordsById.values()].sort((a, b) => b.updatedAt - a.updatedAt),
|
|
113
|
+
tombstones,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function mergeConflictStates(local, remote, now = Date.now()) {
|
|
118
|
+
const left = normalizeConflictState(local, now);
|
|
119
|
+
const right = normalizeConflictState(remote, now);
|
|
120
|
+
return normalizeConflictState({
|
|
121
|
+
version: 1,
|
|
122
|
+
records: [...left.records, ...right.records],
|
|
123
|
+
tombstones: [...left.tombstones, ...right.tombstones],
|
|
124
|
+
}, now);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createConflictRecord(input, options = {}) {
|
|
128
|
+
const now = timestamp(options.now) || Date.now();
|
|
129
|
+
return normalizeConflictRecord({
|
|
130
|
+
...(input || {}),
|
|
131
|
+
source: options.source || (input && input.source) || '',
|
|
132
|
+
createdAt: timestamp(input && input.createdAt) || now,
|
|
133
|
+
updatedAt: timestamp(input && input.updatedAt) || now,
|
|
134
|
+
status: 'unresolved',
|
|
135
|
+
}, now);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function upsertConflictRecord(state, record, now = Date.now()) {
|
|
139
|
+
if (!record) return normalizeConflictState(state, now);
|
|
140
|
+
const normalized = normalizeConflictState(state, now);
|
|
141
|
+
return normalizeConflictState({
|
|
142
|
+
...normalized,
|
|
143
|
+
records: [...normalized.records, record],
|
|
144
|
+
}, now);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function removeConflictRecord(state, id, now = Date.now()) {
|
|
148
|
+
const normalized = normalizeConflictState(state, now);
|
|
149
|
+
const key = String(id || '');
|
|
150
|
+
if (!key) return normalized;
|
|
151
|
+
return normalizeConflictState({
|
|
152
|
+
version: 1,
|
|
153
|
+
records: normalized.records.filter(record => record.id !== key),
|
|
154
|
+
tombstones: [...normalized.tombstones, { id: key, deletedAt: now }],
|
|
155
|
+
}, now);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// What resolving a conflict writes back to the clip (main's
|
|
159
|
+
// applyConflictResolution): accept_left / accept_right = that side verbatim
|
|
160
|
+
// (its snapshot carries the title and groups); save and keep_both = the text
|
|
161
|
+
// the view sends (keep_both's is the union of both sides, so it saves like
|
|
162
|
+
// save); remove, or anything else, writes nothing and only drops the record.
|
|
163
|
+
// Returns { snapshot, text, title } or null.
|
|
164
|
+
function conflictResolutionWrite(record, payload) {
|
|
165
|
+
const p = payload && typeof payload === 'object' ? payload : {};
|
|
166
|
+
const action = p.action || 'save';
|
|
167
|
+
if (action === 'accept_left' || action === 'accept_right') {
|
|
168
|
+
const snapshot = record && (action === 'accept_left' ? record.left : record.right);
|
|
169
|
+
return snapshot ? { snapshot, text: String(snapshot.text || ''), title: snapshot.title } : null;
|
|
170
|
+
}
|
|
171
|
+
if (action === 'save' || action === 'keep_both') return { snapshot: null, text: String(p.text || ''), title: p.title };
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
conflictResolutionWrite,
|
|
177
|
+
CONFLICT_TOMBSTONE_MAX_AGE_MS,
|
|
178
|
+
normalizeSnapshot,
|
|
179
|
+
normalizeConflictRecord,
|
|
180
|
+
normalizeTombstones,
|
|
181
|
+
normalizeConflictState,
|
|
182
|
+
mergeConflictStates,
|
|
183
|
+
createConflictRecord,
|
|
184
|
+
upsertConflictRecord,
|
|
185
|
+
removeConflictRecord,
|
|
186
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Local control channel - HELPER side (used by mcp/boardclip-mcp.js).
|
|
4
|
+
//
|
|
5
|
+
// Connects to the running app's named pipe / Unix socket, signs one request with
|
|
6
|
+
// the shared secret from the discovery file, and awaits the single-line JSON
|
|
7
|
+
// response. If the app is not running (no pipe), callers get a clean
|
|
8
|
+
// `app_not_running` error so MCP tools can tell the user to open BoardClip.
|
|
9
|
+
//
|
|
10
|
+
// `timeoutMs` is the longest SILENCE tolerated, not a hard deadline: the app
|
|
11
|
+
// pulses `{pending: true}` while it is still working (an approval prompt is
|
|
12
|
+
// open and the user is reading it), and every pulse re-arms the timer. Pass
|
|
13
|
+
// `keepalive: false` to get the old hard deadline.
|
|
14
|
+
|
|
15
|
+
const net = require('net');
|
|
16
|
+
const hmacAuth = require('./hmac-auth');
|
|
17
|
+
const mcpPaths = require('./mcp-paths');
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
class AppNotRunningError extends Error {
|
|
22
|
+
constructor(message = 'app_not_running') {
|
|
23
|
+
super(message);
|
|
24
|
+
this.code = 'app_not_running';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Send a single request and resolve with the `result` field, or reject.
|
|
29
|
+
function request(method, reqPath, payload, { discovery, timeoutMs = DEFAULT_TIMEOUT_MS, keepalive = true } = {}) {
|
|
30
|
+
const disco = discovery || mcpPaths.readDiscovery();
|
|
31
|
+
if (!disco || !disco.pipePath || !disco.secret) {
|
|
32
|
+
return Promise.reject(new AppNotRunningError());
|
|
33
|
+
}
|
|
34
|
+
const body = JSON.stringify(payload || {});
|
|
35
|
+
const ts = Date.now();
|
|
36
|
+
const sig = hmacAuth.sign(disco.secret, method, reqPath, ts, hmacAuth.bodyHash(Buffer.from(body)));
|
|
37
|
+
const wire = `${JSON.stringify({ id: ts, method, path: reqPath, ts, sig, body, keepalive: keepalive ? 1 : undefined })}\n`;
|
|
38
|
+
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
let settled = false;
|
|
41
|
+
let buffer = '';
|
|
42
|
+
let timer = null;
|
|
43
|
+
const conn = net.connect(disco.pipePath);
|
|
44
|
+
const done = (fn, arg) => {
|
|
45
|
+
if (settled) return;
|
|
46
|
+
settled = true;
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
try { conn.destroy(); } catch {}
|
|
49
|
+
fn(arg);
|
|
50
|
+
};
|
|
51
|
+
const arm = () => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
timer = setTimeout(() => done(reject, new Error('control_timeout')), timeoutMs);
|
|
54
|
+
};
|
|
55
|
+
arm();
|
|
56
|
+
|
|
57
|
+
conn.setEncoding('utf8');
|
|
58
|
+
conn.on('connect', () => {
|
|
59
|
+
try { conn.write(wire); } catch (err) { done(reject, err); }
|
|
60
|
+
});
|
|
61
|
+
conn.on('data', chunk => {
|
|
62
|
+
buffer += chunk;
|
|
63
|
+
let index;
|
|
64
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
65
|
+
const line = buffer.slice(0, index);
|
|
66
|
+
buffer = buffer.slice(index + 1);
|
|
67
|
+
let res;
|
|
68
|
+
try { res = JSON.parse(line); } catch { return done(reject, new Error('bad_response')); }
|
|
69
|
+
// Still working (e.g. the approval prompt is open): keep waiting.
|
|
70
|
+
if (res && res.pending) { arm(); continue; }
|
|
71
|
+
if (res && res.ok) return done(resolve, res.result);
|
|
72
|
+
const failure = new Error((res && res.error) || 'control_error');
|
|
73
|
+
if (res && typeof res.code === 'string') failure.code = res.code;
|
|
74
|
+
if (res && res.details && typeof res.details === 'object') failure.details = res.details;
|
|
75
|
+
return done(reject, failure);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
conn.on('error', err => {
|
|
79
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ECONNREFUSED')) {
|
|
80
|
+
return done(reject, new AppNotRunningError());
|
|
81
|
+
}
|
|
82
|
+
done(reject, err);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { request, AppNotRunningError, DEFAULT_TIMEOUT_MS };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Local control channel - APP side.
|
|
4
|
+
//
|
|
5
|
+
// A named pipe (Windows) / Unix domain socket (macOS/Linux) server, NOT an HTTP
|
|
6
|
+
// server: no TCP port, no firewall prompt. The standalone MCP helper connects
|
|
7
|
+
// here for the few actions that need the live app (approval modal, clipboard
|
|
8
|
+
// write, race-free mutation). Every request is HMAC-authed with the shared
|
|
9
|
+
// secret from the discovery file (defence-in-depth on top of the OS user-scoping
|
|
10
|
+
// of the pipe).
|
|
11
|
+
//
|
|
12
|
+
// Pure transport + auth + framing. The actual action dispatch is injected as
|
|
13
|
+
// `handleRequest(method, payload, { signal })` by main.js, so all mutation logic
|
|
14
|
+
// stays in the app and is never duplicated here.
|
|
15
|
+
//
|
|
16
|
+
// Two liveness rules protect a request that waits on the user (approval modal):
|
|
17
|
+
// - `signal` aborts when the caller's socket closes (it timed out or died), so
|
|
18
|
+
// the app can drop the prompt instead of running the action for nobody.
|
|
19
|
+
// - A caller that sent `keepalive` in its envelope gets `{id, pending: true}`
|
|
20
|
+
// every keepaliveMs while the request runs; its inactivity timeout then waits
|
|
21
|
+
// for as long as the user deliberates. Older helpers that never set the flag
|
|
22
|
+
// see exactly one line, the response, as before.
|
|
23
|
+
|
|
24
|
+
const net = require('net');
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const hmacAuth = require('./hmac-auth');
|
|
27
|
+
|
|
28
|
+
const MAX_LINE_BYTES = 4 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
class ControlServer {
|
|
31
|
+
constructor({ pipePath, secret, handleRequest, onError, windowMs, keepaliveMs }) {
|
|
32
|
+
this.pipePath = pipePath;
|
|
33
|
+
this.secret = secret;
|
|
34
|
+
this.handleRequest = handleRequest;
|
|
35
|
+
this.onError = onError || (() => {});
|
|
36
|
+
this.windowMs = windowMs || hmacAuth.DEFAULT_WINDOW_MS;
|
|
37
|
+
this.keepaliveMs = keepaliveMs || 10_000;
|
|
38
|
+
this.server = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
start() {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
// Clean up a stale unix socket from a previous run (Windows pipes don't
|
|
44
|
+
// leave a filesystem artifact, so this is a no-op there).
|
|
45
|
+
if (process.platform !== 'win32') {
|
|
46
|
+
try { fs.rmSync(this.pipePath, { force: true }); } catch {}
|
|
47
|
+
}
|
|
48
|
+
this.server = net.createServer(conn => this._onConnection(conn));
|
|
49
|
+
this.server.on('error', err => {
|
|
50
|
+
this.onError(err);
|
|
51
|
+
reject(err);
|
|
52
|
+
});
|
|
53
|
+
this.server.listen(this.pipePath, () => resolve(this.pipePath));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
stop() {
|
|
58
|
+
return new Promise(resolve => {
|
|
59
|
+
if (!this.server) return resolve();
|
|
60
|
+
const server = this.server;
|
|
61
|
+
this.server = null;
|
|
62
|
+
try {
|
|
63
|
+
server.close(() => {
|
|
64
|
+
if (process.platform !== 'win32') {
|
|
65
|
+
try { fs.rmSync(this.pipePath, { force: true }); } catch {}
|
|
66
|
+
}
|
|
67
|
+
resolve();
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
resolve();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_onConnection(conn) {
|
|
76
|
+
conn.setEncoding('utf8');
|
|
77
|
+
let buffer = '';
|
|
78
|
+
conn.on('error', () => {});
|
|
79
|
+
conn.on('data', chunk => {
|
|
80
|
+
buffer += chunk;
|
|
81
|
+
if (buffer.length > MAX_LINE_BYTES) {
|
|
82
|
+
this._respond(conn, null, { ok: false, error: 'request_too_large' });
|
|
83
|
+
conn.destroy();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
let index;
|
|
87
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
88
|
+
const line = buffer.slice(0, index);
|
|
89
|
+
buffer = buffer.slice(index + 1);
|
|
90
|
+
if (line.trim()) this._handleLine(conn, line);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async _handleLine(conn, line) {
|
|
96
|
+
let req;
|
|
97
|
+
try {
|
|
98
|
+
req = JSON.parse(line);
|
|
99
|
+
} catch {
|
|
100
|
+
return this._respond(conn, null, { ok: false, error: 'bad_json' });
|
|
101
|
+
}
|
|
102
|
+
const { id, method, path: reqPath, ts, sig, body } = req || {};
|
|
103
|
+
const bodyStr = typeof body === 'string' ? body : '';
|
|
104
|
+
const authed = hmacAuth.verify(this.secret, {
|
|
105
|
+
method,
|
|
106
|
+
path: reqPath,
|
|
107
|
+
timestamp: ts,
|
|
108
|
+
signature: sig,
|
|
109
|
+
bodyHashHex: hmacAuth.bodyHash(Buffer.from(bodyStr)),
|
|
110
|
+
windowMs: this.windowMs,
|
|
111
|
+
});
|
|
112
|
+
if (!authed) {
|
|
113
|
+
return this._respond(conn, id, { ok: false, error: 'unauthorized' });
|
|
114
|
+
}
|
|
115
|
+
let payload = {};
|
|
116
|
+
try { payload = bodyStr ? JSON.parse(bodyStr) : {}; } catch { payload = {}; }
|
|
117
|
+
const ac = new AbortController();
|
|
118
|
+
const onClose = () => ac.abort();
|
|
119
|
+
conn.once('close', onClose);
|
|
120
|
+
let pulse = null;
|
|
121
|
+
if (req.keepalive) {
|
|
122
|
+
pulse = setInterval(() => {
|
|
123
|
+
if (ac.signal.aborted || conn.destroyed) return;
|
|
124
|
+
this._respond(conn, id, { pending: true });
|
|
125
|
+
}, this.keepaliveMs);
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const result = await this.handleRequest(reqPath || method, payload, { signal: ac.signal });
|
|
129
|
+
this._respond(conn, id, { ok: true, result });
|
|
130
|
+
} catch (err) {
|
|
131
|
+
const failure = { ok: false, error: (err && err.message) || 'error' };
|
|
132
|
+
// Structured errors (e.g. a RevisionConflict) keep their code + details.
|
|
133
|
+
if (err && typeof err.code === 'string') failure.code = err.code;
|
|
134
|
+
if (err && err.details && typeof err.details === 'object') failure.details = err.details;
|
|
135
|
+
this._respond(conn, id, failure);
|
|
136
|
+
} finally {
|
|
137
|
+
if (pulse) clearInterval(pulse);
|
|
138
|
+
conn.removeListener('close', onClose);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_respond(conn, id, payload) {
|
|
143
|
+
try {
|
|
144
|
+
conn.write(`${JSON.stringify({ id, ...payload })}\n`);
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { ControlServer };
|