@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,297 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// macOS Carbon hotkeys cover shortcut paths where Electron's globalShortcut is
|
|
4
|
+
// incomplete or flaky: Globe/Fn popup shortcuts and numeric quick-paste slots.
|
|
5
|
+
// Unlike a Quartz event tap, this should not require Input Monitoring.
|
|
6
|
+
|
|
7
|
+
const koffi = require('koffi');
|
|
8
|
+
|
|
9
|
+
const MOD_COMMAND = 1 << 8;
|
|
10
|
+
const MOD_SHIFT = 1 << 9;
|
|
11
|
+
const MOD_ALT = 1 << 11;
|
|
12
|
+
const MOD_CONTROL = 1 << 12;
|
|
13
|
+
const MOD_FN = 1 << 17;
|
|
14
|
+
|
|
15
|
+
const KEY_CODE_BY_NAME = {
|
|
16
|
+
A: 0x00, S: 0x01, D: 0x02, F: 0x03, H: 0x04, G: 0x05, Z: 0x06, X: 0x07,
|
|
17
|
+
C: 0x08, V: 0x09, B: 0x0B, Q: 0x0C, W: 0x0D, E: 0x0E, R: 0x0F, Y: 0x10,
|
|
18
|
+
T: 0x11, 1: 0x12, 2: 0x13, 3: 0x14, 4: 0x15, 6: 0x16, 5: 0x17,
|
|
19
|
+
'=': 0x18, 9: 0x19, 7: 0x1A, Minus: 0x1B, 8: 0x1C, 0: 0x1D, ']': 0x1E,
|
|
20
|
+
O: 0x1F, U: 0x20, '[': 0x21, I: 0x22, P: 0x23, L: 0x25, J: 0x26,
|
|
21
|
+
"'": 0x27, K: 0x28, ';': 0x29, Backslash: 0x2A, ',': 0x2B, '/': 0x2C,
|
|
22
|
+
N: 0x2D, M: 0x2E, '.': 0x2F, '`': 0x32,
|
|
23
|
+
Enter: 0x24, Tab: 0x30, Space: 0x31, Backspace: 0x33, Escape: 0x35,
|
|
24
|
+
Delete: 0x75, Home: 0x73, End: 0x77, PageUp: 0x74, PageDown: 0x79,
|
|
25
|
+
Left: 0x7B, Right: 0x7C, Down: 0x7D, Up: 0x7E,
|
|
26
|
+
F1: 0x7A, F2: 0x78, F3: 0x63, F4: 0x76, F5: 0x60, F6: 0x61,
|
|
27
|
+
F7: 0x62, F8: 0x64, F9: 0x65, F10: 0x6D, F11: 0x67, F12: 0x6F,
|
|
28
|
+
F13: 0x69, F14: 0x6B, F15: 0x71, F16: 0x6A, F17: 0x40, F18: 0x4F,
|
|
29
|
+
F19: 0x50, F20: 0x5A,
|
|
30
|
+
num0: 0x52, num1: 0x53, num2: 0x54, num3: 0x55, num4: 0x56,
|
|
31
|
+
num5: 0x57, num6: 0x58, num7: 0x59, num8: 0x5B, num9: 0x5C,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let carbon = null;
|
|
35
|
+
let target = null;
|
|
36
|
+
let handlerCallback = null;
|
|
37
|
+
let handlerRef = null;
|
|
38
|
+
const hotKeyRefs = new Map();
|
|
39
|
+
const handlers = new Map();
|
|
40
|
+
const DEFAULT_NAMESPACE = 'default';
|
|
41
|
+
const QUICK_PASTE_NAMESPACE = 'quickPaste';
|
|
42
|
+
|
|
43
|
+
function fourCharCode(value) {
|
|
44
|
+
return (
|
|
45
|
+
(value.charCodeAt(0) << 24) |
|
|
46
|
+
(value.charCodeAt(1) << 16) |
|
|
47
|
+
(value.charCodeAt(2) << 8) |
|
|
48
|
+
value.charCodeAt(3)
|
|
49
|
+
) >>> 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EventHotKeyID = koffi.struct('EventHotKeyID', {
|
|
53
|
+
signature: 'uint32',
|
|
54
|
+
id: 'uint32',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const EventTypeSpec = koffi.struct('EventTypeSpec', {
|
|
58
|
+
eventClass: 'uint32',
|
|
59
|
+
eventKind: 'uint32',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function loadCarbon() {
|
|
63
|
+
if (carbon) return;
|
|
64
|
+
carbon = koffi.load('/System/Library/Frameworks/Carbon.framework/Carbon');
|
|
65
|
+
const EventHandlerProc = koffi.proto('int EventHandlerProc(void *nextHandler, void *event, void *userData)');
|
|
66
|
+
|
|
67
|
+
carbon.GetApplicationEventTarget = carbon.func('void *GetApplicationEventTarget()');
|
|
68
|
+
carbon.InstallEventHandler = carbon.func(
|
|
69
|
+
'int InstallEventHandler(void *target, EventHandlerProc *handler, uint32 numTypes, EventTypeSpec *list, void *userData, _Out_ void **outHandlerRef)'
|
|
70
|
+
);
|
|
71
|
+
carbon.RemoveEventHandler = carbon.func('int RemoveEventHandler(void *handlerRef)');
|
|
72
|
+
carbon.RegisterEventHotKey = carbon.func(
|
|
73
|
+
'int RegisterEventHotKey(uint32 code, uint32 modifiers, EventHotKeyID hotKeyID, void *target, uint32 options, _Out_ void **outRef)'
|
|
74
|
+
);
|
|
75
|
+
carbon.UnregisterEventHotKey = carbon.func('int UnregisterEventHotKey(void *hotKey)');
|
|
76
|
+
carbon.GetCurrentKeyModifiers = carbon.func('uint32 GetCurrentKeyModifiers()');
|
|
77
|
+
carbon.GetEventParameter = carbon.func(
|
|
78
|
+
'int GetEventParameter(void *event, uint32 name, uint32 desiredType, void *outActualType, uint32 bufferSize, void *outActualSize, _Out_ EventHotKeyID *outData)'
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
target = carbon.GetApplicationEventTarget();
|
|
82
|
+
handlerCallback = koffi.register((_nextHandler, event) => {
|
|
83
|
+
const hotKeyID = {};
|
|
84
|
+
try {
|
|
85
|
+
const status = carbon.GetEventParameter(
|
|
86
|
+
event,
|
|
87
|
+
fourCharCode('----'),
|
|
88
|
+
fourCharCode('hkid'),
|
|
89
|
+
null,
|
|
90
|
+
koffi.sizeof(EventHotKeyID),
|
|
91
|
+
null,
|
|
92
|
+
hotKeyID
|
|
93
|
+
);
|
|
94
|
+
const handler = status === 0 ? handlers.get(hotKeyID.id) : null;
|
|
95
|
+
if (handler) handler();
|
|
96
|
+
} catch (err) { console.error('[macos-hotkey] onPressed:', err); }
|
|
97
|
+
return 0;
|
|
98
|
+
}, koffi.pointer(EventHandlerProc));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeShortcutPart(part) {
|
|
102
|
+
const value = String(part || '').trim();
|
|
103
|
+
const lower = value.toLowerCase();
|
|
104
|
+
if (lower === 'globe' || lower === 'function') return 'Fn';
|
|
105
|
+
if (lower === 'cmd') return 'Command';
|
|
106
|
+
if (lower === 'ctrl') return 'Control';
|
|
107
|
+
if (lower === 'option') return 'Alt';
|
|
108
|
+
if (lower === 'super' || lower === 'meta') return 'Command';
|
|
109
|
+
if (lower === 'return') return 'Enter';
|
|
110
|
+
if (lower === 'esc') return 'Escape';
|
|
111
|
+
if (lower === 'plus') return 'Plus';
|
|
112
|
+
if (lower === 'minus') return 'Minus';
|
|
113
|
+
if (/^num[0-9]$/i.test(value)) return value.toLowerCase();
|
|
114
|
+
if (/^f([1-9]|1[0-9]|20)$/i.test(value)) return value.toUpperCase();
|
|
115
|
+
if (value.length === 1 && /[a-z]/i.test(value)) return value.toUpperCase();
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function modifierMask(part) {
|
|
120
|
+
switch (normalizeShortcutPart(part)) {
|
|
121
|
+
case 'Command':
|
|
122
|
+
case 'CommandOrControl':
|
|
123
|
+
case 'CommandOrCtrl':
|
|
124
|
+
case 'CmdOrCtrl':
|
|
125
|
+
return MOD_COMMAND;
|
|
126
|
+
case 'Control': return MOD_CONTROL;
|
|
127
|
+
case 'Alt': return MOD_ALT;
|
|
128
|
+
case 'Shift': return MOD_SHIFT;
|
|
129
|
+
case 'Fn': return MOD_FN;
|
|
130
|
+
default: return 0;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function usesFn(shortcut) {
|
|
135
|
+
return String(shortcut || '').split('+').some(part => normalizeShortcutPart(part) === 'Fn');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseShortcut(shortcut) {
|
|
139
|
+
const parts = String(shortcut || '').split('+').map(normalizeShortcutPart).filter(Boolean);
|
|
140
|
+
let modifiers = 0;
|
|
141
|
+
let keyName = '';
|
|
142
|
+
for (const part of parts) {
|
|
143
|
+
const mask = modifierMask(part);
|
|
144
|
+
if (mask) modifiers |= mask;
|
|
145
|
+
else keyName = part;
|
|
146
|
+
}
|
|
147
|
+
if (keyName === 'Plus') keyName = '=';
|
|
148
|
+
const keyCode = KEY_CODE_BY_NAME[keyName];
|
|
149
|
+
if (typeof keyCode !== 'number') return null;
|
|
150
|
+
return { keyCode, modifiers };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function installHandler() {
|
|
154
|
+
if (handlerRef) return { ok: true };
|
|
155
|
+
const out = [null];
|
|
156
|
+
const status = carbon.InstallEventHandler(target, handlerCallback, 1, [{
|
|
157
|
+
eventClass: fourCharCode('keyb'),
|
|
158
|
+
eventKind: 5,
|
|
159
|
+
}], null, out);
|
|
160
|
+
if (status !== 0) return { ok: false, error: `Could not install macOS hotkey handler (${status}).` };
|
|
161
|
+
handlerRef = out[0];
|
|
162
|
+
return { ok: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function unregisterHotKey(id) {
|
|
166
|
+
const entry = hotKeyRefs.get(id);
|
|
167
|
+
if (entry && entry.ref) {
|
|
168
|
+
try { carbon.UnregisterEventHotKey(entry.ref); } catch {}
|
|
169
|
+
}
|
|
170
|
+
hotKeyRefs.delete(id);
|
|
171
|
+
handlers.delete(id);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function clearNamespace(namespace = DEFAULT_NAMESPACE) {
|
|
175
|
+
for (const [id, entry] of [...hotKeyRefs.entries()]) {
|
|
176
|
+
if (entry.namespace === namespace) unregisterHotKey(id);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function clearRuntimeShortcut() {
|
|
181
|
+
clearNamespace(DEFAULT_NAMESPACE);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function clearQuickPasteShortcuts() {
|
|
185
|
+
clearNamespace(QUICK_PASTE_NAMESPACE);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function registerParsedHotKey({ parsed, id, namespace, handler, signature = 'BChk' }) {
|
|
189
|
+
loadCarbon();
|
|
190
|
+
const handlerResult = installHandler();
|
|
191
|
+
if (!handlerResult.ok) return handlerResult;
|
|
192
|
+
|
|
193
|
+
unregisterHotKey(id);
|
|
194
|
+
const out = [null];
|
|
195
|
+
const status = carbon.RegisterEventHotKey(parsed.keyCode, parsed.modifiers, {
|
|
196
|
+
signature: fourCharCode(signature),
|
|
197
|
+
id,
|
|
198
|
+
}, target, 0, out);
|
|
199
|
+
|
|
200
|
+
if (status !== 0) {
|
|
201
|
+
const error = status === -9878
|
|
202
|
+
? 'Shortcut is already in use.'
|
|
203
|
+
: `Could not register macOS shortcut (${status}).`;
|
|
204
|
+
return { ok: false, error };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
hotKeyRefs.set(id, { ref: out[0], namespace });
|
|
208
|
+
handlers.set(id, handler);
|
|
209
|
+
return { ok: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function install({ shortcut, onPressed: handler }) {
|
|
213
|
+
if (process.platform !== 'darwin') return { ok: false, error: 'macOS hotkeys are only available on macOS.' };
|
|
214
|
+
const parsed = parseShortcut(shortcut);
|
|
215
|
+
if (!parsed) return { ok: false, error: `Could not parse ${shortcut}.` };
|
|
216
|
+
|
|
217
|
+
clearRuntimeShortcut();
|
|
218
|
+
return registerParsedHotKey({ parsed, id: 1, namespace: DEFAULT_NAMESPACE, handler });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function shortcutForSlot(shortcut, slot) {
|
|
222
|
+
const parts = String(shortcut || '').split('+').map(normalizeShortcutPart).filter(Boolean);
|
|
223
|
+
if (!parts.length) return '';
|
|
224
|
+
parts[parts.length - 1] = String(slot);
|
|
225
|
+
return parts.join('+');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function installQuickPaste({ shortcut, onSlot }) {
|
|
229
|
+
if (process.platform !== 'darwin') return { ok: false, error: 'macOS hotkeys are only available on macOS.' };
|
|
230
|
+
clearQuickPasteShortcuts();
|
|
231
|
+
for (let slot = 1; slot <= 9; slot++) {
|
|
232
|
+
const slotShortcut = shortcutForSlot(shortcut, slot);
|
|
233
|
+
const parsed = parseShortcut(slotShortcut);
|
|
234
|
+
if (!parsed) {
|
|
235
|
+
clearQuickPasteShortcuts();
|
|
236
|
+
return { ok: false, error: `Could not parse ${slotShortcut}.` };
|
|
237
|
+
}
|
|
238
|
+
const result = registerParsedHotKey({
|
|
239
|
+
parsed,
|
|
240
|
+
id: 100 + slot,
|
|
241
|
+
namespace: QUICK_PASTE_NAMESPACE,
|
|
242
|
+
handler: () => onSlot(slot),
|
|
243
|
+
signature: 'BCqp',
|
|
244
|
+
});
|
|
245
|
+
if (!result.ok) {
|
|
246
|
+
clearQuickPasteShortcuts();
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { ok: true };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function currentModifierParts() {
|
|
254
|
+
if (process.platform !== 'darwin') return [];
|
|
255
|
+
loadCarbon();
|
|
256
|
+
const modifiers = carbon.GetCurrentKeyModifiers();
|
|
257
|
+
const parts = [];
|
|
258
|
+
if (modifiers & MOD_FN) parts.push('Fn');
|
|
259
|
+
if (modifiers & MOD_COMMAND) parts.push('Command');
|
|
260
|
+
if (modifiers & MOD_CONTROL) parts.push('Control');
|
|
261
|
+
if (modifiers & MOD_ALT) parts.push('Alt');
|
|
262
|
+
if (modifiers & MOD_SHIFT) parts.push('Shift');
|
|
263
|
+
return parts;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function resolveShortcutFromCurrentModifiers(shortcut) {
|
|
267
|
+
const parts = String(shortcut || '').split('+').map(normalizeShortcutPart).filter(Boolean);
|
|
268
|
+
const keyName = parts.reverse().find(part => !modifierMask(part));
|
|
269
|
+
if (!keyName) return shortcut;
|
|
270
|
+
const modifiers = currentModifierParts();
|
|
271
|
+
return modifiers.length ? [...modifiers, keyName].join('+') : shortcut;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function uninstall() {
|
|
275
|
+
clearNamespace(DEFAULT_NAMESPACE);
|
|
276
|
+
clearNamespace(QUICK_PASTE_NAMESPACE);
|
|
277
|
+
if (handlerRef && carbon) {
|
|
278
|
+
try { carbon.RemoveEventHandler(handlerRef); } catch {}
|
|
279
|
+
}
|
|
280
|
+
handlerRef = null;
|
|
281
|
+
if (handlerCallback) {
|
|
282
|
+
try { koffi.unregister(handlerCallback); } catch {}
|
|
283
|
+
}
|
|
284
|
+
handlerCallback = null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
install,
|
|
289
|
+
clearRuntimeShortcut,
|
|
290
|
+
installQuickPaste,
|
|
291
|
+
clearQuickPasteShortcuts,
|
|
292
|
+
currentModifierParts,
|
|
293
|
+
resolveShortcutFromCurrentModifiers,
|
|
294
|
+
uninstall,
|
|
295
|
+
usesFn,
|
|
296
|
+
parseShortcut,
|
|
297
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
if (process.platform !== 'darwin') {
|
|
4
|
+
module.exports = {
|
|
5
|
+
sendCommandV() { return { ok: true }; },
|
|
6
|
+
typeText() { return { ok: false, chars: 0 }; },
|
|
7
|
+
};
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const koffi = require('koffi');
|
|
12
|
+
|
|
13
|
+
let coreGraphics = null;
|
|
14
|
+
let coreFoundation = null;
|
|
15
|
+
|
|
16
|
+
const KEY_V = 0x09;
|
|
17
|
+
const KEY_COMMAND = 0x37;
|
|
18
|
+
const kCGHIDEventTap = 0;
|
|
19
|
+
const kCGEventFlagMaskCommand = 1 << 20;
|
|
20
|
+
|
|
21
|
+
function loadFrameworks() {
|
|
22
|
+
if (coreGraphics) return;
|
|
23
|
+
coreGraphics = koffi.load('/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics');
|
|
24
|
+
coreFoundation = koffi.load('/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation');
|
|
25
|
+
|
|
26
|
+
coreGraphics.CGEventCreateKeyboardEvent = coreGraphics.func(
|
|
27
|
+
'void *CGEventCreateKeyboardEvent(void *source, uint16 virtualKey, bool keyDown)'
|
|
28
|
+
);
|
|
29
|
+
coreGraphics.CGEventSetFlags = coreGraphics.func(
|
|
30
|
+
'void CGEventSetFlags(void *event, uint64 flags)'
|
|
31
|
+
);
|
|
32
|
+
coreGraphics.CGEventPost = coreGraphics.func(
|
|
33
|
+
'void CGEventPost(uint32 tap, void *event)'
|
|
34
|
+
);
|
|
35
|
+
// Sets the Unicode payload of a keyboard event: posting it inserts the exact
|
|
36
|
+
// string as text (layout-independent), the macOS analogue of Windows'
|
|
37
|
+
// KEYEVENTF_UNICODE. UniChar is uint16, so we pass a UTF-16 code-unit buffer.
|
|
38
|
+
coreGraphics.CGEventKeyboardSetUnicodeString = coreGraphics.func(
|
|
39
|
+
'void CGEventKeyboardSetUnicodeString(void *event, uintptr_t length, uint16 *string)'
|
|
40
|
+
);
|
|
41
|
+
coreFoundation.CFRelease = coreFoundation.func(
|
|
42
|
+
'void CFRelease(void *cf)'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function postKey(keyCode, keyDown, flags) {
|
|
47
|
+
const event = coreGraphics.CGEventCreateKeyboardEvent(null, keyCode, keyDown);
|
|
48
|
+
if (!event) throw new Error(`CGEventCreateKeyboardEvent failed for key ${keyCode}`);
|
|
49
|
+
try {
|
|
50
|
+
coreGraphics.CGEventSetFlags(event, flags);
|
|
51
|
+
coreGraphics.CGEventPost(kCGHIDEventTap, event);
|
|
52
|
+
} finally {
|
|
53
|
+
coreFoundation.CFRelease(event);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sendCommandV() {
|
|
58
|
+
try {
|
|
59
|
+
loadFrameworks();
|
|
60
|
+
postKey(KEY_COMMAND, true, kCGEventFlagMaskCommand);
|
|
61
|
+
postKey(KEY_V, true, kCGEventFlagMaskCommand);
|
|
62
|
+
postKey(KEY_V, false, kCGEventFlagMaskCommand);
|
|
63
|
+
postKey(KEY_COMMAND, false, 0);
|
|
64
|
+
return { ok: true };
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return { ok: false, error: error && error.message };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Post a chunk of text as a single keyboard event carrying its Unicode string.
|
|
71
|
+
// The whole chunk is delivered in one event (not char-by-char), so it is fast.
|
|
72
|
+
function postUnicodeChunk(str) {
|
|
73
|
+
const units = [];
|
|
74
|
+
for (let i = 0; i < str.length; i++) units.push(str.charCodeAt(i));
|
|
75
|
+
const buf = Uint16Array.from(units);
|
|
76
|
+
const down = coreGraphics.CGEventCreateKeyboardEvent(null, 0, true);
|
|
77
|
+
if (!down) throw new Error('CGEventCreateKeyboardEvent failed');
|
|
78
|
+
try {
|
|
79
|
+
coreGraphics.CGEventKeyboardSetUnicodeString(down, buf.length, buf);
|
|
80
|
+
coreGraphics.CGEventPost(kCGHIDEventTap, down);
|
|
81
|
+
} finally {
|
|
82
|
+
coreFoundation.CFRelease(down);
|
|
83
|
+
}
|
|
84
|
+
const up = coreGraphics.CGEventCreateKeyboardEvent(null, 0, false);
|
|
85
|
+
if (!up) return;
|
|
86
|
+
try {
|
|
87
|
+
coreGraphics.CGEventKeyboardSetUnicodeString(up, buf.length, buf);
|
|
88
|
+
coreGraphics.CGEventPost(kCGHIDEventTap, up);
|
|
89
|
+
} finally {
|
|
90
|
+
coreFoundation.CFRelease(up);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Type text by synthesizing keyboard events carrying the Unicode string —
|
|
95
|
+
// bypassing the clipboard entirely (no backup/restore race, clipboard
|
|
96
|
+
// untouched). Chunked so a huge string doesn't overflow a single event.
|
|
97
|
+
function typeText(text) {
|
|
98
|
+
const str = String(text == null ? '' : text);
|
|
99
|
+
if (!str) return { ok: true, chars: 0 };
|
|
100
|
+
try {
|
|
101
|
+
loadFrameworks();
|
|
102
|
+
const CHUNK = 200;
|
|
103
|
+
for (let i = 0; i < str.length; i += CHUNK) {
|
|
104
|
+
postUnicodeChunk(str.slice(i, i + CHUNK));
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, chars: str.length };
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return { ok: false, chars: str.length, error: error && error.message };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { sendCommandV, typeText };
|
package/lib/mcp-core.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure context-shaping for the MCP server.
|
|
4
|
+
//
|
|
5
|
+
// Given the raw clipboard history + settings (read from the JSON files by the
|
|
6
|
+
// helper, or held in memory by the app), this builds exactly what an AI
|
|
7
|
+
// assistant is allowed to see, applying one filter:
|
|
8
|
+
//
|
|
9
|
+
// Allowlist by curation - only clips in a group the user shared with AI
|
|
10
|
+
// ("groups_shared_with_ai", which always includes the auto "AI" group)
|
|
11
|
+
// expose their content. Everything else is metadata only. Sharing is fully
|
|
12
|
+
// opt-in per group, so a clip the user put in a shared group is shared as-is.
|
|
13
|
+
//
|
|
14
|
+
// No I/O here: text-blob hydration and image bytes are the caller's job. This
|
|
15
|
+
// keeps the module pure + unit-testable and lets both the helper (file-backed)
|
|
16
|
+
// and the app (memory-backed) reuse the identical boundary.
|
|
17
|
+
|
|
18
|
+
const model = require('./clipboard-model');
|
|
19
|
+
const search = require('../site/shared/clip-search');
|
|
20
|
+
|
|
21
|
+
const AI_GROUP_NAME = 'AI';
|
|
22
|
+
const DEFAULT_PREVIEW_CHARS = 280;
|
|
23
|
+
const DEFAULT_LIST_LIMIT = 50;
|
|
24
|
+
|
|
25
|
+
function sharedGroupSet(settings) {
|
|
26
|
+
const configured = settings && Array.isArray(settings.groups_shared_with_ai)
|
|
27
|
+
? settings.groups_shared_with_ai
|
|
28
|
+
: [];
|
|
29
|
+
const set = new Set(configured);
|
|
30
|
+
// The auto "AI" group is the quick drop-bucket and is always shared.
|
|
31
|
+
set.add(AI_GROUP_NAME);
|
|
32
|
+
return set;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A clip carries a format when the value is inline OR externalized to a blob.
|
|
36
|
+
// Checked without loading the blob: this is availability, not content.
|
|
37
|
+
function hasRichPayload(item, field) {
|
|
38
|
+
if (!item || item.type === 'image') return false;
|
|
39
|
+
const names = model.blobFieldNames(field);
|
|
40
|
+
return !!(item[names.value] || item[names.ref] || item[names.hash]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isShared(item, sharedSet) {
|
|
44
|
+
const groups = model.groupsOf(item);
|
|
45
|
+
for (const g of groups) if (sharedSet.has(g)) return true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function previewText(item, chars) {
|
|
50
|
+
const raw = item && item.type !== 'image'
|
|
51
|
+
? String(item.text != null && item.text !== '' ? item.text : (item.textPreview || ''))
|
|
52
|
+
: '';
|
|
53
|
+
if (raw.length <= chars) return raw;
|
|
54
|
+
return `${raw.slice(0, chars)}…`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function searchableText(item, { sharedSet = null, forceAll = false } = {}) {
|
|
58
|
+
const shared = sharedSet ? isShared(item, sharedSet) : false;
|
|
59
|
+
const includeTitle = forceAll || shared;
|
|
60
|
+
return [
|
|
61
|
+
includeTitle ? model.titleOf(item) : '',
|
|
62
|
+
item && item.type === 'image' ? (item.image || '') : (item && item.text != null ? item.text : item && item.textPreview || ''),
|
|
63
|
+
item && item.type || '',
|
|
64
|
+
...(includeTitle ? model.groupsOf(item) : []),
|
|
65
|
+
].filter(Boolean).join(' ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Shape a single clip for AI consumption. `forcePreview` is set only on the
|
|
69
|
+
// post-approval ('all') path, where the user has explicitly approved reading
|
|
70
|
+
// beyond the allowlist - it surfaces the preview even for a non-shared clip.
|
|
71
|
+
function clipView(item, { sharedSet, previewChars = DEFAULT_PREVIEW_CHARS, forcePreview = false } = {}) {
|
|
72
|
+
const id = model.itemKey(item);
|
|
73
|
+
const shared = isShared(item, sharedSet);
|
|
74
|
+
const base = {
|
|
75
|
+
id,
|
|
76
|
+
type: item.type === 'image' ? 'image' : 'text',
|
|
77
|
+
ts: item.ts || 0,
|
|
78
|
+
pinned: model.isPinned(item),
|
|
79
|
+
numpad: model.numpadSlotOf(item),
|
|
80
|
+
rev: model.clipRevision(item),
|
|
81
|
+
shared,
|
|
82
|
+
};
|
|
83
|
+
if (!shared && !forcePreview) {
|
|
84
|
+
// Metadata only: no content, no group names, no preview.
|
|
85
|
+
return base;
|
|
86
|
+
}
|
|
87
|
+
if (!shared && forcePreview) base.viaApproval = true;
|
|
88
|
+
// Only expose the SHARED group names a clip belongs to - never the names of
|
|
89
|
+
// any private/non-shared groups it is also a member of.
|
|
90
|
+
base.groups = model.groupsOf(item).filter(g => sharedSet.has(g));
|
|
91
|
+
if (item.type === 'image') {
|
|
92
|
+
base.image = item.image || null;
|
|
93
|
+
if (item.width) base.width = item.width;
|
|
94
|
+
if (item.height) base.height = item.height;
|
|
95
|
+
return base;
|
|
96
|
+
}
|
|
97
|
+
const title = model.titleOf(item);
|
|
98
|
+
if (title) base.title = title;
|
|
99
|
+
base.preview = previewText(item, previewChars);
|
|
100
|
+
// Advertise that formatting exists WITHOUT shipping it. The raw HTML/RTF is
|
|
101
|
+
// large and low-value to a model, but knowing a clip is rich is useful.
|
|
102
|
+
if (hasRichPayload(item, 'html')) base.has_html = true;
|
|
103
|
+
if (hasRichPayload(item, 'rtf')) base.has_rtf = true;
|
|
104
|
+
return base;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The full-text read result shape, shared by the helper's get_clip fast-path and
|
|
108
|
+
// the app's approval-gated read_clip so both stay identical. Group names are
|
|
109
|
+
// filtered to shared-only, matching clipView's policy.
|
|
110
|
+
function fullTextResult(item, sharedSet) {
|
|
111
|
+
const groups = sharedSet ? model.groupsOf(item).filter(g => sharedSet.has(g)) : model.groupsOf(item);
|
|
112
|
+
const result = { id: model.itemKey(item), rev: model.clipRevision(item), type: 'text', text: String(item.text || ''), groups, ts: item.ts || 0 };
|
|
113
|
+
const title = model.titleOf(item);
|
|
114
|
+
if (title) result.title = title;
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Summary of the user's organisation - the "clipboard / workflows" context.
|
|
119
|
+
function buildContext(history, settings) {
|
|
120
|
+
const items = Array.isArray(history) ? history : [];
|
|
121
|
+
const sharedSet = sharedGroupSet(settings);
|
|
122
|
+
const groupNames = Array.isArray(settings && settings.groups) ? settings.groups : [];
|
|
123
|
+
const counts = new Map();
|
|
124
|
+
const numpad = {};
|
|
125
|
+
let pinnedCount = 0;
|
|
126
|
+
let sharedCount = 0;
|
|
127
|
+
|
|
128
|
+
for (const item of items) {
|
|
129
|
+
if (model.isPinned(item)) pinnedCount++;
|
|
130
|
+
const slot = model.numpadSlotOf(item);
|
|
131
|
+
if (slot != null) numpad[slot] = { id: model.itemKey(item), type: item.type === 'image' ? 'image' : 'text' };
|
|
132
|
+
for (const g of model.groupsOf(item)) counts.set(g, (counts.get(g) || 0) + 1);
|
|
133
|
+
if (isShared(item, sharedSet)) sharedCount++;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Only name the SHARED groups. Private/non-shared groups are surfaced as a bare
|
|
137
|
+
// count so the assistant knows the scale without learning private group names.
|
|
138
|
+
const groups = groupNames
|
|
139
|
+
.filter(name => sharedSet.has(name))
|
|
140
|
+
.map(name => ({ name, shared: true, count: counts.get(name) || 0 }));
|
|
141
|
+
const privateGroupCount = groupNames.filter(name => !sharedSet.has(name)).length;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
totalClips: items.length,
|
|
145
|
+
sharedClips: sharedCount,
|
|
146
|
+
pinnedClips: pinnedCount,
|
|
147
|
+
groups,
|
|
148
|
+
privateGroupCount,
|
|
149
|
+
numpadSlots: numpad,
|
|
150
|
+
aiGroup: AI_GROUP_NAME,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// List clips. By default returns shared clips with previews (most recent first);
|
|
155
|
+
// pass includeNonShared to also append metadata-only entries for the rest.
|
|
156
|
+
function listClips(history, settings, {
|
|
157
|
+
limit = DEFAULT_LIST_LIMIT,
|
|
158
|
+
previewChars = DEFAULT_PREVIEW_CHARS,
|
|
159
|
+
group = null,
|
|
160
|
+
includeNonShared = false,
|
|
161
|
+
} = {}) {
|
|
162
|
+
const items = Array.isArray(history) ? history : [];
|
|
163
|
+
const sharedSet = sharedGroupSet(settings);
|
|
164
|
+
const out = [];
|
|
165
|
+
let nonSharedTotal = 0;
|
|
166
|
+
|
|
167
|
+
for (const item of items) {
|
|
168
|
+
const shared = isShared(item, sharedSet);
|
|
169
|
+
if (!shared) {
|
|
170
|
+
nonSharedTotal++;
|
|
171
|
+
if (!includeNonShared) continue;
|
|
172
|
+
}
|
|
173
|
+
if (group && !model.groupsOf(item).includes(group)) continue;
|
|
174
|
+
out.push(clipView(item, { sharedSet, previewChars }));
|
|
175
|
+
if (out.length >= limit) break;
|
|
176
|
+
}
|
|
177
|
+
return { clips: out, nonSharedTotal, returned: out.length };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// A boundary-aware search doc: the SAME normalized shape the shared engine ranks/filters,
|
|
181
|
+
// but with title/groups HIDDEN unless the clip is shared (or forceAll after approval). So the
|
|
182
|
+
// engine's grammar (title:/since:/is:/…) works while the allowlist boundary is preserved — a
|
|
183
|
+
// `title:` scope on a non-shared clip can never match unless the user approved all-scope.
|
|
184
|
+
function clipToSearchDoc(item, { sharedSet, forceAll = false } = {}) {
|
|
185
|
+
const visible = forceAll || isShared(item, sharedSet);
|
|
186
|
+
const isImage = item && item.type === 'image';
|
|
187
|
+
const body = isImage ? String(item && item.image || '') : String(item && (item.text != null ? item.text : item.textPreview) || '');
|
|
188
|
+
return {
|
|
189
|
+
id: model.itemKey(item),
|
|
190
|
+
type: isImage ? 'image' : 'text',
|
|
191
|
+
title: visible ? model.titleOf(item) : '',
|
|
192
|
+
body,
|
|
193
|
+
groups: visible ? model.groupsOf(item) : [],
|
|
194
|
+
numpad: model.numpadSlotOf(item),
|
|
195
|
+
pinned: model.isPinned(item),
|
|
196
|
+
ts: Number(item && item.ts) || 0,
|
|
197
|
+
len: isImage ? 0 : body.length,
|
|
198
|
+
lines: isImage || !body ? 0 : body.split(/\r\n|\r|\n/).length,
|
|
199
|
+
words: isImage || !body.trim() ? 0 : body.trim().split(/\s+/).length,
|
|
200
|
+
url: !isImage && /^(?:[a-z][a-z0-9+.-]*:\/\/|www\.)[^\s]+$|^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/[^\s]*)?$/i.test(body.trim()),
|
|
201
|
+
rich: !isImage && !!(item.html || item.htmlRef || item.htmlHash || item.rtf || item.rtfRef || item.rtfHash),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Search. Shared matches return previews; non-shared matches are counted only (the caller
|
|
206
|
+
// decides whether to escalate to an approval-gated full search). Delegates parsing +
|
|
207
|
+
// matching to the SHARED engine (site/shared/clip-search) so the MCP tool understands the
|
|
208
|
+
// SAME language as the app's search field: `title:`, `group:`, `is:pinned`,
|
|
209
|
+
// `since:7d`, `-word`, `/regex/`, `a OR b`, `( )`. Plain words are literal. The old
|
|
210
|
+
// `regex: true` flag still works: the query's free text is read as ONE regex
|
|
211
|
+
// (search.legacyRegexQuery).
|
|
212
|
+
function searchClips(history, settings, {
|
|
213
|
+
query,
|
|
214
|
+
regex = false,
|
|
215
|
+
limit = DEFAULT_LIST_LIMIT,
|
|
216
|
+
previewChars = DEFAULT_PREVIEW_CHARS,
|
|
217
|
+
scope = 'shared',
|
|
218
|
+
now,
|
|
219
|
+
} = {}) {
|
|
220
|
+
const items = Array.isArray(history) ? history : [];
|
|
221
|
+
const sharedSet = sharedGroupSet(settings);
|
|
222
|
+
const parsed = search.parseQuery(regex ? search.legacyRegexQuery(query) : query);
|
|
223
|
+
const matches = [];
|
|
224
|
+
let nonSharedMatches = 0;
|
|
225
|
+
|
|
226
|
+
for (const item of items) {
|
|
227
|
+
const shared = isShared(item, sharedSet);
|
|
228
|
+
const doc = clipToSearchDoc(item, { sharedSet, forceAll: scope === 'all' });
|
|
229
|
+
if (!search.matchDoc(doc, parsed, { now })) continue;
|
|
230
|
+
if (shared) {
|
|
231
|
+
if (matches.length < limit) matches.push(clipView(item, { sharedSet, previewChars }));
|
|
232
|
+
} else {
|
|
233
|
+
nonSharedMatches++;
|
|
234
|
+
// scope === 'all' is only used by the app AFTER approval; it shapes the
|
|
235
|
+
// non-shared match as a real preview so the assistant can read it.
|
|
236
|
+
if (scope === 'all' && matches.length < limit) {
|
|
237
|
+
matches.push(clipView(item, { sharedSet, previewChars, forcePreview: true }));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { matches, nonSharedMatches, returned: matches.length };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Resolve a single clip id for a full-text read. Returns the decision; the
|
|
245
|
+
// caller hydrates the text only when allowed. `reason` is one of:
|
|
246
|
+
// 'ok' | 'not_found' | 'not_shared'.
|
|
247
|
+
function resolveForRead(history, settings, id) {
|
|
248
|
+
const items = Array.isArray(history) ? history : [];
|
|
249
|
+
const sharedSet = sharedGroupSet(settings);
|
|
250
|
+
const item = items.find(i => model.itemKey(i) === id);
|
|
251
|
+
if (!item) return { reason: 'not_found', item: null, shared: false };
|
|
252
|
+
const shared = isShared(item, sharedSet);
|
|
253
|
+
if (!shared) return { reason: 'not_shared', item, shared: false };
|
|
254
|
+
return { reason: 'ok', item, shared: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
AI_GROUP_NAME,
|
|
259
|
+
DEFAULT_PREVIEW_CHARS,
|
|
260
|
+
DEFAULT_LIST_LIMIT,
|
|
261
|
+
sharedGroupSet,
|
|
262
|
+
isShared,
|
|
263
|
+
previewText,
|
|
264
|
+
clipView,
|
|
265
|
+
fullTextResult,
|
|
266
|
+
buildContext,
|
|
267
|
+
listClips,
|
|
268
|
+
searchClips,
|
|
269
|
+
resolveForRead,
|
|
270
|
+
};
|