@tobq/boardclip 2.1.114 → 2.1.115
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/index.html +22 -11
- package/lib/windows-hook-policy.js +155 -37
- package/lib/windows-hook-worker.js +2 -2
- package/lib/windows-hook.js +25 -13
- package/main.js +82 -43
- package/package.json +1 -1
- package/preload.js +7 -0
package/index.html
CHANGED
|
@@ -827,7 +827,8 @@ function renderShortcutSettings(settings, { clearStatus = true } = {}) {
|
|
|
827
827
|
const quickRecord = shortcutRecordEl('quickPaste');
|
|
828
828
|
const quickShortcut = info.quickPaste || '';
|
|
829
829
|
if (quickShortcut) {
|
|
830
|
-
|
|
830
|
+
const numpad = /\+num[1-9]$/i.test(quickShortcut);
|
|
831
|
+
quickRecord.textContent = `${shortcutLabel(quickShortcut.replace(/\+(?:num)?[1-9]$/i, ''))} + ${numpad ? 'Num ' : ''}1-9`;
|
|
831
832
|
quickRecord.title = `${quickShortcut.replace(/[1-9]$/, '1')} ... ${quickShortcut.replace(/[1-9]$/, '9')}`;
|
|
832
833
|
} else {
|
|
833
834
|
quickRecord.textContent = 'Not set';
|
|
@@ -945,25 +946,35 @@ for (const kind of Object.keys(SHORTCUT_KINDS)) {
|
|
|
945
946
|
shortcutResetEl(kind).addEventListener('click', (e) => applyShortcut(kind, e.currentTarget.dataset.apply || ''));
|
|
946
947
|
}
|
|
947
948
|
|
|
948
|
-
|
|
949
|
-
|
|
949
|
+
// The recording row takes one chord: shortcut = its accelerator ('' = not a
|
|
950
|
+
// shortcut), key = its non-modifier key's name ('' = none or no name).
|
|
951
|
+
async function recordShortcut(shortcut, key) {
|
|
950
952
|
const kind = shortcutRecording;
|
|
951
|
-
|
|
952
|
-
e.stopPropagation();
|
|
953
|
-
if (e.key === 'Escape') {
|
|
954
|
-
await cancelShortcutRecording();
|
|
955
|
-
return;
|
|
956
|
-
}
|
|
957
|
-
const shortcut = shortcutFromEvent(e);
|
|
953
|
+
if (!kind) return;
|
|
958
954
|
if (!shortcut) {
|
|
959
|
-
setShortcutStatus(kind,
|
|
955
|
+
setShortcutStatus(kind, key ? 'Add a shortcut modifier.' : 'Press a non-modifier key.', true);
|
|
960
956
|
return;
|
|
961
957
|
}
|
|
962
958
|
const resolved = kind === 'show' && window.api.resolveShowShortcut
|
|
963
959
|
? await window.api.resolveShowShortcut(shortcut)
|
|
964
960
|
: shortcut;
|
|
965
961
|
await applyShortcut(kind, resolved);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
document.addEventListener('keydown', async (e) => {
|
|
965
|
+
if (!shortcutRecording) return;
|
|
966
|
+
e.preventDefault();
|
|
967
|
+
e.stopPropagation();
|
|
968
|
+
if (e.key === 'Escape') {
|
|
969
|
+
await cancelShortcutRecording();
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
await recordShortcut(shortcutFromEvent(e), shortcutKeyFromEvent(e));
|
|
966
973
|
}, true);
|
|
974
|
+
// Windows: every Win/Ctrl/Alt chord pressed while recording is taken by the
|
|
975
|
+
// keyboard hook before Windows acts on it (Win+1 would switch to a taskbar
|
|
976
|
+
// app and hide the popup) and arrives here instead of as a keydown.
|
|
977
|
+
if (window.api.onShortcutRecorded) window.api.onShortcutRecorded((chord) => recordShortcut(chord.shortcut, chord.key));
|
|
967
978
|
|
|
968
979
|
document.getElementById('addGroupBtn').addEventListener('click', () => controller.addGroup(null));
|
|
969
980
|
document.getElementById('syncAccounts').addEventListener('change', async (e) => {
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// decision, kept Win32-free so it is unit-tested without installing a hook
|
|
5
5
|
// (test/windows-hook.test.js):
|
|
6
6
|
// - STATE: the SharedArrayBuffer layout main writes and the worker reads;
|
|
7
|
+
// - the chord table: the Win chords main hands the hook (writeChords) and
|
|
8
|
+
// the accelerator <-> key-code names both sides read them by;
|
|
7
9
|
// - createHookClaimer: which key presses the hook takes.
|
|
8
10
|
|
|
9
11
|
// --- Shared state layout (one byte each; main writes, the worker reads) ---
|
|
@@ -11,8 +13,12 @@ const STATE = Object.freeze({
|
|
|
11
13
|
POPUP_VISIBLE: 0, // the popup is open: plain numpad keys type into it
|
|
12
14
|
SLOT_FIRST: 1, // bytes 1-9: numpad slot N holds a clip
|
|
13
15
|
SLOT_LAST: 9,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
RECORDING: 10, // a Settings shortcut row is recording: chords go to it
|
|
17
|
+
CHORD_COUNT: 11, // live entries in the chord table
|
|
18
|
+
CHORDS: 12, // CHORD_BYTES each: key code, MOD bits, action, slot
|
|
19
|
+
CHORD_BYTES: 4,
|
|
20
|
+
MAX_CHORDS: 12, // show + new note + quick paste 1-9 = 11
|
|
21
|
+
SIZE: 64,
|
|
16
22
|
});
|
|
17
23
|
|
|
18
24
|
const VK = Object.freeze({
|
|
@@ -20,18 +26,102 @@ const VK = Object.freeze({
|
|
|
20
26
|
LWIN: 0x5B, RWIN: 0x5C,
|
|
21
27
|
N: 0x4E, V: 0x56,
|
|
22
28
|
NUMPAD1: 0x61, NUMPAD9: 0x69,
|
|
29
|
+
OEM_PLUS: 0xBB,
|
|
23
30
|
});
|
|
24
31
|
const LLKHF_ALTDOWN = 0x20;
|
|
32
|
+
// The modifiers held at a keydown, as bits (a chord matches on all four).
|
|
33
|
+
const MOD = Object.freeze({ WIN: 1, CTRL: 2, ALT: 4, SHIFT: 8 });
|
|
34
|
+
const PRIMARY = MOD.WIN | MOD.CTRL | MOD.ALT; // what makes a press a shortcut
|
|
35
|
+
// Shift / Ctrl / Alt, generic and left / right (the hook sees the sided codes).
|
|
36
|
+
const MODIFIER_KEYS = new Set([VK.SHIFT, VK.CONTROL, VK.MENU, 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5]);
|
|
25
37
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
function modBits(mods) {
|
|
39
|
+
return (mods.win ? MOD.WIN : 0) | (mods.ctrl ? MOD.CTRL : 0) | (mods.alt ? MOD.ALT : 0) | (mods.shift ? MOD.SHIFT : 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Key names: the accelerator spelling of each key code ---
|
|
43
|
+
// The names the Settings recorder writes for a key it sees (index.html
|
|
44
|
+
// shortcutKeyFromEvent), so a chord the hook hears while recording reads and
|
|
45
|
+
// saves exactly like one the page heard; punctuation at its US position.
|
|
46
|
+
const KEY_NAMES = new Map([
|
|
47
|
+
[0x08, 'Backspace'], [0x09, 'Tab'], [0x0D, 'Enter'], [0x14, 'Capslock'], [0x20, 'Space'],
|
|
48
|
+
[0x21, 'PageUp'], [0x22, 'PageDown'], [0x23, 'End'], [0x24, 'Home'],
|
|
49
|
+
[0x25, 'Left'], [0x26, 'Up'], [0x27, 'Right'], [0x28, 'Down'], [0x2D, 'Insert'], [0x2E, 'Delete'],
|
|
50
|
+
[0x90, 'Numlock'], [0x91, 'Scrolllock'],
|
|
51
|
+
[0xBA, ';'], [VK.OEM_PLUS, '='], [0xBC, ','], [0xBD, 'Minus'], [0xBE, '.'], [0xBF, '/'], [0xC0, '`'],
|
|
52
|
+
[0xDB, '['], [0xDC, 'Backslash'], [0xDD, ']'], [0xDE, "'"],
|
|
53
|
+
]);
|
|
54
|
+
for (let i = 0; i <= 9; i++) KEY_NAMES.set(0x30 + i, String(i));
|
|
55
|
+
for (let i = 0; i < 26; i++) KEY_NAMES.set(0x41 + i, String.fromCharCode(0x41 + i));
|
|
56
|
+
for (let i = 1; i <= 24; i++) KEY_NAMES.set(0x6F + i, `F${i}`);
|
|
57
|
+
for (let i = 0; i <= 9; i++) KEY_NAMES.set(0x60 + i, `num${i}`);
|
|
58
|
+
// Numpad operators last: the page names them like the main keys, so a saved
|
|
59
|
+
// name reads back as the main key (what Electron registers for it too).
|
|
60
|
+
for (const [vk, name] of [[0x6A, '*'], [0x6B, 'Plus'], [0x6D, 'Minus'], [0x6E, '.'], [0x6F, '/']]) KEY_NAMES.set(vk, name);
|
|
61
|
+
|
|
62
|
+
const KEY_CODES = new Map([['plus', VK.OEM_PLUS], ['esc', 0x1B], ['escape', 0x1B], ['return', 0x0D],
|
|
63
|
+
['numadd', 0x6B], ['numsub', 0x6D], ['nummult', 0x6A], ['numdiv', 0x6F], ['numdec', 0x6E]]);
|
|
64
|
+
for (const [vk, name] of KEY_NAMES) if (!KEY_CODES.has(name.toLowerCase())) KEY_CODES.set(name.toLowerCase(), vk);
|
|
65
|
+
|
|
66
|
+
const MOD_NAMES = new Map([
|
|
67
|
+
['super', MOD.WIN], ['meta', MOD.WIN],
|
|
68
|
+
['control', MOD.CTRL], ['ctrl', MOD.CTRL], ['commandorcontrol', MOD.CTRL], ['commandorctrl', MOD.CTRL], ['cmdorctrl', MOD.CTRL],
|
|
69
|
+
['alt', MOD.ALT], ['option', MOD.ALT],
|
|
70
|
+
['shift', MOD.SHIFT],
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
function acceleratorParts(accelerator) {
|
|
74
|
+
return String(accelerator || '').split('+').map(p => p.trim().toLowerCase()).filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A Win chord: Windows' shell holds most of them at the RegisterHotKey layer
|
|
78
|
+
// (Win+V Clipboard History, Win+N notifications, Win+1-9 the taskbar), where
|
|
79
|
+
// a globalShortcut silently loses, so on Windows every one is the hook's.
|
|
80
|
+
function isWinChord(accelerator) {
|
|
81
|
+
return acceleratorParts(accelerator).some(part => MOD_NAMES.get(part) === MOD.WIN);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// accelerator -> { vk, mods } for the chord table; null when it is not one
|
|
85
|
+
// key with a shortcut modifier, or names a key the hook has no code for.
|
|
86
|
+
function chordFromAccelerator(accelerator) {
|
|
87
|
+
let mods = 0;
|
|
88
|
+
let vk = 0;
|
|
89
|
+
for (const part of acceleratorParts(accelerator)) {
|
|
90
|
+
if (MOD_NAMES.has(part)) { mods |= MOD_NAMES.get(part); continue; }
|
|
91
|
+
if (vk || !KEY_CODES.has(part)) return null;
|
|
92
|
+
vk = KEY_CODES.get(part);
|
|
93
|
+
}
|
|
94
|
+
return vk && (mods & PRIMARY) ? { vk, mods } : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// What a recording heard, as the page recorder writes it: Super, Control,
|
|
98
|
+
// Alt, Shift, then the key. key '' = a key with no name ("Press a
|
|
99
|
+
// non-modifier key."); shortcut '' with a key = no shortcut modifier.
|
|
100
|
+
function recordedChord(vk, mods) {
|
|
101
|
+
const key = vk === VK.OEM_PLUS && (mods & MOD.SHIFT) ? 'Plus' : (KEY_NAMES.get(vk) || '');
|
|
102
|
+
const parts = [[MOD.WIN, 'Super'], [MOD.CTRL, 'Control'], [MOD.ALT, 'Alt'], [MOD.SHIFT, 'Shift']]
|
|
103
|
+
.filter(([bit]) => mods & bit).map(([, name]) => name);
|
|
104
|
+
return { key, shortcut: key && (mods & PRIMARY) ? [...parts, key].join('+') : '' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Chord table (main writes it whole, the hook reads it per key) ---
|
|
108
|
+
const ACTIONS = Object.freeze(['show', 'new-note', 'quick-paste']); // stored as index + 1
|
|
109
|
+
|
|
110
|
+
// chords = [{ vk, mods, type, slot? }]. The count drops to 0 while the
|
|
111
|
+
// entries change, so the hook never reads a half-written one. Returns
|
|
112
|
+
// whether every chord fit.
|
|
113
|
+
function writeChords(state, chords) {
|
|
114
|
+
const list = chords.slice(0, STATE.MAX_CHORDS);
|
|
115
|
+
Atomics.store(state, STATE.CHORD_COUNT, 0);
|
|
116
|
+
list.forEach((chord, i) => {
|
|
117
|
+
const at = STATE.CHORDS + i * STATE.CHORD_BYTES;
|
|
118
|
+
state[at] = chord.vk;
|
|
119
|
+
state[at + 1] = chord.mods;
|
|
120
|
+
state[at + 2] = ACTIONS.indexOf(chord.type) + 1;
|
|
121
|
+
state[at + 3] = chord.slot || 0;
|
|
122
|
+
});
|
|
123
|
+
Atomics.store(state, STATE.CHORD_COUNT, list.length);
|
|
124
|
+
return list.length === chords.length;
|
|
35
125
|
}
|
|
36
126
|
|
|
37
127
|
// One verdict per press: a key's auto-repeats and its keyup follow its first
|
|
@@ -47,6 +137,7 @@ function exactModifiers(mods, { win = false } = {}) {
|
|
|
47
137
|
function createPressTracker() {
|
|
48
138
|
const held = new Map(); // vk -> claimed, for keys down since their first keydown
|
|
49
139
|
return {
|
|
140
|
+
holds(vk) { return held.has(vk); },
|
|
50
141
|
event(vk, down, claim, wasDown) {
|
|
51
142
|
if (!down) {
|
|
52
143
|
const claimed = held.get(vk) === true;
|
|
@@ -66,30 +157,55 @@ function createPressTracker() {
|
|
|
66
157
|
|
|
67
158
|
// The hook's claim decision for one key event. `state` is the shared-state
|
|
68
159
|
// byte array; io.readMods(flags) and io.isKeyDown(vk) are the worker's Win32
|
|
69
|
-
// reads (GetAsyncKeyState), called only
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
160
|
+
// reads (GetAsyncKeyState), called only for a key the shared bytes say could
|
|
161
|
+
// be claimed, so any other key costs a few byte reads.
|
|
162
|
+
// recording -> every press with Win, Ctrl or Alt, while the popup is open:
|
|
163
|
+
// post { type: 'record', vk, mods } (Windows never acts on it,
|
|
164
|
+
// so Win+1 records instead of switching to a taskbar app);
|
|
165
|
+
// a chord -> its EXACT modifiers: post { type } ('show', 'new-note') or
|
|
166
|
+
// { type: 'quick-paste', slot };
|
|
167
|
+
// Numpad1-9 -> plain or with Win, the popup closed and the slot assigned:
|
|
168
|
+
// post { type: 'numpad', slot } (open, the digit types into
|
|
169
|
+
// the search; Ctrl/Shift/Alt+digit stay the apps', Alt codes).
|
|
75
170
|
// The Win key itself always passes. A press swallowed while Win was held
|
|
76
171
|
// leaves Windows a bare Win tap, so the Win release after one says
|
|
77
172
|
// markWinUsed and the worker taps a key that keeps the Start menu shut.
|
|
78
173
|
// Returns { swallow, post, markWinUsed }.
|
|
79
174
|
const PASS = Object.freeze({ swallow: false, post: null, markWinUsed: false });
|
|
80
175
|
function createHookClaimer(state, io) {
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
176
|
+
const recording = () => state[STATE.RECORDING] === 1 && state[STATE.POPUP_VISIBLE] === 1;
|
|
177
|
+
// The table entry for vk (with exactly `mods`, or any when null), else -1.
|
|
178
|
+
const chordIndex = (vk, mods) => {
|
|
179
|
+
const count = Math.min(state[STATE.CHORD_COUNT], STATE.MAX_CHORDS);
|
|
180
|
+
for (let i = 0; i < count; i++) {
|
|
181
|
+
const at = STATE.CHORDS + i * STATE.CHORD_BYTES;
|
|
182
|
+
if (state[at] === vk && (mods == null || state[at + 1] === mods)) return i;
|
|
183
|
+
}
|
|
184
|
+
return -1;
|
|
185
|
+
};
|
|
186
|
+
const isNumpadDigit = (vk) => vk >= VK.NUMPAD1 && vk <= VK.NUMPAD9;
|
|
187
|
+
const numpadSlot = (vk) => {
|
|
188
|
+
const slot = isNumpadDigit(vk) ? vk - VK.NUMPAD1 + 1 : 0;
|
|
189
|
+
return slot && state[STATE.POPUP_VISIBLE] !== 1 && state[STATE.SLOT_FIRST + slot - 1] === 1 ? slot : 0;
|
|
190
|
+
};
|
|
191
|
+
// Tracked keys get one verdict per press. A numpad digit always is, so a
|
|
192
|
+
// press that began as a digit stays one when the popup closes mid-press.
|
|
193
|
+
const tracked = (vk) => !MODIFIER_KEYS.has(vk) && (recording() || isNumpadDigit(vk) || chordIndex(vk, null) >= 0);
|
|
194
|
+
// Whether this press could be claimed at all, from the shared bytes alone:
|
|
195
|
+
// only then are the modifiers read.
|
|
196
|
+
const claimable = (vk) => recording() || numpadSlot(vk) > 0 || chordIndex(vk, null) >= 0;
|
|
197
|
+
const decide = (vk, mods) => {
|
|
198
|
+
if (recording()) return mods & PRIMARY ? { type: 'record', vk, mods } : null;
|
|
199
|
+
const i = chordIndex(vk, mods);
|
|
200
|
+
if (i >= 0) {
|
|
201
|
+
const at = STATE.CHORDS + i * STATE.CHORD_BYTES;
|
|
202
|
+
const type = ACTIONS[state[at + 2] - 1];
|
|
203
|
+
if (type === 'quick-paste') return { type, slot: state[at + 3] };
|
|
204
|
+
if (type) return { type };
|
|
205
|
+
}
|
|
206
|
+
const slot = numpadSlot(vk);
|
|
207
|
+
return slot && !(mods & (MOD.CTRL | MOD.ALT | MOD.SHIFT)) ? { type: 'numpad', slot } : null;
|
|
208
|
+
};
|
|
93
209
|
const tracker = createPressTracker();
|
|
94
210
|
let winUsed = false;
|
|
95
211
|
return {
|
|
@@ -99,16 +215,15 @@ function createHookClaimer(state, io) {
|
|
|
99
215
|
winUsed = false;
|
|
100
216
|
return { swallow: false, post: null, markWinUsed: true };
|
|
101
217
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
let
|
|
218
|
+
if (!tracker.holds(vk) && !(down && tracked(vk))) return PASS;
|
|
219
|
+
let mods = 0;
|
|
220
|
+
let post = null;
|
|
105
221
|
const r = tracker.event(vk, down,
|
|
106
|
-
() =>
|
|
222
|
+
() => claimable(vk) && !!(post = decide(vk, mods = modBits(io.readMods(flags)))),
|
|
107
223
|
() => io.isKeyDown(vk));
|
|
108
224
|
if (!r.swallow) return PASS;
|
|
109
|
-
if (r.fire && mods.
|
|
110
|
-
|
|
111
|
-
return { swallow: true, post, markWinUsed: false };
|
|
225
|
+
if (r.fire && (mods & MOD.WIN)) winUsed = true;
|
|
226
|
+
return { swallow: true, post: r.fire ? post : null, markWinUsed: false };
|
|
112
227
|
},
|
|
113
228
|
};
|
|
114
229
|
}
|
|
@@ -124,4 +239,7 @@ function modsFrom(isKeyDown, flags) {
|
|
|
124
239
|
};
|
|
125
240
|
}
|
|
126
241
|
|
|
127
|
-
module.exports = {
|
|
242
|
+
module.exports = {
|
|
243
|
+
STATE, VK, MOD, LLKHF_ALTDOWN, modBits, isWinChord, chordFromAccelerator, recordedChord,
|
|
244
|
+
writeChords, createPressTracker, createHookClaimer, modsFrom,
|
|
245
|
+
};
|
|
@@ -103,8 +103,8 @@ const keybd_event = user32.func(
|
|
|
103
103
|
);
|
|
104
104
|
|
|
105
105
|
const isKeyDown = (vk) => (GetAsyncKeyState(vk) & 0x8000) !== 0;
|
|
106
|
-
//
|
|
107
|
-
// one verdict per press (lib/windows-hook-policy.js).
|
|
106
|
+
// Main's chord table, a Settings recording and Numpad1-9: each claimed only
|
|
107
|
+
// with exactly its modifiers, one verdict per press (lib/windows-hook-policy.js).
|
|
108
108
|
const claimer = createHookClaimer(sharedState, {
|
|
109
109
|
isKeyDown,
|
|
110
110
|
readMods: (flags) => modsFrom(isKeyDown, flags),
|
package/lib/windows-hook.js
CHANGED
|
@@ -4,31 +4,33 @@
|
|
|
4
4
|
// On Windows we can't use Electron's `globalShortcut.register('Super+V')`
|
|
5
5
|
// because Windows Clipboard History owns Win+V at the RegisterHotKey layer
|
|
6
6
|
// and silently rejects our registration. This module installs a
|
|
7
|
-
// WH_KEYBOARD_LL hook on a dedicated worker thread which intercepts
|
|
8
|
-
// Win
|
|
9
|
-
//
|
|
7
|
+
// WH_KEYBOARD_LL hook on a dedicated worker thread which intercepts every
|
|
8
|
+
// Win chord BoardClip uses (Win+V, Win+N and any recorded one, see the chord
|
|
9
|
+
// table in lib/windows-hook-policy.js), the numpad slot keys, and the chord a
|
|
10
|
+
// Settings row is recording, *before* Windows' own handlers see them.
|
|
10
11
|
//
|
|
11
12
|
// See lib/windows-hook-worker.js for the hook implementation and the
|
|
12
13
|
// rationale for running it in a worker.
|
|
13
14
|
//
|
|
14
|
-
// Shared state (popup visible, which numpad slots are assigned,
|
|
15
|
-
// layout = STATE in lib/windows-hook-policy.js) is passed via a
|
|
15
|
+
// Shared state (popup visible, which numpad slots are assigned, recording,
|
|
16
|
+
// the chord table; layout = STATE in lib/windows-hook-policy.js) is passed via a
|
|
16
17
|
// SharedArrayBuffer because the worker is synchronously blocked in
|
|
17
18
|
// GetMessageW and can't process postMessage events from its JS event loop.
|
|
18
19
|
|
|
19
20
|
const { Worker } = require('worker_threads');
|
|
20
21
|
const path = require('path');
|
|
21
|
-
const { STATE } = require('./windows-hook-policy');
|
|
22
|
+
const { STATE, writeChords } = require('./windows-hook-policy');
|
|
22
23
|
|
|
23
24
|
let currentHook = null;
|
|
24
25
|
|
|
25
|
-
function install({ onShowPopup, onNumpadPaste, onNewNote }) {
|
|
26
|
+
function install({ onShowPopup, onNumpadPaste, onQuickPaste, onNewNote, onRecordKey }) {
|
|
26
27
|
if (process.platform !== 'win32') {
|
|
27
28
|
// Non-Windows platforms use Electron's globalShortcut; no-op API here.
|
|
28
29
|
return {
|
|
29
30
|
setPopupVisible() {},
|
|
30
31
|
setSlotAssignments() {},
|
|
31
|
-
|
|
32
|
+
setChords() { return true; },
|
|
33
|
+
setRecording() {},
|
|
32
34
|
uninstall() {},
|
|
33
35
|
};
|
|
34
36
|
}
|
|
@@ -49,8 +51,14 @@ function install({ onShowPopup, onNumpadPaste, onNewNote }) {
|
|
|
49
51
|
case 'numpad':
|
|
50
52
|
try { onNumpadPaste(msg.slot); } catch (e) { console.error('[windows-hook] onNumpadPaste:', e); }
|
|
51
53
|
break;
|
|
54
|
+
case 'quick-paste':
|
|
55
|
+
try { onQuickPaste(msg.slot); } catch (e) { console.error('[windows-hook] onQuickPaste:', e); }
|
|
56
|
+
break;
|
|
52
57
|
case 'new-note':
|
|
53
|
-
try {
|
|
58
|
+
try { onNewNote(); } catch (e) { console.error('[windows-hook] onNewNote:', e); }
|
|
59
|
+
break;
|
|
60
|
+
case 'record':
|
|
61
|
+
try { onRecordKey(msg.vk, msg.mods); } catch (e) { console.error('[windows-hook] onRecordKey:', e); }
|
|
54
62
|
break;
|
|
55
63
|
case 'ready':
|
|
56
64
|
// Hook installed successfully — no action needed.
|
|
@@ -81,10 +89,14 @@ function install({ onShowPopup, onNumpadPaste, onNewNote }) {
|
|
|
81
89
|
sharedState[STATE.SLOT_FIRST + n - 1] = assignedSet && assignedSet.has(n) ? 1 : 0;
|
|
82
90
|
}
|
|
83
91
|
},
|
|
84
|
-
// Win
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
sharedState
|
|
92
|
+
// The Win chords the hook claims: [{ vk, mods, type, slot? }] (main's
|
|
93
|
+
// registerShortcuts; empty while a recording runs). False = some did not fit.
|
|
94
|
+
setChords(chords) {
|
|
95
|
+
return writeChords(sharedState, chords);
|
|
96
|
+
},
|
|
97
|
+
// A Settings row is recording: every Win/Ctrl/Alt press goes to it.
|
|
98
|
+
setRecording(on) {
|
|
99
|
+
sharedState[STATE.RECORDING] = on ? 1 : 0;
|
|
88
100
|
},
|
|
89
101
|
uninstall() {
|
|
90
102
|
if (!currentHook) return;
|
package/main.js
CHANGED
|
@@ -50,6 +50,7 @@ const conflictModel = require('./lib/conflict-model');
|
|
|
50
50
|
const { ControlServer } = require('./lib/control-server');
|
|
51
51
|
const windowsConsole = require('./lib/windows-console');
|
|
52
52
|
const windowsDwm = require('./lib/windows-dwm');
|
|
53
|
+
const hookPolicy = require('./lib/windows-hook-policy');
|
|
53
54
|
const appearance = require('./lib/appearance');
|
|
54
55
|
const { createNotifier } = require('./lib/notifications');
|
|
55
56
|
|
|
@@ -676,9 +677,9 @@ function effectiveQuickPasteShortcut() {
|
|
|
676
677
|
return settings.quick_paste_shortcut || defaultQuickPasteShortcut();
|
|
677
678
|
}
|
|
678
679
|
|
|
679
|
-
// Windows: Win+N, taken by the keyboard hook like Win
|
|
680
|
-
// notification panel chord, which RegisterHotKey cannot win).
|
|
681
|
-
// until one is recorded.
|
|
680
|
+
// Windows: Win+N, taken by the keyboard hook like every Win chord (it is
|
|
681
|
+
// Windows' own notification panel chord, which RegisterHotKey cannot win).
|
|
682
|
+
// Elsewhere none until one is recorded.
|
|
682
683
|
function defaultNewNoteShortcut() {
|
|
683
684
|
return process.platform === 'win32' ? 'Super+N' : '';
|
|
684
685
|
}
|
|
@@ -687,22 +688,11 @@ function defaultNewNoteShortcut() {
|
|
|
687
688
|
// (Windows: Win+N goes back to Windows' notification panel). '' = the default.
|
|
688
689
|
const SHORTCUT_OFF = 'none';
|
|
689
690
|
|
|
690
|
-
// The chord the user recorded ('' for the default or off).
|
|
691
|
-
function recordedNewNoteShortcut() {
|
|
692
|
-
const value = settings.new_note_shortcut || '';
|
|
693
|
-
return value === SHORTCUT_OFF ? '' : value;
|
|
694
|
-
}
|
|
695
|
-
|
|
696
691
|
function effectiveNewNoteShortcut() {
|
|
697
692
|
if (settings.new_note_shortcut === SHORTCUT_OFF) return '';
|
|
698
693
|
return settings.new_note_shortcut || defaultNewNoteShortcut();
|
|
699
694
|
}
|
|
700
695
|
|
|
701
|
-
function globalShowShortcut() {
|
|
702
|
-
if (process.platform === 'darwin' && shortcutUsesFn(settings.show_shortcut)) return '';
|
|
703
|
-
return settings.show_shortcut || (process.platform === 'win32' ? '' : DEFAULT_SHOW_SHORTCUT);
|
|
704
|
-
}
|
|
705
|
-
|
|
706
696
|
function globalQuickPasteShortcut() {
|
|
707
697
|
const shortcut = effectiveQuickPasteShortcut();
|
|
708
698
|
if (shortcutUsesFn(shortcut)) return '';
|
|
@@ -761,7 +751,8 @@ function quickPasteShortcutForSlot(shortcut, slot) {
|
|
|
761
751
|
if (!parts.length) return '';
|
|
762
752
|
const keyIndex = parts.length - 1;
|
|
763
753
|
if (quickPasteSlotFromShortcut(parts[keyIndex]) == null) return '';
|
|
764
|
-
|
|
754
|
+
// A numpad digit stays a numpad digit (Ctrl+Alt+num1 is Ctrl+Alt+num1..num9).
|
|
755
|
+
parts[keyIndex] = (/^num/i.test(parts[keyIndex]) ? 'num' : '') + slot;
|
|
765
756
|
return parts.join('+');
|
|
766
757
|
}
|
|
767
758
|
|
|
@@ -8022,15 +8013,45 @@ function commitShortcutSetting(key, next, registeredFlag, error) {
|
|
|
8022
8013
|
}
|
|
8023
8014
|
|
|
8024
8015
|
function registerShortcuts() {
|
|
8025
|
-
//
|
|
8026
|
-
// Windows hook,
|
|
8027
|
-
//
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
const
|
|
8031
|
-
const
|
|
8032
|
-
const
|
|
8033
|
-
const
|
|
8016
|
+
// Where each shortcut lives: macOS's popup hotkey (Carbon, Globe-capable),
|
|
8017
|
+
// the Windows keyboard hook, a globalShortcut, or nowhere (off). On Windows
|
|
8018
|
+
// EVERY Win chord is the hook's (hookPolicy.isWinChord: the shell holds most
|
|
8019
|
+
// of them, Win+V / Win+N / Win+1-9 included, where a globalShortcut silently
|
|
8020
|
+
// loses); the defaults Win+V and Win+N are Win chords like a recorded one.
|
|
8021
|
+
const viaHook = (shortcut) => process.platform === 'win32' && hookPolicy.isWinChord(shortcut);
|
|
8022
|
+
const showShortcut = effectiveShowShortcut();
|
|
8023
|
+
const macosShowKey = process.platform === 'darwin' ? showShortcut : '';
|
|
8024
|
+
const showHook = viaHook(showShortcut);
|
|
8025
|
+
const showKey = macosShowKey || showHook ? '' : showShortcut;
|
|
8026
|
+
const quickPasteShortcut = globalQuickPasteShortcut();
|
|
8027
|
+
const quickPasteHook = viaHook(quickPasteShortcut);
|
|
8028
|
+
const quickPasteKey = quickPasteHook ? '' : quickPasteShortcut;
|
|
8029
|
+
const newNoteShortcut = shortcutUsesFn(effectiveNewNoteShortcut()) ? '' : effectiveNewNoteShortcut();
|
|
8030
|
+
const newNoteHook = viaHook(newNoteShortcut);
|
|
8031
|
+
const newNoteKey = newNoteHook ? '' : newNoteShortcut;
|
|
8032
|
+
|
|
8033
|
+
// The hook's chord table, first come first served like globalShortcut: a
|
|
8034
|
+
// chord two shortcuts share, or a key the hook has no code for, fails the
|
|
8035
|
+
// later one the way an already-registered globalShortcut does.
|
|
8036
|
+
const hookChords = [];
|
|
8037
|
+
const addHookChord = (shortcut, type, slot) => {
|
|
8038
|
+
const chord = hookPolicy.chordFromAccelerator(shortcut);
|
|
8039
|
+
if (!chord || hookChords.some(c => c.vk === chord.vk && c.mods === chord.mods)) return false;
|
|
8040
|
+
hookChords.push({ ...chord, type, slot });
|
|
8041
|
+
return true;
|
|
8042
|
+
};
|
|
8043
|
+
const showHookOk = !showHook || addHookChord(showShortcut, 'show');
|
|
8044
|
+
const quickPasteRegistrations = [];
|
|
8045
|
+
let quickPasteHookOk = true;
|
|
8046
|
+
if (quickPasteHook) {
|
|
8047
|
+
for (let n = 1; n <= 9; n++) {
|
|
8048
|
+
const key = quickPasteShortcutForSlot(quickPasteShortcut, n);
|
|
8049
|
+
const registered = !!key && addHookChord(key, 'quick-paste', n);
|
|
8050
|
+
quickPasteRegistrations.push({ slot: n, key, registered, backend: 'hook' });
|
|
8051
|
+
if (!registered) quickPasteHookOk = false;
|
|
8052
|
+
}
|
|
8053
|
+
}
|
|
8054
|
+
const newNoteHookOk = !newNoteHook || addHookChord(newNoteShortcut, 'new-note');
|
|
8034
8055
|
|
|
8035
8056
|
// A throwaway QA instance (BOARDCLIP_ISOLATED=1) never takes the keyboard: its
|
|
8036
8057
|
// low-level hook would sit in front of the real app's, so Win+V would open the
|
|
@@ -8040,11 +8061,11 @@ function registerShortcuts() {
|
|
|
8040
8061
|
// live, as it does for real (it cannot fail), so its row can be saved in QA.
|
|
8041
8062
|
if (process.env.BOARDCLIP_ISOLATED === '1') {
|
|
8042
8063
|
const isolated = {
|
|
8043
|
-
showShortcutRegistered: !(macosShowKey || showKey),
|
|
8064
|
+
showShortcutRegistered: showHookOk && !(macosShowKey || showKey),
|
|
8044
8065
|
showShortcut: effectiveShowShortcut(),
|
|
8045
|
-
quickPasteRegistered: !quickPasteKey,
|
|
8066
|
+
quickPasteRegistered: quickPasteHookOk && !quickPasteKey,
|
|
8046
8067
|
quickPasteShortcut: effectiveQuickPasteShortcut(),
|
|
8047
|
-
newNoteRegistered: !newNoteKey,
|
|
8068
|
+
newNoteRegistered: newNoteHookOk && !newNoteKey,
|
|
8048
8069
|
newNoteShortcut: effectiveNewNoteShortcut(),
|
|
8049
8070
|
};
|
|
8050
8071
|
diagnostics.record('shortcut.register', { platform: process.platform, isolated: true, show_registered: isolated.showShortcutRegistered, quick_paste_registered: isolated.quickPasteRegistered, new_note_registered: isolated.newNoteRegistered });
|
|
@@ -8062,22 +8083,26 @@ function registerShortcuts() {
|
|
|
8062
8083
|
}
|
|
8063
8084
|
globalShortcut.unregisterAll();
|
|
8064
8085
|
|
|
8065
|
-
let showShortcutRegistered =
|
|
8066
|
-
let quickPasteRegistered =
|
|
8067
|
-
let newNoteRegistered =
|
|
8086
|
+
let showShortcutRegistered = showHookOk;
|
|
8087
|
+
let quickPasteRegistered = quickPasteHookOk;
|
|
8088
|
+
let newNoteRegistered = newNoteHookOk;
|
|
8068
8089
|
|
|
8069
8090
|
if (process.platform === 'win32') {
|
|
8070
|
-
//
|
|
8071
|
-
//
|
|
8072
|
-
//
|
|
8073
|
-
// intercepts these keys *before* Windows Clipboard History sees them.
|
|
8091
|
+
// The WH_KEYBOARD_LL hook on its own worker thread (lib/windows-hook.js):
|
|
8092
|
+
// it takes the chord table, the numpad slot keys and a recording before
|
|
8093
|
+
// Windows' own handlers see them.
|
|
8074
8094
|
const { install } = require('./lib/windows-hook');
|
|
8075
8095
|
windowsHook = install({
|
|
8076
8096
|
onShowPopup: showPopup,
|
|
8077
8097
|
onNumpadPaste: handleNumpad,
|
|
8098
|
+
onQuickPaste: (slot) => handleQuickPaste(slot).catch(error => {
|
|
8099
|
+
diagnostics.record('shortcut.quick_paste_error', { slot, source: 'hook', error: error && error.message }, { forceFile: true });
|
|
8100
|
+
}),
|
|
8078
8101
|
onNewNote: handleNewNoteShortcut,
|
|
8102
|
+
onRecordKey: handleHookRecordedKey,
|
|
8079
8103
|
});
|
|
8080
|
-
windowsHook.
|
|
8104
|
+
windowsHook.setRecording(false);
|
|
8105
|
+
windowsHook.setChords(hookChords);
|
|
8081
8106
|
// Seed the shared state with current history so plain numpad keys
|
|
8082
8107
|
// immediately intercept for already-assigned slots.
|
|
8083
8108
|
syncHookState();
|
|
@@ -8099,7 +8124,6 @@ function registerShortcuts() {
|
|
|
8099
8124
|
if (!showShortcutRegistered) logSafe(`Warning: Could not register popup shortcut ${showKey}`);
|
|
8100
8125
|
}
|
|
8101
8126
|
|
|
8102
|
-
const quickPasteRegistrations = [];
|
|
8103
8127
|
if (quickPasteKey) {
|
|
8104
8128
|
if (process.platform === 'darwin') {
|
|
8105
8129
|
const hook = getMacosHotkey();
|
|
@@ -8148,15 +8172,15 @@ function registerShortcuts() {
|
|
|
8148
8172
|
}
|
|
8149
8173
|
diagnostics.record('shortcut.register', {
|
|
8150
8174
|
platform: process.platform,
|
|
8151
|
-
show_key:
|
|
8175
|
+
show_key: showShortcut,
|
|
8152
8176
|
show_registered: showShortcutRegistered,
|
|
8153
|
-
show_backend: macosShowKey ? 'carbon' : (
|
|
8154
|
-
quick_paste_key:
|
|
8177
|
+
show_backend: macosShowKey ? 'carbon' : (showHook ? 'hook' : 'electron'),
|
|
8178
|
+
quick_paste_key: quickPasteShortcut,
|
|
8155
8179
|
quick_paste_registered: quickPasteRegistered,
|
|
8156
8180
|
quick_paste_registrations: quickPasteRegistrations,
|
|
8157
|
-
new_note_key:
|
|
8181
|
+
new_note_key: newNoteShortcut,
|
|
8158
8182
|
new_note_registered: newNoteRegistered,
|
|
8159
|
-
new_note_backend:
|
|
8183
|
+
new_note_backend: newNoteHook ? 'hook' : (newNoteKey ? 'electron' : ''),
|
|
8160
8184
|
}, { forceFile: true });
|
|
8161
8185
|
|
|
8162
8186
|
return {
|
|
@@ -8172,8 +8196,14 @@ function registerShortcuts() {
|
|
|
8172
8196
|
function suspendShortcutsForRecording() {
|
|
8173
8197
|
shortcutsSuspended = true;
|
|
8174
8198
|
globalShortcut.unregisterAll();
|
|
8175
|
-
// The hook's
|
|
8176
|
-
|
|
8199
|
+
// The hook's chords too, and every Win/Ctrl/Alt press now goes to the
|
|
8200
|
+
// recorder instead of its app (resume re-arms them). Without this Windows
|
|
8201
|
+
// acted on a Win chord first: Win+1 switched to a taskbar app, the popup
|
|
8202
|
+
// lost focus and hid mid-recording, nothing was saved.
|
|
8203
|
+
if (windowsHook) {
|
|
8204
|
+
windowsHook.setChords([]);
|
|
8205
|
+
windowsHook.setRecording(true);
|
|
8206
|
+
}
|
|
8177
8207
|
if (macosHotkey) {
|
|
8178
8208
|
macosHotkey.clearRuntimeShortcut();
|
|
8179
8209
|
macosHotkey.clearQuickPasteShortcuts();
|
|
@@ -8182,6 +8212,15 @@ function suspendShortcutsForRecording() {
|
|
|
8182
8212
|
return { ok: true };
|
|
8183
8213
|
}
|
|
8184
8214
|
|
|
8215
|
+
// A chord the hook took while a Settings row records (Windows): handed to the
|
|
8216
|
+
// popup's recorder as the chord its own keydown would have been.
|
|
8217
|
+
function handleHookRecordedKey(vk, mods) {
|
|
8218
|
+
if (!shortcutsSuspended || !win || win.isDestroyed()) return;
|
|
8219
|
+
const chord = hookPolicy.recordedChord(vk, mods);
|
|
8220
|
+
diagnostics.record('shortcut.record_key', { vk, mods, shortcut: chord.shortcut }, { forceFile: diagnostics.isEnabled() });
|
|
8221
|
+
win.webContents.send('shortcut-recorded', chord);
|
|
8222
|
+
}
|
|
8223
|
+
|
|
8185
8224
|
function resumeShortcutsAfterRecording() {
|
|
8186
8225
|
shortcutsSuspended = false;
|
|
8187
8226
|
const result = registerShortcuts();
|
package/package.json
CHANGED
package/preload.js
CHANGED
|
@@ -35,6 +35,13 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
35
35
|
suspendShortcuts: () => ipcRenderer.invoke('suspend-shortcuts'),
|
|
36
36
|
resumeShortcuts: () => ipcRenderer.invoke('resume-shortcuts'),
|
|
37
37
|
resolveShowShortcut: (shortcut) => ipcRenderer.invoke('resolve-show-shortcut', shortcut),
|
|
38
|
+
// Windows: the chord the keyboard hook took while a row records ({ shortcut, key },
|
|
39
|
+
// what the page recorder makes of a keydown); the page never sees those keys.
|
|
40
|
+
onShortcutRecorded: (callback) => {
|
|
41
|
+
const listener = (_, chord) => callback(chord);
|
|
42
|
+
ipcRenderer.on('shortcut-recorded', listener);
|
|
43
|
+
return () => ipcRenderer.removeListener('shortcut-recorded', listener);
|
|
44
|
+
},
|
|
38
45
|
groupCreate: (text) => ipcRenderer.invoke('group-create', text), // -> the tag names ("a, b" = two), or false
|
|
39
46
|
groupDelete: (name) => ipcRenderer.invoke('group-delete', name), // -> { ok, name, ids, shared }, or false
|
|
40
47
|
groupRestore: (payload) => ipcRenderer.invoke('group-restore', payload), // the delete's Undo
|