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 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
+ }