qdesk 1.0.11 → 1.0.13

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.
@@ -0,0 +1,70 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ const STORAGE_FILE = path.join(os.tmpdir(), "qdesk.bindings");
5
+ export class KeyBindings {
6
+ bindings = new Map();
7
+ constructor() {
8
+ const loaded = load();
9
+ for (const { chord, info } of loaded) {
10
+ this.bindings.set(chord, info);
11
+ }
12
+ }
13
+ get(chord) {
14
+ return this.bindings.get(chord);
15
+ }
16
+ set(chord, info) {
17
+ this.bindings.set(chord, info);
18
+ console.log(`[add] Bound ${chord} -> "${info.name}"`);
19
+ save(this.bindings);
20
+ }
21
+ reportMissing(chord) {
22
+ const info = this.bindings.get(chord);
23
+ if (info) {
24
+ info.missing = true;
25
+ }
26
+ }
27
+ delete(chord) {
28
+ const output = this.bindings.delete(chord);
29
+ if (output) {
30
+ console.log(`[remove] Unbound ${chord}`);
31
+ }
32
+ else {
33
+ console.log(`[remove] No binding exists for ${chord}`);
34
+ }
35
+ save(this.bindings);
36
+ return output;
37
+ }
38
+ get size() {
39
+ return this.bindings.size;
40
+ }
41
+ entries() {
42
+ return [...this.bindings.entries()].sort(([chordA], [chordB]) => chordA.localeCompare(chordB));
43
+ }
44
+ }
45
+ function load() {
46
+ try {
47
+ const raw = fs.readFileSync(STORAGE_FILE, "utf8");
48
+ const parsed = JSON.parse(raw);
49
+ return parsed.map(({ chord, info }) => ({
50
+ chord,
51
+ info: {
52
+ handle: BigInt(info.handle),
53
+ name: info.name,
54
+ },
55
+ }));
56
+ }
57
+ catch {
58
+ return [];
59
+ }
60
+ }
61
+ function save(bindings) {
62
+ const data = Array.from(bindings.entries()).map(([chord, info]) => ({
63
+ chord,
64
+ info: {
65
+ handle: info.handle.toString(),
66
+ name: info.name,
67
+ },
68
+ }));
69
+ fs.writeFileSync(STORAGE_FILE, JSON.stringify(data), "utf8");
70
+ }
package/dist/index.js CHANGED
@@ -1,77 +1,32 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
3
- import process from "node:process";
4
- import { normalizeChord, startListening } from "./chord-utils.js";
5
- import { setScreenHue } from "./screen-hue.js";
6
- import { activateWindowByHandle, getActiveWindowHandleAndName, } from "./window-utils.js";
7
- import { isAlreadyRunning } from "./process-utils.js";
8
- if (isAlreadyRunning()) {
9
- console.error("Another instance is already running. Exiting.");
10
- process.exit(1);
11
- }
12
- // Configure this chord at the top of file.
13
- const DEFAULT_ADD_CHORD = "win+ctrl+a";
14
- const DEFAULT_DROP_CHORD = "win+ctrl+d";
15
- function hasCliFlag(flag) {
16
- return process.argv.slice(2).includes(flag);
17
- }
18
- function getPackageVersion() {
19
- try {
20
- const packageJson = readFileSync(new URL("../package.json", import.meta.url), "utf8");
21
- const parsed = JSON.parse(packageJson);
22
- return parsed.version ?? "unknown";
23
- }
24
- catch {
25
- return "unknown";
26
- }
27
- }
28
- function setTerminalTitle(title) {
29
- process.title = title;
30
- if (process.stdout.isTTY) {
31
- process.stdout.write(`\u001b]0;${title}\u0007`);
32
- }
33
- }
34
- if (hasCliFlag("--version") || hasCliFlag("-v")) {
35
- console.log(getPackageVersion());
36
- process.exit(0);
37
- }
38
- setTerminalTitle("qdesk");
39
- function normalizeChordInput(chord) {
40
- return normalizeChord(chord.trim().toLowerCase());
41
- }
42
- function getCliArgValue(flag) {
43
- const args = process.argv.slice(2);
44
- const exact = args.find((arg) => arg.startsWith(`${flag}=`));
45
- if (exact) {
46
- return exact.slice(flag.length + 1);
47
- }
48
- const idx = args.indexOf(flag);
49
- if (idx >= 0 && idx + 1 < args.length) {
50
- return args[idx + 1];
51
- }
52
- return undefined;
53
- }
54
- const ADD_CHORD = normalizeChordInput(getCliArgValue("-a") ?? DEFAULT_ADD_CHORD);
55
- const DROP_CHORD = normalizeChordInput(getCliArgValue("-d") ?? DEFAULT_DROP_CHORD);
2
+ import { startListening } from "./koffi/chord-listener.js";
3
+ import { setScreenHue } from "./koffi/screen-hue.js";
4
+ import { activateWindowByHandle, checkIfWindowExists, getActiveWindowHandleAndName, } from "./koffi/window-utils.js";
5
+ import { ensureSingleProcess } from "./process/process-deduplication.js";
6
+ import { getCliArgStuff } from "./process/cli.js";
7
+ import { KeyBindings } from "./bank/key-bindings.js";
8
+ import { logInfo, logSwitch, logWarn } from "./log/log.js";
9
+ ensureSingleProcess();
10
+ const { ADD_CHORD, DROP_CHORD } = getCliArgStuff();
56
11
  if (ADD_CHORD === DROP_CHORD) {
57
- console.warn(`[warn] add and drop chords are identical (${ADD_CHORD}); behavior may conflict.`);
12
+ logWarn(`add and drop chords are identical (${ADD_CHORD}); behavior may conflict.`);
58
13
  }
59
- const bindings = new Map();
60
14
  let activeRecording;
61
15
  let awaitingChordRemoval = false;
62
16
  const toolWindowAtStartup = getActiveWindowHandleAndName() ?? undefined;
63
- console.log("[info] Tool window at startup:", toolWindowAtStartup?.name ?? "(unknown)");
64
- const listener = startListening({
17
+ logInfo(`Tool window at startup: ${toolWindowAtStartup?.name ?? "(unknown)"}`);
18
+ const bindings = new KeyBindings();
19
+ startListening({
65
20
  onEscape: (stopPropagating) => {
66
21
  if (!activeRecording && !awaitingChordRemoval) {
67
22
  return;
68
23
  }
69
24
  if (activeRecording) {
70
- console.log("[add] Canceled recording mode via Escape.");
25
+ logInfo("Canceled recording mode via Escape.");
71
26
  activeRecording = undefined;
72
27
  }
73
28
  if (awaitingChordRemoval) {
74
- console.log("[drop] Canceled removal mode via Escape.");
29
+ logInfo("Canceled removal mode via Escape.");
75
30
  awaitingChordRemoval = false;
76
31
  }
77
32
  setScreenHue("off");
@@ -82,11 +37,10 @@ const listener = startListening({
82
37
  // recording
83
38
  if (activeRecording) {
84
39
  if ([ADD_CHORD, DROP_CHORD].includes(chord)) {
85
- console.warn(`[warn] Ignoring ${chord} chord during active recording to avoid conflicts.`);
40
+ logWarn(`Ignoring ${chord} chord during active recording to avoid conflicts.`);
86
41
  return stopPropagating();
87
42
  }
88
43
  bindings.set(chord, activeRecording);
89
- console.log(`[add] Bound ${chord} -> "${activeRecording.name}"`);
90
44
  activeRecording = undefined;
91
45
  setScreenHue("off");
92
46
  printBindings();
@@ -96,12 +50,7 @@ const listener = startListening({
96
50
  if (awaitingChordRemoval) {
97
51
  awaitingChordRemoval = false;
98
52
  setScreenHue("off");
99
- if (bindings.delete(chord)) {
100
- console.log(`[drop] Removed binding for ${chord}`);
101
- }
102
- else {
103
- console.log(`[drop] No binding exists for ${chord}`);
104
- }
53
+ bindings.delete(chord);
105
54
  printBindings();
106
55
  return stopPropagating();
107
56
  }
@@ -111,10 +60,10 @@ const listener = startListening({
111
60
  setScreenHue("off");
112
61
  activeRecording = getActiveWindowHandleAndName();
113
62
  if (!activeRecording) {
114
- console.warn("[warn] No foreground window found to bind.");
63
+ logWarn("No foreground window found to bind.");
115
64
  }
116
65
  else {
117
- console.log(`[add] Armed for "${activeRecording.name}". Waiting for next chord...`);
66
+ logInfo(`Armed for "${activeRecording.name}". Waiting for next chord... (Esc to cancel)`);
118
67
  setScreenHue("record");
119
68
  }
120
69
  return stopPropagating();
@@ -129,7 +78,7 @@ const listener = startListening({
129
78
  if (toolWindowAtStartup) {
130
79
  activateWindowByHandle(toolWindowAtStartup.handle);
131
80
  }
132
- console.log("[drop] Armed. Press the chord you want to remove.");
81
+ logInfo("Armed for removal. Press the chord you want to remove. (Esc to cancel)");
133
82
  printBindings();
134
83
  setScreenHue("remove");
135
84
  return stopPropagating();
@@ -139,14 +88,30 @@ const listener = startListening({
139
88
  if (binding) {
140
89
  const result = activateWindowByHandle(binding.handle);
141
90
  if (result === "missing") {
142
- console.error(`[switch ${chord}] Window "${binding.name}" no longer exists, clearing binding.`);
143
- bindings.delete(chord);
91
+ logSwitch({
92
+ chord,
93
+ name: binding.name,
94
+ type: "missing",
95
+ });
96
+ bindings.reportMissing(chord);
97
+ setScreenHue("remove");
98
+ setTimeout(() => {
99
+ setScreenHue("off");
100
+ }, 50);
144
101
  }
145
102
  else if (result === "activated") {
146
- console.log(`[switch ${chord}] Switched to "${binding.name}"`);
103
+ logSwitch({
104
+ chord,
105
+ name: binding.name,
106
+ type: "success",
107
+ });
147
108
  }
148
109
  else {
149
- console.warn(`[switch ${chord}] Activation failed for "${binding.name}"`);
110
+ logSwitch({
111
+ chord,
112
+ name: binding.name,
113
+ type: "failed",
114
+ });
150
115
  }
151
116
  return stopPropagating();
152
117
  }
@@ -158,8 +123,9 @@ function printBindings() {
158
123
  console.log(" (none)");
159
124
  }
160
125
  else {
161
- for (const [chord, window] of bindings) {
162
- console.log(` ${chord} -> "${window.name}"`);
126
+ const maxBindingLength = Math.max(...Array.from(bindings.entries()).map(([chord]) => chord.length));
127
+ for (const [chord, window] of bindings.entries()) {
128
+ console.log(` ${chord.padEnd(maxBindingLength)} ->${window.missing ? "❓" : " "} "${window.name}"`);
163
129
  }
164
130
  }
165
131
  console.log("");
@@ -168,4 +134,11 @@ console.log("[qdesk] Starting...");
168
134
  console.log(` Adding a chord: ${ADD_CHORD}`);
169
135
  console.log(` Dropping a chord: ${DROP_CHORD}`);
170
136
  console.log(" CLI overrides: -a <combo>, -d <combo>");
171
- listener.runMessageLoop();
137
+ for (const [chord, window] of bindings.entries()) {
138
+ if (!checkIfWindowExists(window.handle)) {
139
+ bindings.reportMissing(chord);
140
+ }
141
+ }
142
+ if (bindings.size > 0) {
143
+ printBindings();
144
+ }
@@ -0,0 +1,94 @@
1
+ import { HC_ACTION, PM_REMOVE, VK_ESCAPE, WH_KEYBOARD_LL, WM_KEYDOWN, WM_SYSKEYDOWN, buildCurrentChord, normalizeChord, } from "./chord-utils.js";
2
+ import { registerKeyboardHookCallback, CallNextHookEx, decodeKeyboardHookLParam, GetModuleHandleW, SetWindowsHookExW, UnhookWindowsHookEx, unregisterCallback, PeekMessageW, TranslateMessage, DispatchMessageW, } from "./koffi-utils.js";
3
+ export function startListening(options) {
4
+ const activationNormalized = options.activationChord
5
+ ? normalizeChord(options.activationChord)
6
+ : null;
7
+ if (options.activationChord && !activationNormalized) {
8
+ console.error(`[error] Invalid activation chord: ${options.activationChord}`);
9
+ process.exit(1);
10
+ }
11
+ let keyboardHook = null;
12
+ let keyboardHookProcPtr = null;
13
+ let messagePump;
14
+ let isShuttingDown = false;
15
+ const callback = (nCode, wParam, lParam) => {
16
+ const passThrough = () => BigInt(Number(CallNextHookEx(keyboardHook, nCode, wParam, lParam)));
17
+ try {
18
+ if (nCode !== HC_ACTION) {
19
+ return passThrough();
20
+ }
21
+ const message = Number(wParam);
22
+ if (message !== WM_KEYDOWN && message !== WM_SYSKEYDOWN) {
23
+ return passThrough();
24
+ }
25
+ const kb = decodeKeyboardHookLParam(lParam);
26
+ const vkCode = Number(kb.vkCode);
27
+ let shouldStopPropagation = false;
28
+ if (vkCode === VK_ESCAPE) {
29
+ options.onEscape?.(() => {
30
+ shouldStopPropagation = true;
31
+ });
32
+ if (shouldStopPropagation) {
33
+ return 1n;
34
+ }
35
+ }
36
+ const combo = buildCurrentChord(vkCode);
37
+ if (!combo) {
38
+ return passThrough();
39
+ }
40
+ if (activationNormalized && combo === activationNormalized) {
41
+ options.onActivation?.(() => {
42
+ shouldStopPropagation = true;
43
+ });
44
+ }
45
+ options.onChord(combo, () => {
46
+ shouldStopPropagation = true;
47
+ });
48
+ if (shouldStopPropagation) {
49
+ return 1n;
50
+ }
51
+ return passThrough();
52
+ }
53
+ catch (error) {
54
+ console.error("[error] Keyboard hook callback failed:", error);
55
+ return passThrough();
56
+ }
57
+ };
58
+ keyboardHookProcPtr = registerKeyboardHookCallback(callback);
59
+ const moduleHandle = GetModuleHandleW(null);
60
+ keyboardHook = SetWindowsHookExW(WH_KEYBOARD_LL, keyboardHookProcPtr, moduleHandle, 0);
61
+ if (!keyboardHook) {
62
+ console.error("[error] SetWindowsHookExW failed.");
63
+ process.exit(1);
64
+ }
65
+ function shutdown() {
66
+ if (isShuttingDown) {
67
+ return;
68
+ }
69
+ isShuttingDown = true;
70
+ console.log("\n[qdesk] Shutting down...");
71
+ if (messagePump) {
72
+ clearInterval(messagePump);
73
+ messagePump = undefined;
74
+ }
75
+ if (keyboardHook) {
76
+ UnhookWindowsHookEx(keyboardHook);
77
+ keyboardHook = null;
78
+ }
79
+ if (keyboardHookProcPtr) {
80
+ unregisterCallback(keyboardHookProcPtr);
81
+ keyboardHookProcPtr = null;
82
+ }
83
+ process.exit(0);
84
+ }
85
+ process.on("SIGINT", shutdown);
86
+ process.on("SIGTERM", shutdown);
87
+ const msg = {};
88
+ messagePump = setInterval(() => {
89
+ while (Number(PeekMessageW(msg, null, 0, 0, PM_REMOVE)) !== 0) {
90
+ TranslateMessage(msg);
91
+ DispatchMessageW(msg);
92
+ }
93
+ }, 8);
94
+ }
@@ -0,0 +1,218 @@
1
+ import { GetAsyncKeyState } 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
+ export const VK_ESCAPE = 0x1b;
10
+ const VK_LWIN = 0x5b;
11
+ const VK_RWIN = 0x5c;
12
+ export const HC_ACTION = 0;
13
+ export const WH_KEYBOARD_LL = 13;
14
+ export const WM_KEYDOWN = 0x0100;
15
+ export const WM_SYSKEYDOWN = 0x0104;
16
+ export const PM_REMOVE = 0x0001;
17
+ const VK_TOKEN_TO_CODE = {
18
+ backspace: 0x08,
19
+ tab: 0x09,
20
+ clear: 0x0c,
21
+ enter: 0x0d,
22
+ return: 0x0d,
23
+ pause: 0x13,
24
+ capslock: 0x14,
25
+ esc: VK_ESCAPE,
26
+ escape: VK_ESCAPE,
27
+ space: 0x20,
28
+ pageup: 0x21,
29
+ pagedown: 0x22,
30
+ end: 0x23,
31
+ home: 0x24,
32
+ left: 0x25,
33
+ up: 0x26,
34
+ right: 0x27,
35
+ down: 0x28,
36
+ printscreen: 0x2c,
37
+ prtsc: 0x2c,
38
+ snapshot: 0x2c,
39
+ insert: 0x2d,
40
+ delete: 0x2e,
41
+ num0: 0x60,
42
+ num1: 0x61,
43
+ num2: 0x62,
44
+ num3: 0x63,
45
+ num4: 0x64,
46
+ num5: 0x65,
47
+ num6: 0x66,
48
+ num7: 0x67,
49
+ num8: 0x68,
50
+ num9: 0x69,
51
+ num_mul: 0x6a,
52
+ num_add: 0x6b,
53
+ num_sub: 0x6d,
54
+ num_dec: 0x6e,
55
+ num_div: 0x6f,
56
+ };
57
+ const VK_CODE_TO_TOKEN = new Map(Object.entries(VK_TOKEN_TO_CODE).map(([token, code]) => [code, token]));
58
+ VK_CODE_TO_TOKEN.set(VK_ESCAPE, "esc");
59
+ VK_CODE_TO_TOKEN.set(0x0d, "enter");
60
+ VK_CODE_TO_TOKEN.set(0x2c, "printscreen");
61
+ const MODIFIER_TOKENS = new Set([
62
+ "ctrl",
63
+ "control",
64
+ "alt",
65
+ "shift",
66
+ "win",
67
+ "meta",
68
+ ]);
69
+ function normalizeKeyToken(rawKey) {
70
+ const key = rawKey.trim().toLowerCase();
71
+ if (!key) {
72
+ return null;
73
+ }
74
+ if (/^[0-9a-z]$/.test(key)) {
75
+ return key;
76
+ }
77
+ if (/^f([1-9]|1[0-9]|2[0-4])$/.test(key)) {
78
+ return key;
79
+ }
80
+ if (VK_TOKEN_TO_CODE[key] !== undefined) {
81
+ return VK_CODE_TO_TOKEN.get(VK_TOKEN_TO_CODE[key]) ?? key;
82
+ }
83
+ return null;
84
+ }
85
+ function keyTokenToVk(key) {
86
+ if (/^[0-9]$/.test(key)) {
87
+ return key.charCodeAt(0);
88
+ }
89
+ if (/^[a-z]$/.test(key)) {
90
+ return key.toUpperCase().charCodeAt(0);
91
+ }
92
+ const functionMatch = /^f([1-9]|1[0-9]|2[0-4])$/.exec(key);
93
+ if (functionMatch) {
94
+ const fn = Number(functionMatch[1]);
95
+ return 0x70 + (fn - 1);
96
+ }
97
+ return VK_TOKEN_TO_CODE[key] ?? -1;
98
+ }
99
+ export function normalizeChord(chord) {
100
+ chord = chord.trim().toLowerCase();
101
+ const parts = chord
102
+ .split("+")
103
+ .map((part) => part.trim().toLowerCase())
104
+ .filter(Boolean);
105
+ if (parts.length === 0) {
106
+ return null;
107
+ }
108
+ const key = normalizeKeyToken(parts[parts.length - 1]);
109
+ if (!key) {
110
+ return null;
111
+ }
112
+ const modifiers = new Set(parts.slice(0, -1));
113
+ for (const mod of modifiers) {
114
+ if (!MODIFIER_TOKENS.has(mod)) {
115
+ return null;
116
+ }
117
+ }
118
+ const normalizedMods = [];
119
+ if (modifiers.has("ctrl") || modifiers.has("control")) {
120
+ normalizedMods.push("ctrl");
121
+ }
122
+ if (modifiers.has("alt")) {
123
+ normalizedMods.push("alt");
124
+ }
125
+ if (modifiers.has("shift")) {
126
+ normalizedMods.push("shift");
127
+ }
128
+ if (modifiers.has("win") || modifiers.has("meta")) {
129
+ normalizedMods.push("win");
130
+ }
131
+ return normalizedMods.length > 0 ? `${normalizedMods.join("+")}+${key}` : key;
132
+ }
133
+ export function parseHotkeyCombo(combo) {
134
+ const parts = combo
135
+ .split("+")
136
+ .map((part) => part.trim().toLowerCase())
137
+ .filter(Boolean);
138
+ if (parts.length === 0) {
139
+ return null;
140
+ }
141
+ const key = normalizeKeyToken(parts[parts.length - 1]);
142
+ if (!key) {
143
+ return null;
144
+ }
145
+ const modifiers = parts.slice(0, -1);
146
+ let modMask = 0;
147
+ const normalizedModifiers = [];
148
+ for (const mod of modifiers) {
149
+ if (mod === "alt") {
150
+ modMask |= MOD_ALT;
151
+ normalizedModifiers.push("alt");
152
+ }
153
+ else if (mod === "ctrl" || mod === "control") {
154
+ modMask |= MOD_CONTROL;
155
+ normalizedModifiers.push("ctrl");
156
+ }
157
+ else if (mod === "shift") {
158
+ modMask |= MOD_SHIFT;
159
+ normalizedModifiers.push("shift");
160
+ }
161
+ else if (mod === "win" || mod === "meta") {
162
+ modMask |= MOD_WIN;
163
+ normalizedModifiers.push("win");
164
+ }
165
+ else {
166
+ return null;
167
+ }
168
+ }
169
+ const vk = keyTokenToVk(key);
170
+ if (vk < 0) {
171
+ return null;
172
+ }
173
+ const canonicalModifiers = ["ctrl", "alt", "shift", "win"].filter((mod) => normalizedModifiers.includes(mod));
174
+ return {
175
+ modifiers: modMask,
176
+ vk,
177
+ normalized: `${canonicalModifiers.join("+")}+${key}`,
178
+ };
179
+ }
180
+ function isPressed(vk) {
181
+ return (Number(GetAsyncKeyState(vk)) & 0x8000) !== 0;
182
+ }
183
+ function vkToKeyToken(vkCode) {
184
+ if (vkCode >= 0x30 && vkCode <= 0x39) {
185
+ return String.fromCharCode(vkCode);
186
+ }
187
+ if (vkCode >= 0x41 && vkCode <= 0x5a) {
188
+ return String.fromCharCode(vkCode).toLowerCase();
189
+ }
190
+ if (vkCode >= 0x70 && vkCode <= 0x87) {
191
+ return `f${vkCode - 0x6f}`;
192
+ }
193
+ const named = VK_CODE_TO_TOKEN.get(vkCode);
194
+ if (named) {
195
+ return named;
196
+ }
197
+ return null;
198
+ }
199
+ export function buildCurrentChord(vkCode) {
200
+ const key = vkToKeyToken(vkCode);
201
+ if (!key) {
202
+ return null;
203
+ }
204
+ const parts = [];
205
+ if (isPressed(VK_CONTROL)) {
206
+ parts.push("ctrl");
207
+ }
208
+ if (isPressed(VK_MENU)) {
209
+ parts.push("alt");
210
+ }
211
+ if (isPressed(VK_SHIFT)) {
212
+ parts.push("shift");
213
+ }
214
+ if (isPressed(VK_LWIN) || isPressed(VK_RWIN)) {
215
+ parts.push("win");
216
+ }
217
+ return parts.length > 0 ? `${parts.join("+")}+${key}` : key;
218
+ }
@@ -22,14 +22,24 @@ const KBDLLHOOKSTRUCT = koffi.struct("KBDLLHOOKSTRUCT", {
22
22
  dwExtraInfo: "uint64",
23
23
  });
24
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");
25
+ export const user32 = koffi.load("user32.dll");
26
+ export const kernel32 = koffi.load("kernel32.dll");
27
+ export const gdi32 = koffi.load("gdi32.dll");
28
+ export const magnification = (() => {
29
+ try {
30
+ return koffi.load("Magnification.dll");
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ })();
27
36
  export const GetForegroundWindow = user32.func("GetForegroundWindow", HWND, []);
28
37
  export const SetForegroundWindow = user32.func("SetForegroundWindow", "int", [
29
38
  HWND,
30
39
  ]);
31
40
  export const IsWindow = user32.func("IsWindow", "int", [HWND]);
32
41
  export const IsIconic = user32.func("IsIconic", "int", [HWND]);
42
+ export const IsZoomed = user32.func("IsZoomed", "int", [HWND]);
33
43
  export const ShowWindow = user32.func("ShowWindow", "int", [HWND, "int"]);
34
44
  export const BringWindowToTop = user32.func("BringWindowToTop", "int", [HWND]);
35
45
  export const SetActiveWindow = user32.func("SetActiveWindow", HWND, [HWND]);
@@ -1,15 +1,6 @@
1
1
  import koffi from "koffi";
2
- import { HWND } from "./koffi-utils.js";
2
+ import { gdi32, HWND, magnification, user32 } from "./koffi-utils.js";
3
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
4
  const GetDC = user32.func("GetDC", HDC, [HWND]);
14
5
  const ReleaseDC = user32.func("ReleaseDC", "int", [HWND, HDC]);
15
6
  const SetDeviceGammaRamp = gdi32.func("SetDeviceGammaRamp", "int", [
@@ -1,7 +1,8 @@
1
1
  import { Buffer } from "node:buffer";
2
- import { AttachThreadInput, BringWindowToTop, GetForegroundWindow, GetCurrentThreadId, GetWindowThreadProcessId, GetWindowTextW, IsIconic, IsWindow, SetActiveWindow, SetForegroundWindow, ShowWindow, } from "./koffi-utils.js";
2
+ import { AttachThreadInput, BringWindowToTop, GetForegroundWindow, GetCurrentThreadId, GetWindowThreadProcessId, GetWindowTextW, IsIconic, IsZoomed, IsWindow, SetActiveWindow, SetForegroundWindow, ShowWindow, } from "./koffi-utils.js";
3
3
  const SW_RESTORE = 9;
4
4
  const SW_SHOW = 5;
5
+ const SW_SHOWMAXIMIZED = 3;
5
6
  function getWindowTitle(hwnd) {
6
7
  const buf = Buffer.alloc(512);
7
8
  const len = Number(GetWindowTextW(hwnd, buf, 256));
@@ -21,12 +22,18 @@ export function activateWindowByHandle(handle) {
21
22
  if (!Number(IsWindow(handle))) {
22
23
  return "missing";
23
24
  }
24
- if (Number(IsIconic(handle))) {
25
- ShowWindow(handle, SW_RESTORE);
26
- }
27
- else {
25
+ const showTargetWindow = () => {
26
+ if (Number(IsIconic(handle))) {
27
+ ShowWindow(handle, SW_RESTORE);
28
+ return;
29
+ }
30
+ if (Number(IsZoomed(handle))) {
31
+ ShowWindow(handle, SW_SHOWMAXIMIZED);
32
+ return;
33
+ }
28
34
  ShowWindow(handle, SW_SHOW);
29
- }
35
+ };
36
+ showTargetWindow();
30
37
  // Fast path for windows that can be foregrounded immediately.
31
38
  if (Number(SetForegroundWindow(handle)) !== 0) {
32
39
  return "activated";
@@ -51,7 +58,7 @@ export function activateWindowByHandle(handle) {
51
58
  tryAttach(targetThreadId);
52
59
  BringWindowToTop(handle);
53
60
  SetActiveWindow(handle);
54
- ShowWindow(handle, SW_SHOW);
61
+ showTargetWindow();
55
62
  if (Number(SetForegroundWindow(handle)) !== 0) {
56
63
  return "activated";
57
64
  }
@@ -63,3 +70,6 @@ export function activateWindowByHandle(handle) {
63
70
  }
64
71
  return "failed";
65
72
  }
73
+ export function checkIfWindowExists(handle) {
74
+ return Boolean(IsWindow(handle));
75
+ }
@@ -0,0 +1,13 @@
1
+ export function logSwitch({ chord, name, type, }) {
2
+ const status = type === "success" ? "✅" : type === "failed" ? "⚠️" : "❓";
3
+ console.log(`[switch ${chord}] ${status} "${name}"`);
4
+ }
5
+ export function logInfo(message) {
6
+ console.log(`[info] ${message}`);
7
+ }
8
+ export function logError(message) {
9
+ console.error(`[error] ${message}`);
10
+ }
11
+ export function logWarn(message) {
12
+ console.warn(`[warn] ${message}`);
13
+ }
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from "fs";
2
+ import { normalizeChord } from "../koffi/chord-utils.js";
3
+ const DEFAULT_ADD_CHORD = "win+ctrl+a";
4
+ const DEFAULT_DROP_CHORD = "win+ctrl+d";
5
+ export function getCliArgStuff() {
6
+ if (hasCliFlag("--version") || hasCliFlag("-v")) {
7
+ console.log(getPackageVersion());
8
+ process.exit(0);
9
+ }
10
+ setTerminalTitle("qdesk");
11
+ const ADD_CHORD = normalizeChord(getCliArgValue("-a") ?? DEFAULT_ADD_CHORD);
12
+ const DROP_CHORD = normalizeChord(getCliArgValue("-d") ?? DEFAULT_DROP_CHORD);
13
+ return { ADD_CHORD, DROP_CHORD };
14
+ }
15
+ function hasCliFlag(flag) {
16
+ return process.argv.slice(2).includes(flag);
17
+ }
18
+ function getPackageVersion() {
19
+ try {
20
+ const packageJson = readFileSync(new URL("../package.json", import.meta.url), "utf8");
21
+ const parsed = JSON.parse(packageJson);
22
+ return parsed.version ?? "unknown";
23
+ }
24
+ catch {
25
+ return "unknown";
26
+ }
27
+ }
28
+ function setTerminalTitle(title) {
29
+ process.title = title;
30
+ if (process.stdout.isTTY) {
31
+ process.stdout.write(`\u001b]0;${title}\u0007`);
32
+ }
33
+ }
34
+ function getCliArgValue(flag) {
35
+ const args = process.argv.slice(2);
36
+ const exact = args.find((arg) => arg.startsWith(`${flag}=`));
37
+ if (exact) {
38
+ return exact.slice(flag.length + 1);
39
+ }
40
+ const idx = args.indexOf(flag);
41
+ if (idx >= 0 && idx + 1 < args.length) {
42
+ return args[idx + 1];
43
+ }
44
+ return undefined;
45
+ }
@@ -0,0 +1,45 @@
1
+ import koffi from "koffi";
2
+ import { kernel32 } from "../koffi/koffi-utils.js";
3
+ const HANDLE = koffi.pointer("HANDLE", koffi.opaque());
4
+ const OpenProcess = kernel32.func("OpenProcess", HANDLE, [
5
+ "uint32",
6
+ "int",
7
+ "uint32",
8
+ ]);
9
+ const GetProcessTimes = kernel32.func("GetProcessTimes", "int", [
10
+ HANDLE,
11
+ "void *",
12
+ "void *",
13
+ "void *",
14
+ "void *",
15
+ ]);
16
+ const CloseHandle = kernel32.func("CloseHandle", "int", [HANDLE]);
17
+ const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
18
+ function readFileTime(buffer) {
19
+ const low = BigInt(buffer.readUInt32LE(0));
20
+ const high = BigInt(buffer.readUInt32LE(4));
21
+ return ((high << 32n) | low).toString();
22
+ }
23
+ export function getProcessCreationTime(pid) {
24
+ if (!Number.isInteger(pid) || pid <= 0) {
25
+ return null;
26
+ }
27
+ const processHandle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
28
+ if (!processHandle) {
29
+ return null;
30
+ }
31
+ const creationTime = Buffer.alloc(8);
32
+ const exitTime = Buffer.alloc(8);
33
+ const kernelTime = Buffer.alloc(8);
34
+ const userTime = Buffer.alloc(8);
35
+ try {
36
+ const ok = Number(GetProcessTimes(processHandle, creationTime, exitTime, kernelTime, userTime));
37
+ if (!ok) {
38
+ return null;
39
+ }
40
+ return readFileTime(creationTime);
41
+ }
42
+ finally {
43
+ CloseHandle(processHandle);
44
+ }
45
+ }
@@ -0,0 +1,26 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { getProcessCreationTime } from "./get-process-creation-time.js";
5
+ const LOCK_FILE = path.join(os.tmpdir(), "qdesk.lock");
6
+ export function isAlreadyRunning() {
7
+ try {
8
+ const lockInfo = JSON.parse(fs.readFileSync(LOCK_FILE, "utf8").trim());
9
+ process.kill(lockInfo.pid, 0);
10
+ return getProcessCreationTime(lockInfo.pid) === lockInfo.creationTime;
11
+ }
12
+ catch {
13
+ // stale lock, fall through
14
+ }
15
+ fs.writeFileSync(LOCK_FILE, JSON.stringify({
16
+ pid: process.pid,
17
+ creationTime: getProcessCreationTime(process.pid),
18
+ }));
19
+ return false;
20
+ }
21
+ export function ensureSingleProcess() {
22
+ if (isAlreadyRunning()) {
23
+ console.error("Another instance is already running. Exiting.");
24
+ process.exit(1);
25
+ }
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdesk",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,244 +0,0 @@
1
- import { CallNextHookEx, decodeKeyboardHookLParam, DispatchMessageW, GetAsyncKeyState, GetModuleHandleW, PeekMessageW, registerKeyboardHookCallback, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx, unregisterCallback, } from "./koffi-utils.js";
2
- const process = globalThis.process;
3
- const MOD_ALT = 0x0001;
4
- const MOD_CONTROL = 0x0002;
5
- const MOD_SHIFT = 0x0004;
6
- const MOD_WIN = 0x0008;
7
- const VK_SHIFT = 0x10;
8
- const VK_CONTROL = 0x11;
9
- const VK_MENU = 0x12;
10
- const VK_ESCAPE = 0x1b;
11
- const VK_LWIN = 0x5b;
12
- const VK_RWIN = 0x5c;
13
- const HC_ACTION = 0;
14
- const WH_KEYBOARD_LL = 13;
15
- const WM_KEYDOWN = 0x0100;
16
- const WM_SYSKEYDOWN = 0x0104;
17
- const PM_REMOVE = 0x0001;
18
- export function normalizeChord(chord) {
19
- const parts = chord
20
- .split("+")
21
- .map((part) => part.trim().toLowerCase())
22
- .filter(Boolean);
23
- if (parts.length < 2) {
24
- return null;
25
- }
26
- const key = parts[parts.length - 1];
27
- if (!/^[0-9a-z]$/.test(key) && !/^f([1-9]|1[0-9]|2[0-4])$/.test(key)) {
28
- return null;
29
- }
30
- const modifiers = new Set(parts.slice(0, -1));
31
- for (const mod of modifiers) {
32
- if (!["ctrl", "control", "alt", "shift", "win", "meta"].includes(mod)) {
33
- return null;
34
- }
35
- }
36
- const normalizedMods = [];
37
- if (modifiers.has("ctrl") || modifiers.has("control")) {
38
- normalizedMods.push("ctrl");
39
- }
40
- if (modifiers.has("alt")) {
41
- normalizedMods.push("alt");
42
- }
43
- if (modifiers.has("shift")) {
44
- normalizedMods.push("shift");
45
- }
46
- if (modifiers.has("win") || modifiers.has("meta")) {
47
- normalizedMods.push("win");
48
- }
49
- if (normalizedMods.length === 0) {
50
- return null;
51
- }
52
- return `${normalizedMods.join("+")}+${key}`;
53
- }
54
- function parseHotkeyCombo(combo) {
55
- const parts = combo
56
- .split("+")
57
- .map((part) => part.trim().toLowerCase())
58
- .filter(Boolean);
59
- if (parts.length < 2) {
60
- return null;
61
- }
62
- const key = parts[parts.length - 1];
63
- const modifiers = parts.slice(0, -1);
64
- let modMask = 0;
65
- const normalizedModifiers = [];
66
- for (const mod of modifiers) {
67
- if (mod === "alt") {
68
- modMask |= MOD_ALT;
69
- normalizedModifiers.push("alt");
70
- }
71
- else if (mod === "ctrl" || mod === "control") {
72
- modMask |= MOD_CONTROL;
73
- normalizedModifiers.push("ctrl");
74
- }
75
- else if (mod === "shift") {
76
- modMask |= MOD_SHIFT;
77
- normalizedModifiers.push("shift");
78
- }
79
- else if (mod === "win" || mod === "meta") {
80
- modMask |= MOD_WIN;
81
- normalizedModifiers.push("win");
82
- }
83
- else {
84
- return null;
85
- }
86
- }
87
- let vk = -1;
88
- if (/^[0-9]$/.test(key)) {
89
- vk = key.charCodeAt(0);
90
- }
91
- else if (/^[a-z]$/.test(key)) {
92
- vk = key.toUpperCase().charCodeAt(0);
93
- }
94
- else {
95
- const functionMatch = /^f([1-9]|1[0-9]|2[0-4])$/.exec(key);
96
- if (functionMatch) {
97
- const fn = Number(functionMatch[1]);
98
- vk = 0x70 + (fn - 1);
99
- }
100
- }
101
- if (vk < 0 || modMask === 0) {
102
- return null;
103
- }
104
- const canonicalModifiers = ["ctrl", "alt", "shift", "win"].filter((mod) => normalizedModifiers.includes(mod));
105
- return {
106
- modifiers: modMask,
107
- vk,
108
- normalized: `${canonicalModifiers.join("+")}+${key}`,
109
- };
110
- }
111
- function isPressed(vk) {
112
- return (Number(GetAsyncKeyState(vk)) & 0x8000) !== 0;
113
- }
114
- function vkToKeyToken(vkCode) {
115
- if (vkCode >= 0x30 && vkCode <= 0x39) {
116
- return String.fromCharCode(vkCode);
117
- }
118
- if (vkCode >= 0x41 && vkCode <= 0x5a) {
119
- return String.fromCharCode(vkCode).toLowerCase();
120
- }
121
- if (vkCode >= 0x70 && vkCode <= 0x87) {
122
- return `f${vkCode - 0x6f}`;
123
- }
124
- return null;
125
- }
126
- function buildCurrentChord(vkCode) {
127
- const key = vkToKeyToken(vkCode);
128
- if (!key) {
129
- return null;
130
- }
131
- const parts = [];
132
- if (isPressed(VK_CONTROL)) {
133
- parts.push("ctrl");
134
- }
135
- if (isPressed(VK_MENU)) {
136
- parts.push("alt");
137
- }
138
- if (isPressed(VK_SHIFT)) {
139
- parts.push("shift");
140
- }
141
- if (isPressed(VK_LWIN) || isPressed(VK_RWIN)) {
142
- parts.push("win");
143
- }
144
- if (parts.length === 0) {
145
- return null;
146
- }
147
- return `${parts.join("+")}+${key}`;
148
- }
149
- export function startListening(options) {
150
- const activationNormalized = options.activationChord
151
- ? normalizeChord(options.activationChord)
152
- : null;
153
- if (options.activationChord && !activationNormalized) {
154
- console.error(`[error] Invalid activation chord: ${options.activationChord}`);
155
- process.exit(1);
156
- }
157
- let keyboardHook = null;
158
- let keyboardHookProcPtr = null;
159
- let messagePump;
160
- let isShuttingDown = false;
161
- const callback = (nCode, wParam, lParam) => {
162
- const passThrough = () => BigInt(Number(CallNextHookEx(keyboardHook, nCode, wParam, lParam)));
163
- try {
164
- if (nCode !== HC_ACTION) {
165
- return passThrough();
166
- }
167
- const message = Number(wParam);
168
- if (message !== WM_KEYDOWN && message !== WM_SYSKEYDOWN) {
169
- return passThrough();
170
- }
171
- const kb = decodeKeyboardHookLParam(lParam);
172
- const vkCode = Number(kb.vkCode);
173
- let shouldStopPropagation = false;
174
- if (vkCode === VK_ESCAPE) {
175
- options.onEscape?.(() => {
176
- shouldStopPropagation = true;
177
- });
178
- if (shouldStopPropagation) {
179
- return 1n;
180
- }
181
- }
182
- const combo = buildCurrentChord(vkCode);
183
- if (!combo) {
184
- return passThrough();
185
- }
186
- if (activationNormalized && combo === activationNormalized) {
187
- options.onActivation?.(() => {
188
- shouldStopPropagation = true;
189
- });
190
- }
191
- options.onChord(combo, () => {
192
- shouldStopPropagation = true;
193
- });
194
- if (shouldStopPropagation) {
195
- return 1n;
196
- }
197
- return passThrough();
198
- }
199
- catch (error) {
200
- console.error("[error] Keyboard hook callback failed:", error);
201
- return passThrough();
202
- }
203
- };
204
- keyboardHookProcPtr = registerKeyboardHookCallback(callback);
205
- const moduleHandle = GetModuleHandleW(null);
206
- keyboardHook = SetWindowsHookExW(WH_KEYBOARD_LL, keyboardHookProcPtr, moduleHandle, 0);
207
- if (!keyboardHook) {
208
- console.error("[error] SetWindowsHookExW failed.");
209
- process.exit(1);
210
- }
211
- function shutdown() {
212
- if (isShuttingDown) {
213
- return;
214
- }
215
- isShuttingDown = true;
216
- console.log("\n[qdesk] Shutting down...");
217
- if (messagePump) {
218
- clearInterval(messagePump);
219
- messagePump = undefined;
220
- }
221
- if (keyboardHook) {
222
- UnhookWindowsHookEx(keyboardHook);
223
- keyboardHook = null;
224
- }
225
- if (keyboardHookProcPtr) {
226
- unregisterCallback(keyboardHookProcPtr);
227
- keyboardHookProcPtr = null;
228
- }
229
- process.exit(0);
230
- }
231
- process.on("SIGINT", shutdown);
232
- process.on("SIGTERM", shutdown);
233
- return {
234
- runMessageLoop: () => {
235
- const msg = {};
236
- messagePump = setInterval(() => {
237
- while (Number(PeekMessageW(msg, null, 0, 0, PM_REMOVE)) !== 0) {
238
- TranslateMessage(msg);
239
- DispatchMessageW(msg);
240
- }
241
- }, 8);
242
- },
243
- };
244
- }
@@ -1,18 +0,0 @@
1
- import fs from "fs";
2
- import os from "os";
3
- import path from "path";
4
- const LOCK_FILE = path.join(os.tmpdir(), "qdesk.lock");
5
- export function isAlreadyRunning() {
6
- if (fs.existsSync(LOCK_FILE)) {
7
- const pid = parseInt(fs.readFileSync(LOCK_FILE, "utf8").trim());
8
- try {
9
- process.kill(pid, 0);
10
- return true;
11
- }
12
- catch {
13
- // stale lock, fall through
14
- }
15
- }
16
- fs.writeFileSync(LOCK_FILE, String(process.pid));
17
- return false;
18
- }