@wobuxcm/dsh-win-tools 0.0.0-stage → 0.2.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.
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Win32 native bindings for dsh-cua-win.
3
+ *
4
+ * One module, one koffi context, loaded lazily and at most once. Everything
5
+ * the rest of the plugin needs from user32/gdi32/kernel32 is declared here
6
+ * behind small JS wrappers, so no other file ever touches koffi directly and
7
+ * a koffi failure degrades to "plugin reports missing native layer" instead
8
+ * of an activation crash.
9
+ *
10
+ * DPI: SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2) is called at init
11
+ * (falling back to SetProcessDPIAware on old systems). Electron hosts are
12
+ * already per-monitor aware, so this is a defensive no-op there — but a bare
13
+ * Node host gets correct physical-pixel coordinates either way. Without it,
14
+ * GetSystemMetrics/GetWindowRect lie by the scale factor (e.g. ~2x off at
15
+ * 150% on 2880x1800).
16
+ *
17
+ * @module dsh-cua-win/lib/native
18
+ */
19
+ import koffi from 'koffi'
20
+
21
+ /** All GDI objects created by this module are deleted eagerly; leaks here hang sessions. */
22
+ let state = null
23
+
24
+ /** Win32 constants used by the wrappers. */
25
+ export const CONST = {
26
+ SM_CXSCREEN: 0,
27
+ SM_CYSCREEN: 1,
28
+ SM_XVIRTUALSCREEN: 76,
29
+ SM_YVIRTUALSCREEN: 77,
30
+ SM_CXVIRTUALSCREEN: 78,
31
+ SM_CYVIRTUALSCREEN: 79,
32
+ SRCCOPY: 0x00cc0020,
33
+ SW_RESTORE: 9,
34
+ SW_SHOW: 5,
35
+ GA_ROOT: 2,
36
+ PROCESS_QUERY_LIMITED_INFORMATION: 0x1000,
37
+ // mouse flags
38
+ MOUSEEVENTF_MOVE: 0x0001,
39
+ MOUSEEVENTF_LEFTDOWN: 0x0002,
40
+ MOUSEEVENTF_LEFTUP: 0x0004,
41
+ MOUSEEVENTF_RIGHTDOWN: 0x0008,
42
+ MOUSEEVENTF_RIGHTUP: 0x0010,
43
+ MOUSEEVENTF_MIDDLEDOWN: 0x0020,
44
+ MOUSEEVENTF_MIDDLEUP: 0x0040,
45
+ MOUSEEVENTF_XDOWN: 0x0080,
46
+ MOUSEEVENTF_XUP: 0x0100,
47
+ MOUSEEVENTF_WHEEL: 0x0800,
48
+ MOUSEEVENTF_HWHEEL: 0x1000,
49
+ MOUSEEVENTF_MOVE_NOCOALESCE: 0x2000,
50
+ MOUSEEVENTF_VIRTUALDESK: 0x4000,
51
+ MOUSEEVENTF_ABSOLUTE: 0x8000,
52
+ // keyboard flags
53
+ KEYEVENTF_EXTENDEDKEY: 0x0001,
54
+ KEYEVENTF_KEYUP: 0x0002,
55
+ KEYEVENTF_UNICODE: 0x0004,
56
+ // input types
57
+ INPUT_MOUSE: 0,
58
+ INPUT_KEYBOARD: 1,
59
+ WHEEL_DELTA: 120,
60
+ }
61
+
62
+ /**
63
+ * Declare every native function once and wrap it behind plain JS.
64
+ * @returns the native state object.
65
+ */
66
+ function init() {
67
+ const user32 = koffi.load('user32.dll')
68
+ const gdi32 = koffi.load('gdi32.dll')
69
+ const kernel32 = koffi.load('kernel32.dll')
70
+
71
+ const RECT = koffi.struct('CUA_RECT', { left: 'long', top: 'long', right: 'long', bottom: 'long' })
72
+ const POINT = koffi.struct('CUA_POINT', { x: 'long', y: 'long' })
73
+ const MOUSEINPUT = koffi.struct('CUA_MOUSEINPUT', { dx: 'long', dy: 'long', mouseData: 'uint32', dwFlags: 'uint32', time: 'uint32', dwExtraInfo: 'uintptr' })
74
+ const KEYBDINPUT = koffi.struct('CUA_KEYBDINPUT', { wVk: 'uint16', wScan: 'uint16', dwFlags: 'uint32', time: 'uint32', dwExtraInfo: 'uintptr' })
75
+ const HARDWAREINPUT = koffi.struct('CUA_HARDWAREINPUT', { uMsg: 'uint32', wParamL: 'uint16', wParamH: 'uint16' })
76
+ const INPUT_UNION = koffi.union('CUA_INPUT_UNION', { mi: MOUSEINPUT, ki: KEYBDINPUT, hi: HARDWAREINPUT })
77
+ const INPUT = koffi.struct('CUA_INPUT', { type: 'uint32', u: INPUT_UNION })
78
+ const BITMAPINFOHEADER = koffi.struct('CUA_BITMAPINFOHEADER', {
79
+ biSize: 'uint32', biWidth: 'long', biHeight: 'long', biPlanes: 'uint16', biBitCount: 'uint16',
80
+ biCompression: 'uint32', biSizeImage: 'uint32', biXPelsPerMeter: 'long', biYPelsPerMeter: 'long',
81
+ biClrUsed: 'uint32', biClrImportant: 'uint32',
82
+ })
83
+ const EnumWindowsCb = koffi.proto('bool __stdcall CUA_EnumWindowsCb(intptr hwnd, intptr lparam)')
84
+
85
+ // ---- DPI awareness: must happen before any coordinate API is trusted.
86
+ let dpiAware = false
87
+ try {
88
+ const SetProcessDpiAwarenessContext = user32.func('bool __stdcall SetProcessDpiAwarenessContext(intptr value)')
89
+ dpiAware = SetProcessDpiAwarenessContext(-4) // PER_MONITOR_AWARE_V2
90
+ } catch {
91
+ dpiAware = false
92
+ }
93
+ if (!dpiAware) {
94
+ try {
95
+ user32.func('bool __stdcall SetProcessDPIAware()')()
96
+ dpiAware = true
97
+ } catch {
98
+ dpiAware = false
99
+ }
100
+ }
101
+
102
+ const fn = {
103
+ GetSystemMetrics: user32.func('int __stdcall GetSystemMetrics(int index)'),
104
+ GetDpiForSystem: user32.func('uint32 __stdcall GetDpiForSystem()'),
105
+ GetForegroundWindow: user32.func('intptr __stdcall GetForegroundWindow()'),
106
+ GetWindowTextW: user32.func('int __stdcall GetWindowTextW(intptr hWnd, _Out_ uint16_t *buf, int max)'),
107
+ GetWindowTextLengthW: user32.func('int __stdcall GetWindowTextLengthW(intptr hWnd)'),
108
+ GetClassNameW: user32.func('int __stdcall GetClassNameW(intptr hWnd, _Out_ uint16_t *buf, int max)'),
109
+ GetWindowRect: user32.func('bool __stdcall GetWindowRect(intptr hWnd, _Out_ CUA_RECT *rect)'),
110
+ GetClientRect: user32.func('bool __stdcall GetClientRect(intptr hWnd, _Out_ CUA_RECT *rect)'),
111
+ ClientToScreen: user32.func('bool __stdcall ClientToScreen(intptr hWnd, _Inout_ CUA_POINT *pt)'),
112
+ IsWindowVisible: user32.func('bool __stdcall IsWindowVisible(intptr)'),
113
+ IsIconic: user32.func('bool __stdcall IsIconic(intptr)'),
114
+ IsWindow: user32.func('bool __stdcall IsWindow(intptr)'),
115
+ EnumWindows: user32.func('bool __stdcall EnumWindows(CUA_EnumWindowsCb *cb, intptr lparam)'),
116
+ GetWindowThreadProcessId: user32.func('uint32 __stdcall GetWindowThreadProcessId(intptr hWnd, _Out_ uint32 *pid)'),
117
+ GetAncestor: user32.func('intptr __stdcall GetAncestor(intptr hwnd, uint32 gaFlags)'),
118
+ GetCursorPos: user32.func('bool __stdcall GetCursorPos(_Out_ CUA_POINT *pt)'),
119
+ WindowFromPoint: user32.func('intptr __stdcall WindowFromPoint(CUA_POINT pt)'),
120
+ SetCursorPos: user32.func('bool __stdcall SetCursorPos(int x, int y)'),
121
+ SendInput: user32.func('uint32 __stdcall SendInput(uint32 n, CUA_INPUT *inputs, int size)'),
122
+ SetForegroundWindow: user32.func('bool __stdcall SetForegroundWindow(intptr)'),
123
+ BringWindowToTop: user32.func('bool __stdcall BringWindowToTop(intptr)'),
124
+ ShowWindow: user32.func('bool __stdcall ShowWindow(intptr, int)'),
125
+ AttachThreadInput: user32.func('bool __stdcall AttachThreadInput(uint32 from, uint32 to, bool attach)'),
126
+ GetDC: user32.func('intptr __stdcall GetDC(intptr)'),
127
+ ReleaseDC: user32.func('int __stdcall ReleaseDC(intptr, intptr)'),
128
+ CreateCompatibleDC: gdi32.func('intptr __stdcall CreateCompatibleDC(intptr)'),
129
+ CreateCompatibleBitmap: gdi32.func('intptr __stdcall CreateCompatibleBitmap(intptr, int, int)'),
130
+ SelectObject: gdi32.func('intptr __stdcall SelectObject(intptr, intptr)'),
131
+ DeleteDC: gdi32.func('bool __stdcall DeleteDC(intptr)'),
132
+ DeleteObject: gdi32.func('bool __stdcall DeleteObject(intptr)'),
133
+ BitBlt: gdi32.func('bool __stdcall BitBlt(intptr, int, int, int, int, intptr, int, int, uint32)'),
134
+ GetDIBits: gdi32.func('int __stdcall GetDIBits(intptr hdc, intptr hbm, uint32 start, uint32 lines, _Out_ uint8_t *bits, _Inout_ CUA_BITMAPINFOHEADER *bmi, uint32 usage)'),
135
+ GetCurrentThreadId: kernel32.func('uint32 __stdcall GetCurrentThreadId()'),
136
+ OpenProcess: kernel32.func('intptr __stdcall OpenProcess(uint32 access, bool inherit, uint32 pid)'),
137
+ CloseHandle: kernel32.func('bool __stdcall CloseHandle(intptr h)'),
138
+ QueryFullProcessImageNameW: kernel32.func('bool __stdcall QueryFullProcessImageNameW(intptr h, uint32 flags, _Out_ uint16_t *name, _Inout_ uint32 *size)'),
139
+ }
140
+
141
+ return { koffi, RECT, POINT, INPUT, BITMAPINFOHEADER, fn, dpiAware, INPUT_SIZE: koffi.sizeof(INPUT) }
142
+ }
143
+
144
+ /**
145
+ * Get (and lazily create) the native binding state.
146
+ * @returns the state object, or null when koffi is unusable on this machine.
147
+ */
148
+ export function native() {
149
+ if (state !== null) return state
150
+ try {
151
+ state = init()
152
+ } catch (error) {
153
+ console.warn(`[dsh-cua-win] native init failed: ${error?.message ?? error}`)
154
+ state = { failed: String(error?.message ?? error) }
155
+ }
156
+ return state
157
+ }
158
+
159
+ /** @returns {boolean} whether the native layer loaded. */
160
+ export function nativeReady() {
161
+ const s = native()
162
+ return s !== null && s.failed === undefined
163
+ }
164
+
165
+ // ---------------------------------------------------------------- wrappers
166
+
167
+ /**
168
+ * Read one window title (UTF-16 safe).
169
+ * @param {bigint|number} hwnd
170
+ * @returns {string}
171
+ */
172
+ export function windowTitle(hwnd) {
173
+ const { fn } = native()
174
+ const len = fn.GetWindowTextLengthW(hwnd)
175
+ if (len <= 0) return ''
176
+ const buf = Buffer.alloc((len + 1) * 2)
177
+ const n = fn.GetWindowTextW(hwnd, buf, len + 1)
178
+ return buf.toString('utf16le', 0, n * 2)
179
+ }
180
+
181
+ /**
182
+ * Read one window class name.
183
+ * @param {bigint|number} hwnd
184
+ * @returns {string}
185
+ */
186
+ export function windowClass(hwnd) {
187
+ const { fn } = native()
188
+ const buf = Buffer.alloc(512)
189
+ const n = fn.GetClassNameW(hwnd, buf, 256)
190
+ return buf.toString('utf16le', 0, n * 2)
191
+ }
192
+
193
+ /**
194
+ * Physical window rect.
195
+ * @param {bigint|number} hwnd
196
+ * @returns {{x:number,y:number,w:number,h:number}}
197
+ */
198
+ export function windowRect(hwnd) {
199
+ const { fn } = native()
200
+ const rect = {}
201
+ if (!fn.GetWindowRect(hwnd, rect)) return null
202
+ return { x: rect.left, y: rect.top, w: rect.right - rect.left, h: rect.bottom - rect.top }
203
+ }
204
+
205
+ /**
206
+ * @param {bigint|number} hwnd
207
+ * @returns {number} pid, or 0.
208
+ */
209
+ export function windowPid(hwnd) {
210
+ const { fn } = native()
211
+ const pid = [0]
212
+ fn.GetWindowThreadProcessId(hwnd, pid)
213
+ return pid[0]
214
+ }
215
+
216
+ /**
217
+ * Executable path of a pid (no external CLI: OpenProcess + QueryFullProcessImageNameW).
218
+ * @param {number} pid
219
+ * @returns {string|null}
220
+ */
221
+ export function processPath(pid) {
222
+ const { fn } = native()
223
+ if (!pid) return null
224
+ const h = fn.OpenProcess(CONST.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
225
+ if (!h) return null
226
+ try {
227
+ const buf = Buffer.alloc(2048)
228
+ const size = [1024]
229
+ if (!fn.QueryFullProcessImageNameW(h, 0, buf, size)) return null
230
+ return buf.toString('utf16le', 0, size[0] * 2)
231
+ } finally {
232
+ fn.CloseHandle(h)
233
+ }
234
+ }
235
+
236
+ /** Executable base name, lowercase. */
237
+ export function processName(pid) {
238
+ const path = processPath(pid)
239
+ if (!path) return ''
240
+ const base = path.slice(Math.max(path.lastIndexOf('\\'), path.lastIndexOf('/')) + 1)
241
+ return base.toLowerCase()
242
+ }
243
+
244
+ /**
245
+ * @returns {{left:number,top:number,width:number,height:number,dpi:number}} virtual desktop geometry.
246
+ */
247
+ export function virtualScreen() {
248
+ const { fn } = native()
249
+ return {
250
+ left: fn.GetSystemMetrics(CONST.SM_XVIRTUALSCREEN),
251
+ top: fn.GetSystemMetrics(CONST.SM_YVIRTUALSCREEN),
252
+ width: fn.GetSystemMetrics(CONST.SM_CXVIRTUALSCREEN),
253
+ height: fn.GetSystemMetrics(CONST.SM_CYVIRTUALSCREEN),
254
+ dpi: fn.GetDpiForSystem(),
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Foreground window info.
260
+ * @returns {{hwnd:any, title:string, pid:number, process:string}|null}
261
+ */
262
+ export function foregroundInfo() {
263
+ const { fn } = native()
264
+ const hwnd = fn.GetForegroundWindow()
265
+ if (!hwnd) return null
266
+ const pid = windowPid(hwnd)
267
+ return { hwnd, title: windowTitle(hwnd), pid, process: processName(pid) }
268
+ }
269
+
270
+ /**
271
+ * Enumerate visible, titled, top-level windows.
272
+ * @returns {Array<{hwnd:any,title:string,className:string,rect:object,pid:number,process:string,isForeground:boolean}>}
273
+ */
274
+ export function listWindows() {
275
+ const { fn } = native()
276
+ const fg = fn.GetForegroundWindow()
277
+ const out = []
278
+ fn.EnumWindows((hwnd) => {
279
+ if (!fn.IsWindowVisible(hwnd)) return true
280
+ const title = windowTitle(hwnd)
281
+ if (title === '') return true
282
+ const pid = windowPid(hwnd)
283
+ out.push({
284
+ hwnd,
285
+ title,
286
+ className: windowClass(hwnd),
287
+ rect: windowRect(hwnd),
288
+ pid,
289
+ process: processName(pid),
290
+ isForeground: hwnd === fg,
291
+ })
292
+ return true
293
+ }, 0)
294
+ return out
295
+ }
296
+
297
+ /**
298
+ * Find a window by decimal hwnd id or by title substring (case-insensitive).
299
+ * @param {object} spec - {window} where window is a numeric id or title fragment.
300
+ * @returns the window entry, or null.
301
+ */
302
+ export function findWindow(value) {
303
+ const windows = listWindows()
304
+ const numeric = typeof value === 'number' || /^\d+$/.test(String(value ?? ''))
305
+ if (numeric) {
306
+ const id = typeof value === 'number' ? value : parseInt(value, 10)
307
+ return windows.find((w) => Number(w.hwnd) === id) ?? null
308
+ }
309
+ const needle = String(value ?? '').toLowerCase()
310
+ if (needle === '') return null
311
+ return (
312
+ windows.find((w) => w.title.toLowerCase() === needle)
313
+ ?? windows.find((w) => w.title.toLowerCase().includes(needle))
314
+ ?? null
315
+ )
316
+ }
317
+
318
+ /**
319
+ * Root window at a screen point.
320
+ * @returns {{hwnd:any,title:string,pid:number,process:string}|null}
321
+ */
322
+ export function windowAtPoint(x, y) {
323
+ const { fn } = native()
324
+ const hwnd = fn.WindowFromPoint({ x: Math.round(x), y: Math.round(y) })
325
+ if (!hwnd) return null
326
+ const root = fn.GetAncestor(hwnd, CONST.GA_ROOT) || hwnd
327
+ const pid = windowPid(root)
328
+ return { hwnd: root, title: windowTitle(root), pid, process: processName(pid) }
329
+ }
330
+
331
+ /**
332
+ * Bring a window to the foreground — the full AttachThreadInput dance.
333
+ *
334
+ * A background process calling SetForegroundWindow alone gets FALSE (or a
335
+ * silent flash) because Windows only grants foreground to the process the user
336
+ * last interacted with. Attaching our thread to the current foreground
337
+ * thread's input queue puts us in that queue, which unlocks
338
+ * SetForegroundWindow. Without this, clicks land on whatever window Windows
339
+ * kept in front instead.
340
+ *
341
+ * @param {bigint|number} hwnd
342
+ * @returns {{ok:boolean, foreground:boolean, title:string}}
343
+ */
344
+ export function setForeground(hwnd) {
345
+ const { fn } = native()
346
+ if (!fn.IsWindow(hwnd)) return { ok: false, foreground: false, title: '' }
347
+ let attached = false
348
+ let foreignThread = 0
349
+ try {
350
+ const fg = fn.GetForegroundWindow()
351
+ if (fg && fg !== hwnd) {
352
+ const pid = [0]
353
+ foreignThread = fn.GetWindowThreadProcessId(fg, pid)
354
+ const me = fn.GetCurrentThreadId()
355
+ if (foreignThread && foreignThread !== me) {
356
+ attached = fn.AttachThreadInput(foreignThread, me, true)
357
+ }
358
+ }
359
+ if (fn.IsIconic(hwnd)) fn.ShowWindow(hwnd, CONST.SW_RESTORE)
360
+ else fn.ShowWindow(hwnd, CONST.SW_SHOW)
361
+ fn.BringWindowToTop(hwnd)
362
+ const ok = fn.SetForegroundWindow(hwnd)
363
+ const nowFg = fn.GetForegroundWindow() === hwnd
364
+ return { ok: ok || nowFg, foreground: nowFg, title: windowTitle(hwnd) }
365
+ } finally {
366
+ if (attached && foreignThread) {
367
+ try {
368
+ fn.AttachThreadInput(foreignThread, fn.GetCurrentThreadId(), false)
369
+ } catch {
370
+ // detach failure leaves a harmless shared input queue; do not throw
371
+ }
372
+ }
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Capture a screen region as raw BGRA (top-down) using GDI BitBlt.
378
+ *
379
+ * PrintWindow renders Chromium windows black; BitBlt from the screen DC
380
+ * captures what is physically on those pixels — so the caller must ensure the
381
+ * target is in the foreground or accept occlusion.
382
+ *
383
+ * @param {number} x - physical screen x.
384
+ * @param {number} y - physical screen y.
385
+ * @param {number} w - width in pixels.
386
+ * @param {number} h - height in pixels.
387
+ * @returns {{rgba:Uint8Array,width:number,height:number}} RGBA, top-down.
388
+ */
389
+ export function captureRegion(x, y, w, h) {
390
+ const { fn, BITMAPINFOHEADER } = native()
391
+ if (w <= 0 || h <= 0) throw new Error(`invalid capture size ${w}x${h}`)
392
+ const hdc = fn.GetDC(0)
393
+ if (!hdc) throw new Error('GetDC(NULL) failed')
394
+ let mem = 0
395
+ let bm = 0
396
+ try {
397
+ mem = fn.CreateCompatibleDC(hdc)
398
+ bm = fn.CreateCompatibleBitmap(hdc, w, h)
399
+ if (!bm) throw new Error('CreateCompatibleBitmap failed')
400
+ fn.SelectObject(mem, bm)
401
+ if (!fn.BitBlt(mem, 0, 0, w, h, hdc, x, y, CONST.SRCCOPY)) {
402
+ throw new Error(`BitBlt failed (region ${x},${y} ${w}x${h})`)
403
+ }
404
+ const bgra = Buffer.alloc(w * h * 4)
405
+ const bmi = {
406
+ biSize: native().koffi.sizeof(BITMAPINFOHEADER), biWidth: w, biHeight: -h, biPlanes: 1,
407
+ biBitCount: 32, biCompression: 0, biSizeImage: 0, biXPelsPerMeter: 0, biYPelsPerMeter: 0,
408
+ biClrUsed: 0, biClrImportant: 0,
409
+ }
410
+ const lines = fn.GetDIBits(mem, bm, 0, h, bgra, bmi, 0)
411
+ if (lines !== h) throw new Error(`GetDIBits returned ${lines}/${h} lines`)
412
+ const rgba = new Uint8Array(w * h * 4)
413
+ for (let i = 0; i < w * h; i += 1) {
414
+ rgba[i * 4] = bgra[i * 4 + 2]
415
+ rgba[i * 4 + 1] = bgra[i * 4 + 1]
416
+ rgba[i * 4 + 2] = bgra[i * 4]
417
+ rgba[i * 4 + 3] = 255
418
+ }
419
+ return { rgba, width: w, height: h }
420
+ } finally {
421
+ if (bm) fn.DeleteObject(bm)
422
+ if (mem) fn.DeleteDC(mem)
423
+ fn.ReleaseDC(0, hdc)
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Normalize one SendInput mouse struct with absolute virtual-desktop coords.
429
+ * @param {number} x - physical screen x.
430
+ * @param {number} y - physical screen y.
431
+ * @returns {{type:number,u:{mi:object}}}
432
+ */
433
+ export function mouseMoveInput(x, y) {
434
+ const { left, top, width, height } = virtualScreen()
435
+ const nx = Math.round(((x - left) * 65535) / (width - 1))
436
+ const ny = Math.round(((y - top) * 65535) / (height - 1))
437
+ return {
438
+ type: CONST.INPUT_MOUSE,
439
+ u: { mi: { dx: nx, dy: ny, mouseData: 0, dwFlags: CONST.MOUSEEVENTF_MOVE | CONST.MOUSEEVENTF_ABSOLUTE | CONST.MOUSEEVENTF_VIRTUALDESK, time: 0, dwExtraInfo: 0n } },
440
+ }
441
+ }
442
+
443
+ /**
444
+ * @param {number} flags - MOUSEEVENTF_* flags.
445
+ * @param {number} [data] - mouseData (button or wheel delta).
446
+ * @returns {{type:number,u:{mi:object}}}
447
+ */
448
+ export function mouseButtonInput(flags, data = 0) {
449
+ return { type: CONST.INPUT_MOUSE, u: { mi: { dx: 0, dy: 0, mouseData: data, dwFlags: flags, time: 0, dwExtraInfo: 0n } } }
450
+ }
451
+
452
+ /**
453
+ * @param {number} vk - virtual key code.
454
+ * @param {boolean} up - key-up instead of key-down.
455
+ * @returns {{type:number,u:{ki:object}}}
456
+ */
457
+ export function keyInput(vk, up) {
458
+ return { type: CONST.INPUT_KEYBOARD, u: { ki: { wVk: vk, wScan: 0, dwFlags: up ? CONST.KEYEVENTF_KEYUP : 0, time: 0, dwExtraInfo: 0n } } }
459
+ }
460
+
461
+ /**
462
+ * @param {number} codePoint - one UTF-16 code unit.
463
+ * @param {boolean} up
464
+ * @returns {{type:number,u:{ki:object}}}
465
+ */
466
+ export function unicodeInput(codePoint, up) {
467
+ return { type: CONST.INPUT_KEYBOARD, u: { ki: { wVk: 0, wScan: codePoint, dwFlags: CONST.KEYEVENTF_UNICODE | (up ? CONST.KEYEVENTF_KEYUP : 0), time: 0, dwExtraInfo: 0n } } }
468
+ }
469
+
470
+ /**
471
+ * Submit one input batch. Returns how many events were accepted.
472
+ * @param {Array<object>} inputs
473
+ * @returns {number}
474
+ */
475
+ export function sendInput(inputs) {
476
+ const { fn, INPUT_SIZE } = native()
477
+ if (inputs.length === 0) return 0
478
+ return fn.SendInput(inputs.length, inputs, INPUT_SIZE)
479
+ }
480
+
481
+ /** Current cursor position in physical screen coordinates. */
482
+ export function cursorPos() {
483
+ const { fn } = native()
484
+ const pt = {}
485
+ if (!fn.GetCursorPos(pt)) return null
486
+ return { x: pt.x, y: pt.y }
487
+ }
488
+
489
+ /** Sleep helper usable inside the poll loop (keeps the host event loop live). */
490
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Safety rails and durable logging for dsh-cua-win.
3
+ *
4
+ * Every click goes through one gate: dryRun -> confirm -> blacklist -> stop
5
+ * file -> act. The gate is the ONLY place that turns "the model asked for a
6
+ * click" into "the mouse moved", so audit trails are complete by construction.
7
+ *
8
+ * @module dsh-cua-win/lib/safety
9
+ */
10
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
11
+ import { homedir } from 'node:os'
12
+ import { isAbsolute, join } from 'node:path'
13
+
14
+ /** Default keyword blacklist: money-adjacent UI must never be auto-clicked. */
15
+ export const DEFAULT_BLACKLIST = ['支付', '充值', '购买', '授权', '扫码', '付款', '收银', '转账', '下单', '结算', '微信支付', '支付宝']
16
+
17
+ /** Default settings (overridable through the plugin Config). */
18
+ export const DEFAULTS = {
19
+ enabled: true,
20
+ defaultDryRun: true,
21
+ requireConfirm: true,
22
+ screenshotLongEdge: 1600,
23
+ logDir: '',
24
+ blacklist: [...DEFAULT_BLACKLIST],
25
+ maxMacrosPerSession: 20,
26
+ pollMs: 120,
27
+ timeoutMs: 60000,
28
+ }
29
+
30
+ /**
31
+ * DSH home, resolved the same way the host and dsh-agent-desktop resolve it.
32
+ * @returns {string}
33
+ */
34
+ export function dshHome() {
35
+ const raw = process.env.DSH_HOME
36
+ if (typeof raw === 'string' && raw.trim() !== '') {
37
+ const value = raw.trim()
38
+ if (value === '~') return homedir()
39
+ if (value.startsWith('~/') || value.startsWith('~\\')) return join(homedir(), value.slice(2))
40
+ return isAbsolute(value) ? value : join(process.cwd(), value)
41
+ }
42
+ return join(homedir(), '.dsh')
43
+ }
44
+
45
+ /** Plugin state root (logs, shots, baseline). */
46
+ export function stateRoot() {
47
+ const override = process.env.CUA_WIN_STATE_ROOT
48
+ if (typeof override === 'string' && override.trim() !== '') return override.trim()
49
+ return join(dshHome(), 'cua-win')
50
+ }
51
+
52
+ /** Resolve the effective settings from a raw config object. */
53
+ export function resolveConfig(raw) {
54
+ const value = typeof raw === 'object' && raw !== null ? raw : {}
55
+ const list = (v) => (Array.isArray(v) ? v.map(String).filter((s) => s.trim() !== '') : undefined)
56
+ return {
57
+ enabled: value.enabled === undefined ? DEFAULTS.enabled : value.enabled !== false,
58
+ defaultDryRun: value.defaultDryRun === undefined ? DEFAULTS.defaultDryRun : value.defaultDryRun !== false,
59
+ requireConfirm: value.requireConfirm === undefined ? DEFAULTS.requireConfirm : value.requireConfirm !== false,
60
+ screenshotLongEdge: Number.isFinite(value.screenshotLongEdge) && value.screenshotLongEdge > 0 ? value.screenshotLongEdge : DEFAULTS.screenshotLongEdge,
61
+ logDir: typeof value.logDir === 'string' && value.logDir.trim() !== '' ? value.logDir.trim() : join(stateRoot(), 'logs'),
62
+ blacklist: list(value.blacklist) ?? [...DEFAULT_BLACKLIST],
63
+ maxMacrosPerSession: Number.isFinite(value.maxMacrosPerSession) && value.maxMacrosPerSession > 0 ? value.maxMacrosPerSession : DEFAULTS.maxMacrosPerSession,
64
+ pollMs: Number.isFinite(value.pollMs) && value.pollMs >= 30 ? value.pollMs : DEFAULTS.pollMs,
65
+ timeoutMs: Number.isFinite(value.timeoutMs) && value.timeoutMs > 0 ? value.timeoutMs : DEFAULTS.timeoutMs,
66
+ }
67
+ }
68
+
69
+ // ---------------------------------------------------------------- logging
70
+
71
+ /**
72
+ * Append one JSONL line to a log file. Logging must never throw into a tool
73
+ * call — failures are swallowed (and best-effort mirrored to console).
74
+ * @param {string} file - absolute path.
75
+ * @param {object} entry
76
+ */
77
+ export function logJsonl(file, entry) {
78
+ try {
79
+ mkdirSync(join(file, '..'), { recursive: true })
80
+ appendFileSync(file, `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`)
81
+ } catch (error) {
82
+ try {
83
+ console.warn(`[dsh-cua-win] log write failed: ${error?.message ?? error}`)
84
+ } catch {
85
+ // even console can fail in a dead host; nothing more to do
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * The action log — every click/drag/typed string/macro lands here.
92
+ * @param {object} config - effective config.
93
+ * @param {object} entry
94
+ */
95
+ export function logAction(config, entry) {
96
+ logJsonl(join(config.logDir, 'cua-actions.log'), entry)
97
+ }
98
+
99
+ /** Boot/diagnostic trace (why tools are missing, when they are). */
100
+ export function bootLog(line) {
101
+ logJsonl(join(stateRoot(), 'logs', 'cua-boot.log'), { line })
102
+ }
103
+
104
+ // ---------------------------------------------------------------- gates
105
+
106
+ /** The kill-switch file: its existence aborts every macro loop immediately. */
107
+ export function stopFilePath() {
108
+ return join(dshHome(), 'cua.stop')
109
+ }
110
+
111
+ /** @returns true when the operator has dropped a cua.stop file. */
112
+ export function isStopped() {
113
+ try {
114
+ return existsSync(stopFilePath())
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Blacklist check on any number of texts (window title at the point, the
122
+ * foreground window title, ...).
123
+ * @param {object} config
124
+ * @param {string[]} texts
125
+ * @returns {string|null} the matched keyword, or null.
126
+ */
127
+ export function blacklistHit(config, texts) {
128
+ for (const text of texts) {
129
+ if (typeof text !== 'string' || text === '') continue
130
+ for (const keyword of config.blacklist) {
131
+ if (keyword !== '' && text.includes(keyword)) return keyword
132
+ }
133
+ }
134
+ return null
135
+ }
136
+
137
+ /**
138
+ * Decide what a destructive call may do.
139
+ *
140
+ * Order: explicit dryRun wins; else defaultDryRun; confirm=true is REQUIRED
141
+ * before real input when requireConfirm is on (a one-shot escape from
142
+ * defaultDryRun), and never sufficient when dryRun was forced.
143
+ *
144
+ * @param {object} config
145
+ * @param {object} args - the tool call arguments.
146
+ * @returns {{dryRun:boolean, reason?:string}}
147
+ */
148
+ export function gate(config, args) {
149
+ if (!config.enabled) return { dryRun: true, reason: 'plugin disabled (cua-win enabled=false)' }
150
+ if (isStopped()) return { dryRun: true, reason: `stop file present (${stopFilePath()}); delete it to resume` }
151
+ const explicitDry = args?.dryRun === true
152
+ const confirm = args?.confirm === true
153
+ if (explicitDry) return { dryRun: true, reason: 'dryRun requested' }
154
+ if (config.defaultDryRun) {
155
+ if (confirm) return { dryRun: false, reason: 'confirmed one-shot' }
156
+ return { dryRun: true, reason: 'defaultDryRun=true and confirm not set' }
157
+ }
158
+ if (config.requireConfirm && !confirm) {
159
+ return { dryRun: true, reason: 'requireConfirm=true and confirm not set' }
160
+ }
161
+ return { dryRun: false, reason: 'enabled' }
162
+ }