@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,236 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Worker thread running a Win32 low-level keyboard hook (WH_KEYBOARD_LL).
|
|
3
|
+
//
|
|
4
|
+
// This exists because Electron's `globalShortcut.register('Super+V')` fails
|
|
5
|
+
// on Windows — Windows Clipboard History has already claimed Win+V via the
|
|
6
|
+
// built-in RegisterHotKey API. A low-level hook sits *below* that system
|
|
7
|
+
// shortcut layer and can swallow the key before Windows Clipboard History
|
|
8
|
+
// sees it.
|
|
9
|
+
//
|
|
10
|
+
// Why a worker thread (not Electron's main thread):
|
|
11
|
+
// 1. LL hooks are called via messages posted to the installing thread, so
|
|
12
|
+
// that thread must pump its own GetMessage loop. Electron's main thread
|
|
13
|
+
// has a Chromium message pump that doesn't guarantee LL hook delivery
|
|
14
|
+
// and can starve it under load.
|
|
15
|
+
// 2. LowLevelHooksTimeout defaults to 300ms — if any JS task on the main
|
|
16
|
+
// thread runs longer, Windows silently unregisters the hook. Isolating
|
|
17
|
+
// it in a worker with a dedicated GetMessage loop removes that risk.
|
|
18
|
+
//
|
|
19
|
+
// State sharing with main thread uses a SharedArrayBuffer rather than
|
|
20
|
+
// postMessage because the worker is blocked in a synchronous GetMessageW
|
|
21
|
+
// call and can't process its JS event loop.
|
|
22
|
+
|
|
23
|
+
const { parentPort, workerData } = require('worker_threads');
|
|
24
|
+
const koffi = require('koffi');
|
|
25
|
+
|
|
26
|
+
// --- Shared state (worker reads, main writes) ---
|
|
27
|
+
// Layout: [popupVisible, slot1, slot2, ..., slot9, reserved, ...]
|
|
28
|
+
const sharedState = workerData && workerData.sharedStateBuffer
|
|
29
|
+
? new Uint8Array(workerData.sharedStateBuffer)
|
|
30
|
+
: new Uint8Array(16);
|
|
31
|
+
|
|
32
|
+
// --- Win32 constants ---
|
|
33
|
+
const WH_KEYBOARD_LL = 13;
|
|
34
|
+
const WM_KEYDOWN = 0x0100;
|
|
35
|
+
const WM_KEYUP = 0x0101;
|
|
36
|
+
const WM_SYSKEYDOWN = 0x0104;
|
|
37
|
+
const WM_SYSKEYUP = 0x0105;
|
|
38
|
+
const VK_LWIN = 0x5B;
|
|
39
|
+
const VK_RWIN = 0x5C;
|
|
40
|
+
const VK_CONTROL = 0x11;
|
|
41
|
+
const VK_V = 0x56;
|
|
42
|
+
const VK_NUMPAD1 = 0x61;
|
|
43
|
+
const VK_NUMPAD9 = 0x69;
|
|
44
|
+
const KEYEVENTF_KEYUP = 0x0002;
|
|
45
|
+
|
|
46
|
+
// --- koffi types ---
|
|
47
|
+
// KBDLLHOOKSTRUCT is the struct Windows passes to a WH_KEYBOARD_LL hook via
|
|
48
|
+
// lParam. We declare it so koffi can decode the pointer in the callback.
|
|
49
|
+
const KBDLLHOOKSTRUCT = koffi.struct('KBDLLHOOKSTRUCT', {
|
|
50
|
+
vkCode: 'uint32',
|
|
51
|
+
scanCode: 'uint32',
|
|
52
|
+
flags: 'uint32',
|
|
53
|
+
time: 'uint32',
|
|
54
|
+
dwExtraInfo: 'uintptr_t',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// MSG is needed for the GetMessageW pump loop below.
|
|
58
|
+
const MSG = koffi.struct('MSG', {
|
|
59
|
+
hwnd: 'void *',
|
|
60
|
+
message: 'uint32',
|
|
61
|
+
wParam: 'uintptr_t',
|
|
62
|
+
lParam: 'intptr_t',
|
|
63
|
+
time: 'uint32',
|
|
64
|
+
pt_x: 'int32',
|
|
65
|
+
pt_y: 'int32',
|
|
66
|
+
lPrivate: 'uint32',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Hook procedure prototype. Declaring lParam as KBDLLHOOKSTRUCT* means koffi
|
|
70
|
+
// gives us an External we can both decode *and* pass straight back through
|
|
71
|
+
// to CallNextHookEx without reinterpreting the pointer ourselves.
|
|
72
|
+
const HookProc = koffi.proto(
|
|
73
|
+
'intptr_t __stdcall HookProc(int nCode, uintptr_t wParam, KBDLLHOOKSTRUCT *lParam)'
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// --- Load user32.dll and bind functions ---
|
|
77
|
+
let user32;
|
|
78
|
+
try {
|
|
79
|
+
user32 = koffi.load('user32.dll');
|
|
80
|
+
} catch (err) {
|
|
81
|
+
parentPort.postMessage({ type: 'error', error: 'koffi.load user32.dll: ' + String(err) });
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const SetWindowsHookExW = user32.func(
|
|
86
|
+
'void * __stdcall SetWindowsHookExW(int idHook, HookProc *lpfn, void *hmod, uint32 dwThreadId)'
|
|
87
|
+
);
|
|
88
|
+
const UnhookWindowsHookEx = user32.func(
|
|
89
|
+
'int __stdcall UnhookWindowsHookEx(void *hhk)'
|
|
90
|
+
);
|
|
91
|
+
const CallNextHookEx = user32.func(
|
|
92
|
+
'intptr_t __stdcall CallNextHookEx(void *hhk, int nCode, uintptr_t wParam, KBDLLHOOKSTRUCT *lParam)'
|
|
93
|
+
);
|
|
94
|
+
const GetMessageW = user32.func(
|
|
95
|
+
'int __stdcall GetMessageW(_Out_ MSG *lpMsg, void *hWnd, uint32 wMsgFilterMin, uint32 wMsgFilterMax)'
|
|
96
|
+
);
|
|
97
|
+
const TranslateMessage = user32.func(
|
|
98
|
+
'int __stdcall TranslateMessage(MSG *lpMsg)'
|
|
99
|
+
);
|
|
100
|
+
const DispatchMessageW = user32.func(
|
|
101
|
+
'intptr_t __stdcall DispatchMessageW(MSG *lpMsg)'
|
|
102
|
+
);
|
|
103
|
+
const GetAsyncKeyState = user32.func(
|
|
104
|
+
'int16 __stdcall GetAsyncKeyState(int vKey)'
|
|
105
|
+
);
|
|
106
|
+
const keybd_event = user32.func(
|
|
107
|
+
'void __stdcall keybd_event(uint8 bVk, uint8 bScan, uint32 dwFlags, uintptr_t dwExtraInfo)'
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
let winVChordActive = false;
|
|
111
|
+
let winVChordConsumed = false;
|
|
112
|
+
|
|
113
|
+
// Auto-repeat suppression for numpad quick-paste. When a key is physically
|
|
114
|
+
// held, Windows emits repeated WM_KEYDOWN with no intervening WM_KEYUP. Without
|
|
115
|
+
// tracking this, one held (or lag-stretched) press fires the paste many times.
|
|
116
|
+
// numpadHeld = keys currently down; numpadIntercepted = keys whose keydown we
|
|
117
|
+
// swallowed, so we also swallow their paired keyup.
|
|
118
|
+
const numpadHeld = new Set();
|
|
119
|
+
const numpadIntercepted = new Set();
|
|
120
|
+
|
|
121
|
+
function isWinHeld() {
|
|
122
|
+
return (GetAsyncKeyState(VK_LWIN) & 0x8000) !== 0 ||
|
|
123
|
+
(GetAsyncKeyState(VK_RWIN) & 0x8000) !== 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function markWinKeyUsed() {
|
|
127
|
+
// If V is swallowed, Windows can still react to the original Win release.
|
|
128
|
+
// A tiny Ctrl tap marks the Win key as used while allowing the real Win-up
|
|
129
|
+
// through, so we avoid both Clipboard History and stuck modifier state.
|
|
130
|
+
try {
|
|
131
|
+
keybd_event(VK_CONTROL, 0, 0, 0);
|
|
132
|
+
keybd_event(VK_CONTROL, 0, KEYEVENTF_KEYUP, 0);
|
|
133
|
+
} catch {}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- Hook callback ---
|
|
137
|
+
// Fires synchronously on this worker thread each time a keyboard event is
|
|
138
|
+
// delivered to the thread's message queue. Return non-zero to swallow the
|
|
139
|
+
// event (it will not propagate to the rest of the hook chain or any app).
|
|
140
|
+
const hookCallback = koffi.register((nCode, wParam, lParamExt) => {
|
|
141
|
+
try {
|
|
142
|
+
if (nCode >= 0) {
|
|
143
|
+
const kb = koffi.decode(lParamExt, KBDLLHOOKSTRUCT);
|
|
144
|
+
const vk = kb.vkCode;
|
|
145
|
+
const keyDown = wParam === WM_KEYDOWN || wParam === WM_SYSKEYDOWN;
|
|
146
|
+
const keyUp = wParam === WM_KEYUP || wParam === WM_SYSKEYUP;
|
|
147
|
+
|
|
148
|
+
if (!keyDown && !keyUp) {
|
|
149
|
+
return CallNextHookEx(null, nCode, wParam, lParamExt);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const isWinKey = vk === VK_LWIN || vk === VK_RWIN;
|
|
153
|
+
|
|
154
|
+
// Win+V → show popup. Checked before Windows Clipboard History can.
|
|
155
|
+
if (isWinKey && keyUp && winVChordConsumed) {
|
|
156
|
+
markWinKeyUsed();
|
|
157
|
+
winVChordConsumed = false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (vk === VK_V) {
|
|
161
|
+
if (keyDown) {
|
|
162
|
+
if (isWinHeld() || winVChordActive) {
|
|
163
|
+
if (!winVChordActive) {
|
|
164
|
+
winVChordActive = true;
|
|
165
|
+
winVChordConsumed = true;
|
|
166
|
+
parentPort.postMessage({ type: 'show' });
|
|
167
|
+
}
|
|
168
|
+
return 1;
|
|
169
|
+
}
|
|
170
|
+
} else if (winVChordActive) {
|
|
171
|
+
winVChordActive = false;
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Plain Numpad1-9 -> quick-paste when a slot is assigned and the popup
|
|
177
|
+
// is closed. When the popup is visible, pass through so the search box
|
|
178
|
+
// can accept normal numpad typing. Auto-repeat (held key) fires the paste
|
|
179
|
+
// exactly once — repeats are swallowed but not re-dispatched.
|
|
180
|
+
if (vk >= VK_NUMPAD1 && vk <= VK_NUMPAD9) {
|
|
181
|
+
const slot = vk - VK_NUMPAD1 + 1;
|
|
182
|
+
if (keyDown) {
|
|
183
|
+
const popupVisible = sharedState[0] === 1;
|
|
184
|
+
const slotAssigned = sharedState[slot] === 1;
|
|
185
|
+
if (!popupVisible && slotAssigned) {
|
|
186
|
+
if (numpadHeld.has(vk)) return 1; // auto-repeat: swallow, don't re-fire
|
|
187
|
+
numpadHeld.add(vk);
|
|
188
|
+
numpadIntercepted.add(vk);
|
|
189
|
+
parentPort.postMessage({ type: 'numpad', slot });
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
// Not intercepting this press (popup open or slot unassigned): let it
|
|
193
|
+
// type normally and don't track it.
|
|
194
|
+
} else if (keyUp) {
|
|
195
|
+
numpadHeld.delete(vk);
|
|
196
|
+
if (numpadIntercepted.has(vk)) {
|
|
197
|
+
numpadIntercepted.delete(vk);
|
|
198
|
+
return 1; // swallow the keyup paired with a swallowed keydown
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
// Never let JS exceptions escape the hook — Windows will stall the
|
|
205
|
+
// whole keyboard chain. Best-effort report and fall through to
|
|
206
|
+
// CallNextHookEx so the key still reaches its target.
|
|
207
|
+
try { parentPort.postMessage({ type: 'error', error: String(err) }); } catch {}
|
|
208
|
+
}
|
|
209
|
+
return CallNextHookEx(null, nCode, wParam, lParamExt);
|
|
210
|
+
}, koffi.pointer(HookProc));
|
|
211
|
+
|
|
212
|
+
// --- Install hook ---
|
|
213
|
+
// hMod=null + dwThreadId=0 → install a global LL hook owned by the current
|
|
214
|
+
// process. LL hooks are the one case where a null hMod is explicitly allowed.
|
|
215
|
+
const hook = SetWindowsHookExW(WH_KEYBOARD_LL, hookCallback, null, 0);
|
|
216
|
+
if (!hook) {
|
|
217
|
+
parentPort.postMessage({ type: 'error', error: 'SetWindowsHookExW returned null' });
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
parentPort.postMessage({ type: 'ready' });
|
|
222
|
+
|
|
223
|
+
// --- Message pump ---
|
|
224
|
+
// Blocks this worker thread pumping messages so Windows can deliver hook
|
|
225
|
+
// callbacks. Exits on WM_QUIT or worker termination from the main thread.
|
|
226
|
+
const msg = {};
|
|
227
|
+
while (true) {
|
|
228
|
+
const ret = GetMessageW(msg, null, 0, 0);
|
|
229
|
+
if (ret <= 0) break;
|
|
230
|
+
TranslateMessage(msg);
|
|
231
|
+
DispatchMessageW(msg);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- Cleanup (only reached on clean WM_QUIT; worker.terminate skips this) ---
|
|
235
|
+
try { UnhookWindowsHookEx(hook); } catch {}
|
|
236
|
+
try { koffi.unregister(hookCallback); } catch {}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Main-thread wrapper around the Win32 keyboard hook worker.
|
|
3
|
+
//
|
|
4
|
+
// On Windows we can't use Electron's `globalShortcut.register('Super+V')`
|
|
5
|
+
// because Windows Clipboard History owns Win+V at the RegisterHotKey layer
|
|
6
|
+
// and silently rejects our registration. This module installs a
|
|
7
|
+
// WH_KEYBOARD_LL hook on a dedicated worker thread which intercepts Win+V
|
|
8
|
+
// and numpad keys *before* Windows Clipboard History sees them.
|
|
9
|
+
//
|
|
10
|
+
// See lib/windows-hook-worker.js for the hook implementation and the
|
|
11
|
+
// rationale for running it in a worker.
|
|
12
|
+
//
|
|
13
|
+
// Shared state (popup visible + which numpad slots are assigned) is passed
|
|
14
|
+
// via a SharedArrayBuffer because the worker is synchronously blocked in
|
|
15
|
+
// GetMessageW and can't process postMessage events from its JS event loop.
|
|
16
|
+
|
|
17
|
+
const { Worker } = require('worker_threads');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
let currentHook = null;
|
|
21
|
+
|
|
22
|
+
function install({ onShowPopup, onNumpadPaste }) {
|
|
23
|
+
if (process.platform !== 'win32') {
|
|
24
|
+
// Non-Windows platforms use Electron's globalShortcut; no-op API here.
|
|
25
|
+
return {
|
|
26
|
+
setPopupVisible() {},
|
|
27
|
+
setSlotAssignments() {},
|
|
28
|
+
uninstall() {},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (currentHook) return currentHook;
|
|
32
|
+
|
|
33
|
+
// Layout: [popupVisible, slot1, slot2, ..., slot9, reserved...]
|
|
34
|
+
const sharedStateBuffer = new SharedArrayBuffer(16);
|
|
35
|
+
const sharedState = new Uint8Array(sharedStateBuffer);
|
|
36
|
+
|
|
37
|
+
const worker = new Worker(path.join(__dirname, 'windows-hook-worker.js'), {
|
|
38
|
+
workerData: { sharedStateBuffer },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
worker.on('message', (msg) => {
|
|
42
|
+
switch (msg.type) {
|
|
43
|
+
case 'show':
|
|
44
|
+
try { onShowPopup(); } catch (e) { console.error('[windows-hook] onShowPopup:', e); }
|
|
45
|
+
break;
|
|
46
|
+
case 'numpad':
|
|
47
|
+
try { onNumpadPaste(msg.slot); } catch (e) { console.error('[windows-hook] onNumpadPaste:', e); }
|
|
48
|
+
break;
|
|
49
|
+
case 'ready':
|
|
50
|
+
// Hook installed successfully — no action needed.
|
|
51
|
+
break;
|
|
52
|
+
case 'error':
|
|
53
|
+
console.error('[windows-hook]', msg.error);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
worker.on('error', (err) => {
|
|
59
|
+
console.error('[windows-hook] worker crashed:', err);
|
|
60
|
+
currentHook = null;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
worker.on('exit', (code) => {
|
|
64
|
+
if (code !== 0) console.error(`[windows-hook] worker exited with code ${code}`);
|
|
65
|
+
currentHook = null;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
currentHook = {
|
|
69
|
+
setPopupVisible(visible) {
|
|
70
|
+
sharedState[0] = visible ? 1 : 0;
|
|
71
|
+
},
|
|
72
|
+
setSlotAssignments(assignedSet) {
|
|
73
|
+
// assignedSet: Set<number> of assigned numpad slot numbers (1-9)
|
|
74
|
+
for (let n = 1; n <= 9; n++) {
|
|
75
|
+
sharedState[n] = assignedSet && assignedSet.has(n) ? 1 : 0;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
uninstall() {
|
|
79
|
+
if (!currentHook) return;
|
|
80
|
+
currentHook = null;
|
|
81
|
+
// worker.terminate() kills the thread; Windows reclaims the hook on
|
|
82
|
+
// thread exit. A cleaner PostThreadMessageW(WM_QUIT) would require
|
|
83
|
+
// exposing the worker thread ID — not worth the extra FFI surface.
|
|
84
|
+
worker.terminate().catch(() => {});
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return currentHook;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { install };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Fast Windows paste primitives via koffi FFI.
|
|
3
|
+
//
|
|
4
|
+
// Replaces the previous `cscript` + VBScript SendKeys approach which had two
|
|
5
|
+
// problems:
|
|
6
|
+
// 1. Spawning cscript per paste costs 200-500ms (cold start). That was the
|
|
7
|
+
// "delay" users noticed vs. the pre-Electron Python version which used
|
|
8
|
+
// direct Win32 calls via ctypes.
|
|
9
|
+
// 2. VBScript `WshShell.SendKeys "^v"` has a documented Win32 quirk where
|
|
10
|
+
// it can flip NumLock state on some systems, triggering OEM keyboard
|
|
11
|
+
// software notifications (e.g. Logitech "NumLock ON" overlay).
|
|
12
|
+
//
|
|
13
|
+
// Uses SendInput (the modern Win32 API recommended for new code — see
|
|
14
|
+
// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendinput).
|
|
15
|
+
// Benefits over the older keybd_event:
|
|
16
|
+
// - Events are atomic: other input from the user or from other processes
|
|
17
|
+
// cannot be interleaved between our Ctrl down / V down / V up / Ctrl up.
|
|
18
|
+
// - Microsoft explicitly flags keybd_event as superseded for new dev.
|
|
19
|
+
//
|
|
20
|
+
// The INPUT struct uses a tagged union in C. We target x64 (the only arch
|
|
21
|
+
// Electron ships by default on Windows) and use a flat struct with explicit
|
|
22
|
+
// padding that matches the x64 memory layout (40 bytes total).
|
|
23
|
+
|
|
24
|
+
if (process.platform !== 'win32') {
|
|
25
|
+
module.exports = {
|
|
26
|
+
sendCtrlV() {},
|
|
27
|
+
typeText() { return { ok: false, chars: 0, events: 0 }; },
|
|
28
|
+
getForegroundWindow() { return null; },
|
|
29
|
+
getForegroundWindowAddress() { return '0'; },
|
|
30
|
+
setForegroundWindow() { return false; },
|
|
31
|
+
isMouseButtonDown() { return false; },
|
|
32
|
+
};
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const koffi = require('koffi');
|
|
37
|
+
const user32 = koffi.load('user32.dll');
|
|
38
|
+
|
|
39
|
+
// x64 INPUT struct layout (40 bytes):
|
|
40
|
+
// offset 0: type (DWORD, 4)
|
|
41
|
+
// offset 4: padding (4) — union aligns to 8 (ULONG_PTR alignment)
|
|
42
|
+
// offset 8: wVk (WORD, 2)
|
|
43
|
+
// offset 10: wScan (WORD, 2)
|
|
44
|
+
// offset 12: dwFlags (DWORD, 4)
|
|
45
|
+
// offset 16: time (DWORD, 4)
|
|
46
|
+
// offset 20: padding (4) — align dwExtraInfo to 8
|
|
47
|
+
// offset 24: dwExtraInfo (ULONG_PTR, 8)
|
|
48
|
+
// offset 32: padding (8) — pad to 40, matching MOUSEINPUT's size
|
|
49
|
+
const INPUT = koffi.struct('INPUT', {
|
|
50
|
+
type: 'uint32',
|
|
51
|
+
_pad0: 'uint32',
|
|
52
|
+
wVk: 'uint16',
|
|
53
|
+
wScan: 'uint16',
|
|
54
|
+
dwFlags: 'uint32',
|
|
55
|
+
time: 'uint32',
|
|
56
|
+
_pad1: 'uint32',
|
|
57
|
+
dwExtraInfo: 'uintptr_t',
|
|
58
|
+
_pad2: koffi.array('uint8', 8),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const INPUT_KEYBOARD = 1;
|
|
62
|
+
const VK_CONTROL = 0x11;
|
|
63
|
+
const VK_V = 0x56;
|
|
64
|
+
const VK_RETURN = 0x0D;
|
|
65
|
+
const VK_TAB = 0x09;
|
|
66
|
+
const VK_LBUTTON = 0x01;
|
|
67
|
+
const VK_RBUTTON = 0x02;
|
|
68
|
+
const KEYEVENTF_KEYUP = 0x0002;
|
|
69
|
+
const KEYEVENTF_UNICODE = 0x0004;
|
|
70
|
+
|
|
71
|
+
const SendInput = user32.func(
|
|
72
|
+
'uint32 __stdcall SendInput(uint32 cInputs, INPUT *pInputs, int cbSize)'
|
|
73
|
+
);
|
|
74
|
+
const GetForegroundWindow = user32.func(
|
|
75
|
+
'void * __stdcall GetForegroundWindow()'
|
|
76
|
+
);
|
|
77
|
+
const SetForegroundWindow = user32.func(
|
|
78
|
+
'int __stdcall SetForegroundWindow(void *hWnd)'
|
|
79
|
+
);
|
|
80
|
+
const GetAsyncKeyState = user32.func(
|
|
81
|
+
'int16 __stdcall GetAsyncKeyState(int vKey)'
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const INPUT_SIZE = koffi.sizeof(INPUT);
|
|
85
|
+
|
|
86
|
+
function keyInput(vk, keyup) {
|
|
87
|
+
return {
|
|
88
|
+
type: INPUT_KEYBOARD,
|
|
89
|
+
_pad0: 0,
|
|
90
|
+
wVk: vk,
|
|
91
|
+
wScan: 0,
|
|
92
|
+
dwFlags: keyup ? KEYEVENTF_KEYUP : 0,
|
|
93
|
+
time: 0,
|
|
94
|
+
_pad1: 0,
|
|
95
|
+
dwExtraInfo: 0,
|
|
96
|
+
_pad2: [0, 0, 0, 0, 0, 0, 0, 0],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sendCtrlV() {
|
|
101
|
+
// Four events delivered atomically: Ctrl down, V down, V up, Ctrl up.
|
|
102
|
+
SendInput(4, [
|
|
103
|
+
keyInput(VK_CONTROL, false),
|
|
104
|
+
keyInput(VK_V, false),
|
|
105
|
+
keyInput(VK_V, true),
|
|
106
|
+
keyInput(VK_CONTROL, true),
|
|
107
|
+
], INPUT_SIZE);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A single Unicode character injected via KEYEVENTF_UNICODE — inserts the exact
|
|
111
|
+
// codepoint as text, with no virtual-key mapping, so it is immune to keyboard
|
|
112
|
+
// layout AND to Tab/Enter "action" semantics (a literal tab/space is inserted,
|
|
113
|
+
// not a focus change). wVk MUST be 0 for unicode injection.
|
|
114
|
+
function unicodeInput(codeUnit, keyup) {
|
|
115
|
+
return {
|
|
116
|
+
type: INPUT_KEYBOARD,
|
|
117
|
+
_pad0: 0,
|
|
118
|
+
wVk: 0,
|
|
119
|
+
wScan: codeUnit,
|
|
120
|
+
dwFlags: KEYEVENTF_UNICODE | (keyup ? KEYEVENTF_KEYUP : 0),
|
|
121
|
+
time: 0,
|
|
122
|
+
_pad1: 0,
|
|
123
|
+
dwExtraInfo: 0,
|
|
124
|
+
_pad2: [0, 0, 0, 0, 0, 0, 0, 0],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Type text by synthesizing key events — bypassing the clipboard entirely, so
|
|
129
|
+
// there is no backup/restore race and the user's clipboard is never touched.
|
|
130
|
+
// The whole string is delivered in as few SendInput batches as possible (NOT
|
|
131
|
+
// one key at a time with delays), so it is effectively instant.
|
|
132
|
+
//
|
|
133
|
+
// Newlines are sent as a real VK_RETURN keypress (KEYEVENTF_UNICODE '\n' does
|
|
134
|
+
// not produce a newline in most apps). Everything else — including tab — goes
|
|
135
|
+
// through Unicode injection so a snippet's exact characters are inserted without
|
|
136
|
+
// triggering Tab-key focus behavior. Returns {ok, chars, events}.
|
|
137
|
+
function typeText(text) {
|
|
138
|
+
const str = String(text == null ? '' : text);
|
|
139
|
+
if (!str) return { ok: true, chars: 0, events: 0 };
|
|
140
|
+
const events = [];
|
|
141
|
+
for (let i = 0; i < str.length; i++) {
|
|
142
|
+
const code = str.charCodeAt(i);
|
|
143
|
+
if (code === 0x0d) continue; // swallow CR; LF drives the newline
|
|
144
|
+
if (code === 0x0a) { // LF -> real Enter keypress
|
|
145
|
+
events.push(keyInput(VK_RETURN, false));
|
|
146
|
+
events.push(keyInput(VK_RETURN, true));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
events.push(unicodeInput(code, false));
|
|
150
|
+
events.push(unicodeInput(code, true));
|
|
151
|
+
}
|
|
152
|
+
if (!events.length) return { ok: true, chars: str.length, events: 0 };
|
|
153
|
+
// Chunk so a single very large array can't overflow the input pipeline; each
|
|
154
|
+
// SendInput call is atomic and there is no inter-key delay, so this stays fast.
|
|
155
|
+
const CHUNK = 400;
|
|
156
|
+
let sent = 0;
|
|
157
|
+
for (let i = 0; i < events.length; i += CHUNK) {
|
|
158
|
+
const batch = events.slice(i, i + CHUNK);
|
|
159
|
+
sent += SendInput(batch.length, batch, INPUT_SIZE);
|
|
160
|
+
}
|
|
161
|
+
return { ok: sent === events.length, chars: str.length, events: events.length, sent };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getForegroundWindow() {
|
|
165
|
+
return GetForegroundWindow();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Numeric address of the current foreground window, as a string (HWNDs can
|
|
169
|
+
// exceed Number range on x64). Used by tests to confirm the intended window is
|
|
170
|
+
// focused before injecting keystrokes, so injection can't leak into another app.
|
|
171
|
+
function getForegroundWindowAddress() {
|
|
172
|
+
try { return String(koffi.address(GetForegroundWindow())); } catch { return '0'; }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// -> true when Windows accepted the switch.
|
|
176
|
+
function setForegroundWindow(hwnd) {
|
|
177
|
+
if (!hwnd) return false;
|
|
178
|
+
return !!SetForegroundWindow(hwnd);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isMouseButtonDown() {
|
|
182
|
+
return !!((GetAsyncKeyState(VK_LBUTTON) | GetAsyncKeyState(VK_RBUTTON)) & 0x8000);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { sendCtrlV, typeText, getForegroundWindow, getForegroundWindowAddress, setForegroundWindow, isMouseButtonDown };
|