@wobuxcm/dsh-win-tools 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/README.md +88 -2
- package/THIRD-PARTY-NOTICES.md +36 -0
- package/bin/agent-desktop-win32-x64.exe +0 -0
- package/bin/package.json +3 -0
- package/bin/web.js +147 -0
- package/cordis.patch.yml +4 -0
- package/index.js +348 -0
- package/lib/core.js +583 -0
- package/lib/cua/engine.js +322 -0
- package/lib/cua/input.js +205 -0
- package/lib/cua/native.js +490 -0
- package/lib/cua/safety.js +162 -0
- package/lib/cua/screen.js +300 -0
- package/package.json +56 -4
|
@@ -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
|
+
}
|