qdesk 1.0.0
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/README.md +94 -0
- package/dist/chord-utils.js +228 -0
- package/dist/index.js +121 -0
- package/dist/koffi-utils.js +89 -0
- package/dist/screen-hue.js +162 -0
- package/dist/window-utils.js +65 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# qdesk
|
|
2
|
+
|
|
3
|
+
Global keyboard chord switcher for Windows.
|
|
4
|
+
|
|
5
|
+
qdesk lets you:
|
|
6
|
+
|
|
7
|
+
- Arm an add mode, then press any chord to bind the current foreground window.
|
|
8
|
+
- Arm a remove mode, then press a chord to delete that binding.
|
|
9
|
+
- Jump to a bound window by pressing its chord.
|
|
10
|
+
|
|
11
|
+
It uses native Win32 APIs through Koffi.
|
|
12
|
+
|
|
13
|
+
## Platform
|
|
14
|
+
|
|
15
|
+
- Windows only
|
|
16
|
+
- Node.js 18+ recommended
|
|
17
|
+
|
|
18
|
+
## Run With npx
|
|
19
|
+
|
|
20
|
+
Run directly without installing globally:
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
npx qdesk
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Override control chords at startup:
|
|
27
|
+
|
|
28
|
+
```powershell
|
|
29
|
+
npx qdesk -a ctrl+shift+r -d ctrl+shift+d
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Startup flags:
|
|
33
|
+
|
|
34
|
+
- `-a <combo>`: add/bind mode trigger
|
|
35
|
+
- `-d <combo>`: drop mode trigger
|
|
36
|
+
|
|
37
|
+
Defaults:
|
|
38
|
+
|
|
39
|
+
- add chord: `ctrl+win+a`
|
|
40
|
+
- drop chord: `ctrl+win+d`
|
|
41
|
+
|
|
42
|
+
## How It Works
|
|
43
|
+
|
|
44
|
+
1. Press the `add` chord.
|
|
45
|
+
2. qdesk enters recording mode (red screen hue indicates recording).
|
|
46
|
+
3. Press the chord you want to bind.
|
|
47
|
+
4. Press that bound chord later to activate its window.
|
|
48
|
+
|
|
49
|
+
Remove flow:
|
|
50
|
+
|
|
51
|
+
1. Press the 'drop' chord.
|
|
52
|
+
2. qdesk enters remove mode (green hue).
|
|
53
|
+
3. Press the chord to delete.
|
|
54
|
+
|
|
55
|
+
## Install Locally (Dev)
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
pnpm install
|
|
59
|
+
pnpm build
|
|
60
|
+
pnpm start
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Run directly from TypeScript source:
|
|
64
|
+
|
|
65
|
+
```powershell
|
|
66
|
+
pnpm dev
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Package Scripts
|
|
70
|
+
|
|
71
|
+
- `pnpm dev` - run from TypeScript source
|
|
72
|
+
- `pnpm build` - clean and compile to `dist`
|
|
73
|
+
- `pnpm start` - run compiled CLI
|
|
74
|
+
- `pnpm prepack` - build before packing/publishing
|
|
75
|
+
|
|
76
|
+
## Publish/Test Package
|
|
77
|
+
|
|
78
|
+
Create a tarball:
|
|
79
|
+
|
|
80
|
+
```powershell
|
|
81
|
+
pnpm pack
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Test tarball locally:
|
|
85
|
+
|
|
86
|
+
```powershell
|
|
87
|
+
npx --yes .\qdesk-<version>.tgz
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Notes
|
|
91
|
+
|
|
92
|
+
- Some display/driver combinations may limit screen hue APIs.
|
|
93
|
+
- Window activation can be restricted by Windows focus rules for certain apps.
|
|
94
|
+
- Use unique add/drop chords to avoid mode conflicts.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { CallNextHookEx, decodeKeyboardHookLParam, DispatchMessageW, GetAsyncKeyState, GetMessageW, GetModuleHandleW, registerKeyboardHookCallback, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx, unregisterCallback, } from "./koffi-utils.js";
|
|
2
|
+
const MOD_ALT = 0x0001;
|
|
3
|
+
const MOD_CONTROL = 0x0002;
|
|
4
|
+
const MOD_SHIFT = 0x0004;
|
|
5
|
+
const MOD_WIN = 0x0008;
|
|
6
|
+
const VK_SHIFT = 0x10;
|
|
7
|
+
const VK_CONTROL = 0x11;
|
|
8
|
+
const VK_MENU = 0x12;
|
|
9
|
+
const VK_LWIN = 0x5b;
|
|
10
|
+
const VK_RWIN = 0x5c;
|
|
11
|
+
const HC_ACTION = 0;
|
|
12
|
+
const WH_KEYBOARD_LL = 13;
|
|
13
|
+
const WM_KEYDOWN = 0x0100;
|
|
14
|
+
const WM_SYSKEYDOWN = 0x0104;
|
|
15
|
+
export function normalizeChord(chord) {
|
|
16
|
+
const parts = chord
|
|
17
|
+
.split("+")
|
|
18
|
+
.map((part) => part.trim().toLowerCase())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
if (parts.length < 2) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const key = parts[parts.length - 1];
|
|
24
|
+
if (!/^[0-9a-z]$/.test(key) && !/^f([1-9]|1[0-9]|2[0-4])$/.test(key)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const modifiers = new Set(parts.slice(0, -1));
|
|
28
|
+
for (const mod of modifiers) {
|
|
29
|
+
if (!["ctrl", "control", "alt", "shift", "win", "meta"].includes(mod)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const normalizedMods = [];
|
|
34
|
+
if (modifiers.has("ctrl") || modifiers.has("control")) {
|
|
35
|
+
normalizedMods.push("ctrl");
|
|
36
|
+
}
|
|
37
|
+
if (modifiers.has("alt")) {
|
|
38
|
+
normalizedMods.push("alt");
|
|
39
|
+
}
|
|
40
|
+
if (modifiers.has("shift")) {
|
|
41
|
+
normalizedMods.push("shift");
|
|
42
|
+
}
|
|
43
|
+
if (modifiers.has("win") || modifiers.has("meta")) {
|
|
44
|
+
normalizedMods.push("win");
|
|
45
|
+
}
|
|
46
|
+
if (normalizedMods.length === 0) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return `${normalizedMods.join("+")}+${key}`;
|
|
50
|
+
}
|
|
51
|
+
function parseHotkeyCombo(combo) {
|
|
52
|
+
const parts = combo
|
|
53
|
+
.split("+")
|
|
54
|
+
.map((part) => part.trim().toLowerCase())
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
if (parts.length < 2) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const key = parts[parts.length - 1];
|
|
60
|
+
const modifiers = parts.slice(0, -1);
|
|
61
|
+
let modMask = 0;
|
|
62
|
+
const normalizedModifiers = [];
|
|
63
|
+
for (const mod of modifiers) {
|
|
64
|
+
if (mod === "alt") {
|
|
65
|
+
modMask |= MOD_ALT;
|
|
66
|
+
normalizedModifiers.push("alt");
|
|
67
|
+
}
|
|
68
|
+
else if (mod === "ctrl" || mod === "control") {
|
|
69
|
+
modMask |= MOD_CONTROL;
|
|
70
|
+
normalizedModifiers.push("ctrl");
|
|
71
|
+
}
|
|
72
|
+
else if (mod === "shift") {
|
|
73
|
+
modMask |= MOD_SHIFT;
|
|
74
|
+
normalizedModifiers.push("shift");
|
|
75
|
+
}
|
|
76
|
+
else if (mod === "win" || mod === "meta") {
|
|
77
|
+
modMask |= MOD_WIN;
|
|
78
|
+
normalizedModifiers.push("win");
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
let vk = -1;
|
|
85
|
+
if (/^[0-9]$/.test(key)) {
|
|
86
|
+
vk = key.charCodeAt(0);
|
|
87
|
+
}
|
|
88
|
+
else if (/^[a-z]$/.test(key)) {
|
|
89
|
+
vk = key.toUpperCase().charCodeAt(0);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const functionMatch = /^f([1-9]|1[0-9]|2[0-4])$/.exec(key);
|
|
93
|
+
if (functionMatch) {
|
|
94
|
+
const fn = Number(functionMatch[1]);
|
|
95
|
+
vk = 0x70 + (fn - 1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (vk < 0 || modMask === 0) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const canonicalModifiers = ["ctrl", "alt", "shift", "win"].filter((mod) => normalizedModifiers.includes(mod));
|
|
102
|
+
return {
|
|
103
|
+
modifiers: modMask,
|
|
104
|
+
vk,
|
|
105
|
+
normalized: `${canonicalModifiers.join("+")}+${key}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function isPressed(vk) {
|
|
109
|
+
return (Number(GetAsyncKeyState(vk)) & 0x8000) !== 0;
|
|
110
|
+
}
|
|
111
|
+
function vkToKeyToken(vkCode) {
|
|
112
|
+
if (vkCode >= 0x30 && vkCode <= 0x39) {
|
|
113
|
+
return String.fromCharCode(vkCode);
|
|
114
|
+
}
|
|
115
|
+
if (vkCode >= 0x41 && vkCode <= 0x5a) {
|
|
116
|
+
return String.fromCharCode(vkCode).toLowerCase();
|
|
117
|
+
}
|
|
118
|
+
if (vkCode >= 0x70 && vkCode <= 0x87) {
|
|
119
|
+
return `f${vkCode - 0x6f}`;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
function buildCurrentChord(vkCode) {
|
|
124
|
+
const key = vkToKeyToken(vkCode);
|
|
125
|
+
if (!key) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const parts = [];
|
|
129
|
+
if (isPressed(VK_CONTROL)) {
|
|
130
|
+
parts.push("ctrl");
|
|
131
|
+
}
|
|
132
|
+
if (isPressed(VK_MENU)) {
|
|
133
|
+
parts.push("alt");
|
|
134
|
+
}
|
|
135
|
+
if (isPressed(VK_SHIFT)) {
|
|
136
|
+
parts.push("shift");
|
|
137
|
+
}
|
|
138
|
+
if (isPressed(VK_LWIN) || isPressed(VK_RWIN)) {
|
|
139
|
+
parts.push("win");
|
|
140
|
+
}
|
|
141
|
+
if (parts.length === 0) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return `${parts.join("+")}+${key}`;
|
|
145
|
+
}
|
|
146
|
+
export function startListening(options) {
|
|
147
|
+
const activationNormalized = options.activationChord
|
|
148
|
+
? normalizeChord(options.activationChord)
|
|
149
|
+
: null;
|
|
150
|
+
if (options.activationChord && !activationNormalized) {
|
|
151
|
+
console.error(`[error] Invalid activation chord: ${options.activationChord}`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
let keyboardHook = null;
|
|
155
|
+
let keyboardHookProcPtr = null;
|
|
156
|
+
const callback = (nCode, wParam, lParam) => {
|
|
157
|
+
const passThrough = () => BigInt(Number(CallNextHookEx(keyboardHook, nCode, wParam, lParam)));
|
|
158
|
+
try {
|
|
159
|
+
if (nCode !== HC_ACTION) {
|
|
160
|
+
return passThrough();
|
|
161
|
+
}
|
|
162
|
+
const message = Number(wParam);
|
|
163
|
+
if (message !== WM_KEYDOWN && message !== WM_SYSKEYDOWN) {
|
|
164
|
+
return passThrough();
|
|
165
|
+
}
|
|
166
|
+
const kb = decodeKeyboardHookLParam(lParam);
|
|
167
|
+
const combo = buildCurrentChord(Number(kb.vkCode));
|
|
168
|
+
if (!combo) {
|
|
169
|
+
return passThrough();
|
|
170
|
+
}
|
|
171
|
+
let shouldStopPropagation = false;
|
|
172
|
+
if (activationNormalized && combo === activationNormalized) {
|
|
173
|
+
options.onActivation?.(() => {
|
|
174
|
+
shouldStopPropagation = true;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
options.onChord(combo, () => {
|
|
178
|
+
shouldStopPropagation = true;
|
|
179
|
+
});
|
|
180
|
+
if (shouldStopPropagation) {
|
|
181
|
+
return 1n;
|
|
182
|
+
}
|
|
183
|
+
return passThrough();
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
console.error("[error] Keyboard hook callback failed:", error);
|
|
187
|
+
return passThrough();
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
keyboardHookProcPtr = registerKeyboardHookCallback(callback);
|
|
191
|
+
const moduleHandle = GetModuleHandleW(null);
|
|
192
|
+
keyboardHook = SetWindowsHookExW(WH_KEYBOARD_LL, keyboardHookProcPtr, moduleHandle, 0);
|
|
193
|
+
if (!keyboardHook) {
|
|
194
|
+
console.error("[error] SetWindowsHookExW failed.");
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
function shutdown() {
|
|
198
|
+
console.log("\n[qdesk] Shutting down...");
|
|
199
|
+
if (keyboardHook) {
|
|
200
|
+
UnhookWindowsHookEx(keyboardHook);
|
|
201
|
+
keyboardHook = null;
|
|
202
|
+
}
|
|
203
|
+
if (keyboardHookProcPtr) {
|
|
204
|
+
unregisterCallback(keyboardHookProcPtr);
|
|
205
|
+
keyboardHookProcPtr = null;
|
|
206
|
+
}
|
|
207
|
+
process.exit(0);
|
|
208
|
+
}
|
|
209
|
+
process.on("SIGINT", shutdown);
|
|
210
|
+
process.on("SIGTERM", shutdown);
|
|
211
|
+
return {
|
|
212
|
+
runMessageLoop: () => {
|
|
213
|
+
const msg = {};
|
|
214
|
+
while (true) {
|
|
215
|
+
const ret = Number(GetMessageW(msg, null, 0, 0));
|
|
216
|
+
if (ret === 0) {
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
if (ret === -1) {
|
|
220
|
+
console.error("[error] GetMessageW returned -1");
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
TranslateMessage(msg);
|
|
224
|
+
DispatchMessageW(msg);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { normalizeChord, startListening } from "./chord-utils.js";
|
|
4
|
+
import { setScreenHue } from "./screen-hue.js";
|
|
5
|
+
import { activateWindowByHandle, getActiveWindowHandleAndName, } from "./window-utils.js";
|
|
6
|
+
// Configure this chord at the top of file.
|
|
7
|
+
const DEFAULT_ADD_CHORD = "win+ctrl+a";
|
|
8
|
+
const DEFAULT_DROP_CHORD = "win+ctrl+d";
|
|
9
|
+
function normalizeChordInput(chord) {
|
|
10
|
+
return normalizeChord(chord.trim().toLowerCase());
|
|
11
|
+
}
|
|
12
|
+
function getCliArgValue(flag) {
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
const exact = args.find((arg) => arg.startsWith(`${flag}=`));
|
|
15
|
+
if (exact) {
|
|
16
|
+
return exact.slice(flag.length + 1);
|
|
17
|
+
}
|
|
18
|
+
const idx = args.indexOf(flag);
|
|
19
|
+
if (idx >= 0 && idx + 1 < args.length) {
|
|
20
|
+
return args[idx + 1];
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const ADD_CHORD = normalizeChordInput(getCliArgValue("-a") ?? DEFAULT_ADD_CHORD);
|
|
25
|
+
const DROP_CHORD = normalizeChordInput(getCliArgValue("-d") ?? DEFAULT_DROP_CHORD);
|
|
26
|
+
if (ADD_CHORD === DROP_CHORD) {
|
|
27
|
+
console.warn(`[warn] add and drop chords are identical (${ADD_CHORD}); behavior may conflict.`);
|
|
28
|
+
}
|
|
29
|
+
const bindings = new Map();
|
|
30
|
+
let activeRecording;
|
|
31
|
+
let awaitingChordRemoval = false;
|
|
32
|
+
const toolWindowAtStartup = getActiveWindowHandleAndName() ?? undefined;
|
|
33
|
+
console.log("[info] Tool window at startup:", toolWindowAtStartup?.name ?? "(unknown)");
|
|
34
|
+
let removalReturnWindow;
|
|
35
|
+
const listener = startListening({
|
|
36
|
+
onChord: (chord, stopPropagating) => {
|
|
37
|
+
if (activeRecording) {
|
|
38
|
+
if ([ADD_CHORD, DROP_CHORD].includes(chord)) {
|
|
39
|
+
console.warn(`[warn] Ignoring ${chord} chord during active recording to avoid conflicts.`);
|
|
40
|
+
return stopPropagating();
|
|
41
|
+
}
|
|
42
|
+
bindings.set(chord, activeRecording);
|
|
43
|
+
console.log(`[add] Bound ${chord} -> "${activeRecording.name}"`);
|
|
44
|
+
activeRecording = undefined;
|
|
45
|
+
setScreenHue("off");
|
|
46
|
+
printBindings();
|
|
47
|
+
return stopPropagating();
|
|
48
|
+
}
|
|
49
|
+
if (awaitingChordRemoval) {
|
|
50
|
+
awaitingChordRemoval = false;
|
|
51
|
+
setScreenHue("off");
|
|
52
|
+
if (bindings.delete(chord)) {
|
|
53
|
+
console.log(`[drop] Removed binding for ${chord}`);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
console.log(`[drop] No binding exists for ${chord}`);
|
|
57
|
+
}
|
|
58
|
+
printBindings();
|
|
59
|
+
return stopPropagating();
|
|
60
|
+
}
|
|
61
|
+
if (chord === ADD_CHORD) {
|
|
62
|
+
awaitingChordRemoval = false;
|
|
63
|
+
setScreenHue("off");
|
|
64
|
+
activeRecording = getActiveWindowHandleAndName();
|
|
65
|
+
if (!activeRecording) {
|
|
66
|
+
console.warn("[warn] No foreground window found to bind.");
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
console.log(`[add] Armed for "${activeRecording.name}". Waiting for next chord...`);
|
|
70
|
+
setScreenHue("record");
|
|
71
|
+
}
|
|
72
|
+
return stopPropagating();
|
|
73
|
+
}
|
|
74
|
+
if (chord === DROP_CHORD) {
|
|
75
|
+
if (activeRecording) {
|
|
76
|
+
activeRecording = undefined;
|
|
77
|
+
setScreenHue("off");
|
|
78
|
+
}
|
|
79
|
+
awaitingChordRemoval = true;
|
|
80
|
+
if (toolWindowAtStartup) {
|
|
81
|
+
activateWindowByHandle(toolWindowAtStartup.handle);
|
|
82
|
+
}
|
|
83
|
+
console.log("[drop] Armed. Press the chord you want to remove.");
|
|
84
|
+
printBindings();
|
|
85
|
+
setScreenHue("remove");
|
|
86
|
+
return stopPropagating();
|
|
87
|
+
}
|
|
88
|
+
const binding = bindings.get(chord);
|
|
89
|
+
if (binding) {
|
|
90
|
+
const result = activateWindowByHandle(binding.handle);
|
|
91
|
+
if (result === "missing") {
|
|
92
|
+
console.error(`[switch ${chord}] Window "${binding.name}" no longer exists, clearing binding.`);
|
|
93
|
+
bindings.delete(chord);
|
|
94
|
+
}
|
|
95
|
+
else if (result === "activated") {
|
|
96
|
+
console.log(`[binding ${chord}] Switched to "${binding.name}"`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
console.warn(`[switch ${chord}] Activation failed for "${binding.name}"`);
|
|
100
|
+
}
|
|
101
|
+
return stopPropagating();
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
function printBindings() {
|
|
106
|
+
console.log("\n[qdesk] Current bindings:");
|
|
107
|
+
if (bindings.size === 0) {
|
|
108
|
+
console.log(" (none)");
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
for (const [chord, window] of bindings) {
|
|
112
|
+
console.log(` ${chord} -> "${window.name}"`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
console.log("");
|
|
116
|
+
}
|
|
117
|
+
console.log("[qdesk] Starting...");
|
|
118
|
+
console.log(` Adding a chord: ${ADD_CHORD}`);
|
|
119
|
+
console.log(` Dropping a chord: ${DROP_CHORD}`);
|
|
120
|
+
console.log(" CLI overrides: -a <combo>, -d <combo>");
|
|
121
|
+
listener.runMessageLoop();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import koffi from "koffi";
|
|
2
|
+
export const HWND = koffi.pointer("HWND", koffi.opaque());
|
|
3
|
+
const HHOOK = koffi.pointer("HHOOK", koffi.opaque());
|
|
4
|
+
const HINSTANCE = koffi.pointer("HINSTANCE", koffi.opaque());
|
|
5
|
+
const UINT = "uint32";
|
|
6
|
+
const WPARAM = "uint64";
|
|
7
|
+
const LPARAM = "int64";
|
|
8
|
+
const MSG = koffi.struct("MSG", {
|
|
9
|
+
hwnd: HWND,
|
|
10
|
+
message: UINT,
|
|
11
|
+
wParam: WPARAM,
|
|
12
|
+
lParam: LPARAM,
|
|
13
|
+
time: "uint32",
|
|
14
|
+
ptX: "int32",
|
|
15
|
+
ptY: "int32",
|
|
16
|
+
});
|
|
17
|
+
const KBDLLHOOKSTRUCT = koffi.struct("KBDLLHOOKSTRUCT", {
|
|
18
|
+
vkCode: "uint32",
|
|
19
|
+
scanCode: "uint32",
|
|
20
|
+
flags: "uint32",
|
|
21
|
+
time: "uint32",
|
|
22
|
+
dwExtraInfo: "uint64",
|
|
23
|
+
});
|
|
24
|
+
const KeyboardHookProc = koffi.proto("int64 KeyboardHookProc(int nCode, uint64 wParam, void *lParam)");
|
|
25
|
+
const user32 = koffi.load("user32.dll");
|
|
26
|
+
const kernel32 = koffi.load("kernel32.dll");
|
|
27
|
+
export const GetForegroundWindow = user32.func("GetForegroundWindow", HWND, []);
|
|
28
|
+
export const SetForegroundWindow = user32.func("SetForegroundWindow", "int", [
|
|
29
|
+
HWND,
|
|
30
|
+
]);
|
|
31
|
+
export const IsWindow = user32.func("IsWindow", "int", [HWND]);
|
|
32
|
+
export const IsIconic = user32.func("IsIconic", "int", [HWND]);
|
|
33
|
+
export const ShowWindow = user32.func("ShowWindow", "int", [HWND, "int"]);
|
|
34
|
+
export const BringWindowToTop = user32.func("BringWindowToTop", "int", [HWND]);
|
|
35
|
+
export const SetActiveWindow = user32.func("SetActiveWindow", HWND, [HWND]);
|
|
36
|
+
export const AttachThreadInput = user32.func("AttachThreadInput", "int", [
|
|
37
|
+
"uint32",
|
|
38
|
+
"uint32",
|
|
39
|
+
"int",
|
|
40
|
+
]);
|
|
41
|
+
export const GetWindowThreadProcessId = user32.func("GetWindowThreadProcessId", "uint32", [HWND, "void *"]);
|
|
42
|
+
export const GetWindowTextW = user32.func("GetWindowTextW", "int", [
|
|
43
|
+
HWND,
|
|
44
|
+
koffi.out("char16 *"),
|
|
45
|
+
"int",
|
|
46
|
+
]);
|
|
47
|
+
export const GetAsyncKeyState = user32.func("GetAsyncKeyState", "int16", [
|
|
48
|
+
"int",
|
|
49
|
+
]);
|
|
50
|
+
export const GetMessageW = user32.func("GetMessageW", "int", [
|
|
51
|
+
koffi.out(koffi.pointer(MSG)),
|
|
52
|
+
HWND,
|
|
53
|
+
UINT,
|
|
54
|
+
UINT,
|
|
55
|
+
]);
|
|
56
|
+
export const TranslateMessage = user32.func("TranslateMessage", "int", [
|
|
57
|
+
koffi.pointer(MSG),
|
|
58
|
+
]);
|
|
59
|
+
export const DispatchMessageW = user32.func("DispatchMessageW", LPARAM, [
|
|
60
|
+
koffi.pointer(MSG),
|
|
61
|
+
]);
|
|
62
|
+
export const SetWindowsHookExW = user32.func("SetWindowsHookExW", HHOOK, [
|
|
63
|
+
"int",
|
|
64
|
+
koffi.pointer(KeyboardHookProc),
|
|
65
|
+
HINSTANCE,
|
|
66
|
+
"uint32",
|
|
67
|
+
]);
|
|
68
|
+
export const CallNextHookEx = user32.func("CallNextHookEx", "int64", [
|
|
69
|
+
HHOOK,
|
|
70
|
+
"int",
|
|
71
|
+
"uint64",
|
|
72
|
+
"void *",
|
|
73
|
+
]);
|
|
74
|
+
export const UnhookWindowsHookEx = user32.func("UnhookWindowsHookEx", "int", [
|
|
75
|
+
HHOOK,
|
|
76
|
+
]);
|
|
77
|
+
export const GetModuleHandleW = kernel32.func("GetModuleHandleW", HINSTANCE, [
|
|
78
|
+
"char16 *",
|
|
79
|
+
]);
|
|
80
|
+
export const GetCurrentThreadId = kernel32.func("GetCurrentThreadId", "uint32", []);
|
|
81
|
+
export function decodeKeyboardHookLParam(lParam) {
|
|
82
|
+
return koffi.decode(lParam, KBDLLHOOKSTRUCT);
|
|
83
|
+
}
|
|
84
|
+
export function registerKeyboardHookCallback(callback) {
|
|
85
|
+
return koffi.register(callback, koffi.pointer(KeyboardHookProc));
|
|
86
|
+
}
|
|
87
|
+
export function unregisterCallback(cb) {
|
|
88
|
+
koffi.unregister(cb);
|
|
89
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import koffi from "koffi";
|
|
2
|
+
import { HWND } from "./koffi-utils.js";
|
|
3
|
+
const HDC = koffi.pointer("HDC", koffi.opaque());
|
|
4
|
+
const user32 = koffi.load("user32.dll");
|
|
5
|
+
const gdi32 = koffi.load("gdi32.dll");
|
|
6
|
+
let magnification = null;
|
|
7
|
+
try {
|
|
8
|
+
magnification = koffi.load("Magnification.dll");
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
magnification = null;
|
|
12
|
+
}
|
|
13
|
+
const GetDC = user32.func("GetDC", HDC, [HWND]);
|
|
14
|
+
const ReleaseDC = user32.func("ReleaseDC", "int", [HWND, HDC]);
|
|
15
|
+
const SetDeviceGammaRamp = gdi32.func("SetDeviceGammaRamp", "int", [
|
|
16
|
+
HDC,
|
|
17
|
+
"void *",
|
|
18
|
+
]);
|
|
19
|
+
const MagInitialize = magnification
|
|
20
|
+
? magnification.func("MagInitialize", "int", [])
|
|
21
|
+
: null;
|
|
22
|
+
const MagSetFullscreenColorEffect = magnification
|
|
23
|
+
? magnification.func("MagSetFullscreenColorEffect", "int", ["void *"])
|
|
24
|
+
: null;
|
|
25
|
+
let hasWarnedGammaFailure = false;
|
|
26
|
+
let hasWarnedMagFailure = false;
|
|
27
|
+
let magInitialized = false;
|
|
28
|
+
let activeHueBackend = "none";
|
|
29
|
+
function clampScale(value) {
|
|
30
|
+
return Math.max(0.0, Math.min(1.0, value));
|
|
31
|
+
}
|
|
32
|
+
function buildGammaRamp(redScale, greenScale, blueScale) {
|
|
33
|
+
// 3 channels * 256 entries * 2 bytes per uint16
|
|
34
|
+
const ramp = Buffer.alloc(3 * 256 * 2);
|
|
35
|
+
const red = clampScale(redScale);
|
|
36
|
+
const green = clampScale(greenScale);
|
|
37
|
+
const blue = clampScale(blueScale);
|
|
38
|
+
for (let i = 0; i < 256; i++) {
|
|
39
|
+
const base = i * 257; // map 0..255 to 0..65535
|
|
40
|
+
const redValue = Math.max(0, Math.min(65535, Math.round(base * red)));
|
|
41
|
+
const greenValue = Math.max(0, Math.min(65535, Math.round(base * green)));
|
|
42
|
+
const blueValue = Math.max(0, Math.min(65535, Math.round(base * blue)));
|
|
43
|
+
ramp.writeUInt16LE(redValue, i * 2);
|
|
44
|
+
ramp.writeUInt16LE(greenValue, (256 + i) * 2);
|
|
45
|
+
ramp.writeUInt16LE(blueValue, (512 + i) * 2);
|
|
46
|
+
}
|
|
47
|
+
return ramp;
|
|
48
|
+
}
|
|
49
|
+
function buildColorEffectMatrix(mode) {
|
|
50
|
+
// MAGCOLOREFFECT is a 5x5 float matrix.
|
|
51
|
+
const matrix = Buffer.alloc(25 * 4);
|
|
52
|
+
const redScale = mode === "record" ? 1.0 : mode === "remove" ? 0.35 : 1.0;
|
|
53
|
+
const greenScale = mode === "record" ? 0.3 : mode === "remove" ? 1.0 : 1.0;
|
|
54
|
+
const blueScale = mode === "record" ? 0.18 : mode === "remove" ? 0.35 : 1.0;
|
|
55
|
+
const values = [
|
|
56
|
+
redScale,
|
|
57
|
+
0,
|
|
58
|
+
0,
|
|
59
|
+
0,
|
|
60
|
+
0,
|
|
61
|
+
0,
|
|
62
|
+
greenScale,
|
|
63
|
+
0,
|
|
64
|
+
0,
|
|
65
|
+
0,
|
|
66
|
+
0,
|
|
67
|
+
0,
|
|
68
|
+
blueScale,
|
|
69
|
+
0,
|
|
70
|
+
0,
|
|
71
|
+
0,
|
|
72
|
+
0,
|
|
73
|
+
0,
|
|
74
|
+
1,
|
|
75
|
+
0,
|
|
76
|
+
0,
|
|
77
|
+
0,
|
|
78
|
+
0,
|
|
79
|
+
0,
|
|
80
|
+
1,
|
|
81
|
+
];
|
|
82
|
+
for (let i = 0; i < values.length; i++) {
|
|
83
|
+
matrix.writeFloatLE(values[i], i * 4);
|
|
84
|
+
}
|
|
85
|
+
return matrix;
|
|
86
|
+
}
|
|
87
|
+
function tryApplyMagnifierHue(mode) {
|
|
88
|
+
if (!MagInitialize || !MagSetFullscreenColorEffect) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (!magInitialized) {
|
|
92
|
+
const initOk = Number(MagInitialize());
|
|
93
|
+
if (!initOk) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
magInitialized = true;
|
|
97
|
+
}
|
|
98
|
+
const matrix = buildColorEffectMatrix(mode);
|
|
99
|
+
const ok = Number(MagSetFullscreenColorEffect(matrix));
|
|
100
|
+
return ok !== 0;
|
|
101
|
+
}
|
|
102
|
+
export function setScreenHue(mode) {
|
|
103
|
+
const screenDc = GetDC(null);
|
|
104
|
+
if (!screenDc) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
if (mode !== "off") {
|
|
109
|
+
// Try requested tint first, then a milder fallback accepted by more drivers.
|
|
110
|
+
const primaryRamp = mode === "record"
|
|
111
|
+
? buildGammaRamp(1.0, 0.22, 0.35)
|
|
112
|
+
: buildGammaRamp(0.4, 1.0, 0.4);
|
|
113
|
+
let ok = Number(SetDeviceGammaRamp(screenDc, primaryRamp));
|
|
114
|
+
if (!ok) {
|
|
115
|
+
const fallbackRamp = mode === "record"
|
|
116
|
+
? buildGammaRamp(1.0, 0.58, 0.35)
|
|
117
|
+
: buildGammaRamp(0.65, 1.0, 0.65);
|
|
118
|
+
ok = Number(SetDeviceGammaRamp(screenDc, fallbackRamp));
|
|
119
|
+
}
|
|
120
|
+
if (ok) {
|
|
121
|
+
activeHueBackend = "gamma";
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!hasWarnedGammaFailure) {
|
|
125
|
+
hasWarnedGammaFailure = true;
|
|
126
|
+
console.warn("[warn] Gamma ramp unsupported on this display/driver, trying Magnification fallback.");
|
|
127
|
+
}
|
|
128
|
+
if (tryApplyMagnifierHue(mode)) {
|
|
129
|
+
activeHueBackend = "magnifier";
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
activeHueBackend = "none";
|
|
133
|
+
if (!hasWarnedMagFailure) {
|
|
134
|
+
hasWarnedMagFailure = true;
|
|
135
|
+
console.warn("[warn] Screen hue could not be applied on this system.");
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Turning hue off: clear whichever backend might have been used.
|
|
140
|
+
let cleared = false;
|
|
141
|
+
if (activeHueBackend === "magnifier") {
|
|
142
|
+
cleared = tryApplyMagnifierHue("off");
|
|
143
|
+
}
|
|
144
|
+
const neutralRamp = buildGammaRamp(1.0, 1.0, 1.0);
|
|
145
|
+
const gammaCleared = Number(SetDeviceGammaRamp(screenDc, neutralRamp)) !== 0;
|
|
146
|
+
cleared = cleared || gammaCleared;
|
|
147
|
+
if (activeHueBackend !== "magnifier") {
|
|
148
|
+
// Safe no-op when magnifier was never used; necessary when it was.
|
|
149
|
+
cleared = tryApplyMagnifierHue("off") || cleared;
|
|
150
|
+
}
|
|
151
|
+
if (cleared) {
|
|
152
|
+
activeHueBackend = "none";
|
|
153
|
+
}
|
|
154
|
+
else if (!hasWarnedMagFailure) {
|
|
155
|
+
hasWarnedMagFailure = true;
|
|
156
|
+
console.warn("[warn] Screen hue could not be reset on this system.");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
ReleaseDC(null, screenDc);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { AttachThreadInput, BringWindowToTop, GetForegroundWindow, GetCurrentThreadId, GetWindowThreadProcessId, GetWindowTextW, IsIconic, IsWindow, SetActiveWindow, SetForegroundWindow, ShowWindow, } from "./koffi-utils.js";
|
|
3
|
+
const SW_RESTORE = 9;
|
|
4
|
+
const SW_SHOW = 5;
|
|
5
|
+
function getWindowTitle(hwnd) {
|
|
6
|
+
const buf = Buffer.alloc(512);
|
|
7
|
+
const len = Number(GetWindowTextW(hwnd, buf, 256));
|
|
8
|
+
return buf.slice(0, len * 2).toString("utf16le");
|
|
9
|
+
}
|
|
10
|
+
export function getActiveWindowHandleAndName() {
|
|
11
|
+
const hwnd = GetForegroundWindow();
|
|
12
|
+
if (!hwnd) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
handle: hwnd,
|
|
17
|
+
name: getWindowTitle(hwnd),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function activateWindowByHandle(handle) {
|
|
21
|
+
if (!Number(IsWindow(handle))) {
|
|
22
|
+
return "missing";
|
|
23
|
+
}
|
|
24
|
+
if (Number(IsIconic(handle))) {
|
|
25
|
+
ShowWindow(handle, SW_RESTORE);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
ShowWindow(handle, SW_SHOW);
|
|
29
|
+
}
|
|
30
|
+
// Fast path for windows that can be foregrounded immediately.
|
|
31
|
+
if (Number(SetForegroundWindow(handle)) !== 0) {
|
|
32
|
+
return "activated";
|
|
33
|
+
}
|
|
34
|
+
const currentThreadId = Number(GetCurrentThreadId());
|
|
35
|
+
const foreground = GetForegroundWindow();
|
|
36
|
+
const foregroundThreadId = foreground
|
|
37
|
+
? Number(GetWindowThreadProcessId(foreground, null))
|
|
38
|
+
: 0;
|
|
39
|
+
const targetThreadId = Number(GetWindowThreadProcessId(handle, null));
|
|
40
|
+
const attachedThreadIds = [];
|
|
41
|
+
const tryAttach = (threadId) => {
|
|
42
|
+
if (threadId && threadId !== currentThreadId) {
|
|
43
|
+
const ok = Number(AttachThreadInput(currentThreadId, threadId, 1));
|
|
44
|
+
if (ok) {
|
|
45
|
+
attachedThreadIds.push(threadId);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
tryAttach(foregroundThreadId);
|
|
51
|
+
tryAttach(targetThreadId);
|
|
52
|
+
BringWindowToTop(handle);
|
|
53
|
+
SetActiveWindow(handle);
|
|
54
|
+
ShowWindow(handle, SW_SHOW);
|
|
55
|
+
if (Number(SetForegroundWindow(handle)) !== 0) {
|
|
56
|
+
return "activated";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
for (const threadId of attachedThreadIds) {
|
|
61
|
+
AttachThreadInput(currentThreadId, threadId, 0);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return "failed";
|
|
65
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "qdesk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/JakeBeaver/qdesk.git"
|
|
8
|
+
},
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/JakeBeaver/qdesk/issues"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/JakeBeaver/qdesk#readme",
|
|
13
|
+
"bin": {
|
|
14
|
+
"switcher": "dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"koffi": "^3.0.2"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.15.30",
|
|
24
|
+
"tsx": "^4.19.4",
|
|
25
|
+
"typescript": "^5.8.3"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"dev": "tsx src/index.ts",
|
|
29
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
30
|
+
"build": "pnpm clean && tsc -p tsconfig.json",
|
|
31
|
+
"start": "node dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
}
|