qdesk 1.0.10 → 1.0.12

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,64 @@
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
+ delete(chord) {
22
+ const output = this.bindings.delete(chord);
23
+ if (output) {
24
+ console.log(`[remove] Unbound ${chord}`);
25
+ }
26
+ else {
27
+ console.log(`[remove] No binding exists for ${chord}`);
28
+ }
29
+ save(this.bindings);
30
+ return output;
31
+ }
32
+ get size() {
33
+ return this.bindings.size;
34
+ }
35
+ entries() {
36
+ return this.bindings.entries();
37
+ }
38
+ }
39
+ function load() {
40
+ try {
41
+ const raw = fs.readFileSync(STORAGE_FILE, "utf8");
42
+ const parsed = JSON.parse(raw);
43
+ return parsed.map(({ chord, info }) => ({
44
+ chord,
45
+ info: {
46
+ handle: BigInt(info.handle),
47
+ name: info.name,
48
+ },
49
+ }));
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ }
55
+ function save(bindings) {
56
+ const data = Array.from(bindings.entries()).map(([chord, info]) => ({
57
+ chord,
58
+ info: {
59
+ handle: info.handle.toString(),
60
+ name: info.name,
61
+ },
62
+ }));
63
+ fs.writeFileSync(STORAGE_FILE, JSON.stringify(data), "utf8");
64
+ }
package/dist/index.js CHANGED
@@ -1,72 +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
- // Configure this chord at the top of file.
8
- const DEFAULT_ADD_CHORD = "win+ctrl+a";
9
- const DEFAULT_DROP_CHORD = "win+ctrl+d";
10
- function hasCliFlag(flag) {
11
- return process.argv.slice(2).includes(flag);
12
- }
13
- function getPackageVersion() {
14
- try {
15
- const packageJson = readFileSync(new URL("../package.json", import.meta.url), "utf8");
16
- const parsed = JSON.parse(packageJson);
17
- return parsed.version ?? "unknown";
18
- }
19
- catch {
20
- return "unknown";
21
- }
22
- }
23
- function setTerminalTitle(title) {
24
- process.title = title;
25
- if (process.stdout.isTTY) {
26
- process.stdout.write(`\u001b]0;${title}\u0007`);
27
- }
28
- }
29
- if (hasCliFlag("--version") || hasCliFlag("-v")) {
30
- console.log(getPackageVersion());
31
- process.exit(0);
32
- }
33
- setTerminalTitle("qdesk");
34
- function normalizeChordInput(chord) {
35
- return normalizeChord(chord.trim().toLowerCase());
36
- }
37
- function getCliArgValue(flag) {
38
- const args = process.argv.slice(2);
39
- const exact = args.find((arg) => arg.startsWith(`${flag}=`));
40
- if (exact) {
41
- return exact.slice(flag.length + 1);
42
- }
43
- const idx = args.indexOf(flag);
44
- if (idx >= 0 && idx + 1 < args.length) {
45
- return args[idx + 1];
46
- }
47
- return undefined;
48
- }
49
- const ADD_CHORD = normalizeChordInput(getCliArgValue("-a") ?? DEFAULT_ADD_CHORD);
50
- 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();
51
11
  if (ADD_CHORD === DROP_CHORD) {
52
- 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.`);
53
13
  }
54
- const bindings = new Map();
55
14
  let activeRecording;
56
15
  let awaitingChordRemoval = false;
57
16
  const toolWindowAtStartup = getActiveWindowHandleAndName() ?? undefined;
58
- console.log("[info] Tool window at startup:", toolWindowAtStartup?.name ?? "(unknown)");
59
- const listener = startListening({
17
+ logInfo(`Tool window at startup: ${toolWindowAtStartup?.name ?? "(unknown)"}`);
18
+ const bindings = new KeyBindings();
19
+ startListening({
60
20
  onEscape: (stopPropagating) => {
61
21
  if (!activeRecording && !awaitingChordRemoval) {
62
22
  return;
63
23
  }
64
24
  if (activeRecording) {
65
- console.log("[add] Canceled recording mode via Escape.");
25
+ logInfo("Canceled recording mode via Escape.");
66
26
  activeRecording = undefined;
67
27
  }
68
28
  if (awaitingChordRemoval) {
69
- console.log("[drop] Canceled removal mode via Escape.");
29
+ logInfo("Canceled removal mode via Escape.");
70
30
  awaitingChordRemoval = false;
71
31
  }
72
32
  setScreenHue("off");
@@ -77,11 +37,10 @@ const listener = startListening({
77
37
  // recording
78
38
  if (activeRecording) {
79
39
  if ([ADD_CHORD, DROP_CHORD].includes(chord)) {
80
- console.warn(`[warn] Ignoring ${chord} chord during active recording to avoid conflicts.`);
40
+ logWarn(`Ignoring ${chord} chord during active recording to avoid conflicts.`);
81
41
  return stopPropagating();
82
42
  }
83
43
  bindings.set(chord, activeRecording);
84
- console.log(`[add] Bound ${chord} -> "${activeRecording.name}"`);
85
44
  activeRecording = undefined;
86
45
  setScreenHue("off");
87
46
  printBindings();
@@ -91,12 +50,7 @@ const listener = startListening({
91
50
  if (awaitingChordRemoval) {
92
51
  awaitingChordRemoval = false;
93
52
  setScreenHue("off");
94
- if (bindings.delete(chord)) {
95
- console.log(`[drop] Removed binding for ${chord}`);
96
- }
97
- else {
98
- console.log(`[drop] No binding exists for ${chord}`);
99
- }
53
+ bindings.delete(chord);
100
54
  printBindings();
101
55
  return stopPropagating();
102
56
  }
@@ -106,10 +60,10 @@ const listener = startListening({
106
60
  setScreenHue("off");
107
61
  activeRecording = getActiveWindowHandleAndName();
108
62
  if (!activeRecording) {
109
- console.warn("[warn] No foreground window found to bind.");
63
+ logWarn("No foreground window found to bind.");
110
64
  }
111
65
  else {
112
- console.log(`[add] Armed for "${activeRecording.name}". Waiting for next chord...`);
66
+ logInfo(`Armed for "${activeRecording.name}". Waiting for next chord... (Esc to cancel)`);
113
67
  setScreenHue("record");
114
68
  }
115
69
  return stopPropagating();
@@ -124,7 +78,7 @@ const listener = startListening({
124
78
  if (toolWindowAtStartup) {
125
79
  activateWindowByHandle(toolWindowAtStartup.handle);
126
80
  }
127
- 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)");
128
82
  printBindings();
129
83
  setScreenHue("remove");
130
84
  return stopPropagating();
@@ -134,14 +88,26 @@ const listener = startListening({
134
88
  if (binding) {
135
89
  const result = activateWindowByHandle(binding.handle);
136
90
  if (result === "missing") {
137
- console.error(`[switch ${chord}] Window "${binding.name}" no longer exists, clearing binding.`);
91
+ logSwitch({
92
+ chord,
93
+ name: binding.name,
94
+ type: "missing",
95
+ });
138
96
  bindings.delete(chord);
139
97
  }
140
98
  else if (result === "activated") {
141
- console.log(`[switch ${chord}] Switched to "${binding.name}"`);
99
+ logSwitch({
100
+ chord,
101
+ name: binding.name,
102
+ type: "success",
103
+ });
142
104
  }
143
105
  else {
144
- console.warn(`[switch ${chord}] Activation failed for "${binding.name}"`);
106
+ logSwitch({
107
+ chord,
108
+ name: binding.name,
109
+ type: "failed",
110
+ });
145
111
  }
146
112
  return stopPropagating();
147
113
  }
@@ -153,7 +119,7 @@ function printBindings() {
153
119
  console.log(" (none)");
154
120
  }
155
121
  else {
156
- for (const [chord, window] of bindings) {
122
+ for (const [chord, window] of bindings.entries()) {
157
123
  console.log(` ${chord} -> "${window.name}"`);
158
124
  }
159
125
  }
@@ -163,4 +129,11 @@ console.log("[qdesk] Starting...");
163
129
  console.log(` Adding a chord: ${ADD_CHORD}`);
164
130
  console.log(` Dropping a chord: ${DROP_CHORD}`);
165
131
  console.log(" CLI overrides: -a <combo>, -d <combo>");
166
- listener.runMessageLoop();
132
+ for (const [chord, window] of bindings.entries()) {
133
+ if (!checkIfWindowExists(window.handle)) {
134
+ bindings.delete(chord);
135
+ }
136
+ }
137
+ if (bindings.size > 0) {
138
+ printBindings();
139
+ }
@@ -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,148 @@
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
+ export function normalizeChord(chord) {
18
+ chord = chord.trim().toLowerCase();
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
+ export 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
+ export 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
+ }
@@ -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.10",
3
+ "version": "1.0.12",
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
- }