@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.
package/lib/core.js ADDED
@@ -0,0 +1,583 @@
1
+ /**
2
+ * dsh-win-tools core — backend routing without any DSH imports.
3
+ *
4
+ * Backends stay where they already work:
5
+ * - UIA structure/actions: the agent-desktop native CLI (spawn per call)
6
+ * - pixel input / screenshots / window enumeration: dsh-cua-win lib (dynamic import)
7
+ * - browser DOM: the dsh-browser web.js CLI (spawn)
8
+ *
9
+ * Every write action is refused while the operator pause file exists.
10
+ */
11
+ import { spawn } from 'node:child_process'
12
+ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
13
+ import { dirname, join } from 'node:path'
14
+ import { homedir } from 'node:os'
15
+ import { fileURLToPath, pathToFileURL } from 'node:url'
16
+
17
+ const HOME = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== ''
18
+ ? process.env.DSH_HOME.trim()
19
+ : join(homedir(), '.dsh')
20
+
21
+ const HERE = dirname(fileURLToPath(import.meta.url))
22
+ const ROOT = join(HERE, '..')
23
+
24
+ export const PATHS = {
25
+ home: HOME,
26
+ agentBin: process.env.PC_AGENT_BIN || join(ROOT, 'bin', 'agent-desktop-win32-x64.exe'),
27
+ cuaDir: process.env.PC_CUA_DIR || join(HERE, 'cua'),
28
+ browserJs: process.env.PC_BROWSER_JS || join(ROOT, 'bin', 'web.js'),
29
+ nodeBin: process.env.PC_NODE_BIN || (process.versions.electron ? 'node' : process.execPath),
30
+ pauseFile: join(HOME, 'cua.stop'),
31
+ logFile: join(HOME, 'win-tools.log'),
32
+ shotDir: join(HOME, 'win-tools', 'shots'),
33
+ }
34
+
35
+ export function log(entry) {
36
+ try {
37
+ mkdirSync(dirname(PATHS.logFile), { recursive: true })
38
+ writeFileSync(PATHS.logFile, JSON.stringify({ t: new Date().toISOString(), ...entry }) + '\n', { flag: 'a' })
39
+ } catch {
40
+ // logging must never break a tool call
41
+ }
42
+ }
43
+
44
+ export function isPaused() {
45
+ return existsSync(PATHS.pauseFile)
46
+ }
47
+
48
+ export function setPaused(on) {
49
+ if (on) {
50
+ mkdirSync(dirname(PATHS.pauseFile), { recursive: true })
51
+ writeFileSync(PATHS.pauseFile, 'paused by operator\n')
52
+ } else {
53
+ try { rmSync(PATHS.pauseFile, { force: true }) } catch {}
54
+ }
55
+ const state = isPaused()
56
+ log({ tool: 'pc_pause', paused: state })
57
+ return { ok: true, paused: state, file: PATHS.pauseFile }
58
+ }
59
+
60
+ function run(file, args, { timeoutMs = 30000 } = {}) {
61
+ return new Promise((resolve) => {
62
+ let out = ''
63
+ let err = ''
64
+ let settled = false
65
+ const child = spawn(file, args, { windowsHide: true })
66
+ const timer = setTimeout(() => {
67
+ try { child.kill() } catch {}
68
+ if (!settled) { settled = true; resolve({ code: -1, stdout: out, stderr: err + '\n[timeout]', timedOut: true }) }
69
+ }, timeoutMs)
70
+ child.stdout.on('data', (d) => { out += d })
71
+ child.stderr.on('data', (d) => { err += d })
72
+ child.on('error', (e) => {
73
+ clearTimeout(timer)
74
+ if (!settled) { settled = true; resolve({ code: -1, stdout: out, stderr: String(e.message) }) }
75
+ })
76
+ child.on('close', (code) => {
77
+ clearTimeout(timer)
78
+ if (!settled) { settled = true; resolve({ code, stdout: out, stderr: err }) }
79
+ })
80
+ })
81
+ }
82
+
83
+ async function runAgent(args, opts) {
84
+ const res = await run(PATHS.agentBin, args, opts)
85
+ const lines = res.stdout.trim().split(/\r?\n/).filter(Boolean)
86
+ let parsed = null
87
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
88
+ try { parsed = JSON.parse(lines[i]); break } catch {}
89
+ }
90
+ if (parsed) return parsed
91
+ return { ok: false, code: 'CLI_OUTPUT', exitCode: res.code, raw: res.stdout.slice(0, 1500), stderr: res.stderr.slice(0, 800) }
92
+ }
93
+
94
+ const SNAPSHOT_FLAGS = {
95
+ app: (v) => ['--app', String(v)],
96
+ windowId: (v) => ['--window-id', String(v)],
97
+ maxDepth: (v) => ['--max-depth', String(v)],
98
+ surface: (v) => ['--surface', String(v)],
99
+ root: (v) => ['--root', String(v)],
100
+ waitFor: (v) => ['--wait-for', String(v)],
101
+ timeoutMs: (v) => ['--timeout-ms', String(v)],
102
+ interactiveOnly: () => ['--interactive-only'],
103
+ skeleton: () => ['--skeleton'],
104
+ compact: () => ['--compact'],
105
+ includeBounds: () => ['--include-bounds'],
106
+ }
107
+
108
+ export async function snapshot(o = {}) {
109
+ const args = ['snapshot']
110
+ for (const [key, fn] of Object.entries(SNAPSHOT_FLAGS)) {
111
+ if (o[key] !== undefined && o[key] !== null && o[key] !== false) args.push(...fn(o[key]))
112
+ }
113
+ const res = await runAgent(args, { timeoutMs: Number(o.timeoutMs ?? 6000) + 15000 })
114
+ log({ tool: 'pc_snapshot', ok: res?.ok === true, surface: o.surface ?? 'window', interactiveOnly: !!o.interactiveOnly })
115
+ return res
116
+ }
117
+
118
+ const REF_VERBS = new Set(['click', 'double-click', 'right-click', 'type', 'set-value', 'clear', 'press', 'focus', 'select', 'toggle', 'check', 'uncheck', 'expand', 'collapse', 'scroll', 'scroll-to', 'hover'])
119
+
120
+ export async function actByRef(o = {}) {
121
+ const action = String(o.action || '')
122
+ if (!REF_VERBS.has(action)) return { ok: false, code: 'BAD_ACTION', message: 'unsupported ref action: ' + action }
123
+ if (isPaused()) return { ok: false, code: 'PAUSED_BY_USER', message: 'operator pause file present: ' + PATHS.pauseFile, file: PATHS.pauseFile }
124
+ const blocked = await guardTitles()
125
+ if (blocked) return blocked
126
+ const args = [action]
127
+ if (action === 'press') {
128
+ args.push(String(o.keys || 'return'))
129
+ } else if (action === 'scroll') {
130
+ args.push(String(o.ref))
131
+ args.push('--direction', String(o.direction || 'down'))
132
+ args.push('--amount', String(o.amount ?? 3))
133
+ } else if (action === 'scroll-to' || action === 'hover' || action === 'focus' || action === 'toggle' || action === 'check' || action === 'uncheck' || action === 'expand' || action === 'collapse' || action === 'click' || action === 'double-click' || action === 'right-click' || action === 'clear') {
134
+ args.push(String(o.ref))
135
+ if (o.headed) args.push('--headed')
136
+ } else {
137
+ args.push(String(o.ref))
138
+ if (o.text !== undefined && o.text !== null) args.push(String(o.text))
139
+ }
140
+ if (o.timeoutMs) args.push('--timeout-ms', String(o.timeoutMs))
141
+ const res = await runAgent(args, { timeoutMs: Number(o.timeoutMs ?? 8000) + 20000 })
142
+ log({ tool: 'pc_act', mode: 'ref', action, ref: o.ref ?? null, ok: res?.ok === true })
143
+ return res
144
+ }
145
+
146
+ async function cuaLib(name) {
147
+ return import(pathToFileURL(join(PATHS.cuaDir, name + '.js')).href)
148
+ }
149
+
150
+ export async function actByPoint(o = {}) {
151
+ if (isPaused()) return { ok: false, code: 'PAUSED_BY_USER', message: 'operator pause file present: ' + PATHS.pauseFile, file: PATHS.pauseFile }
152
+ const blocked = await guardTitles(o.x, o.y)
153
+ if (blocked) return blocked
154
+ const input = await cuaLib('input')
155
+ const action = String(o.action || 'click')
156
+ if (action === 'click' || action === 'double-click' || action === 'right-click') {
157
+ const button = action === 'right-click' ? 'right' : (o.button || 'left')
158
+ const count = action === 'double-click' ? 2 : 1
159
+ const res = input.click(Number(o.x), Number(o.y), button, count)
160
+ log({ tool: 'pc_act', mode: 'point', action, x: o.x, y: o.y, ok: true })
161
+ return { ok: true, action, cursor: res?.cursor ?? null }
162
+ }
163
+ if (action === 'move') {
164
+ const res = input.move(Number(o.x), Number(o.y))
165
+ return { ok: true, action, cursor: res }
166
+ }
167
+ if (action === 'drag') {
168
+ const res = await input.drag(o.from, o.to, Number(o.durationMs ?? 300), o.button || 'left')
169
+ return { ok: true, action, cursor: res?.cursor ?? null }
170
+ }
171
+ if (action === 'type') {
172
+ const res = await input.typeText(String(o.text ?? ''))
173
+ return { ok: true, action, typed: String(o.text ?? '').length, result: res ?? null }
174
+ }
175
+ return { ok: false, code: 'BAD_ACTION', message: 'unsupported point action: ' + action }
176
+ }
177
+
178
+ export async function keyCombo(o = {}) {
179
+ if (isPaused()) return { ok: false, code: 'PAUSED_BY_USER', message: 'operator pause file present: ' + PATHS.pauseFile, file: PATHS.pauseFile }
180
+ const blocked = await guardTitles()
181
+ if (blocked) return blocked
182
+ const input = await cuaLib('input')
183
+ const res = await input.keyPress(String(o.keys || 'enter'), Number(o.times ?? 1))
184
+ log({ tool: 'pc_key', keys: o.keys, ok: true })
185
+ return { ok: true, ...res }
186
+ }
187
+
188
+ export async function listWindows(app) {
189
+ const native = await cuaLib('native')
190
+ const all = native.listWindows().map((w) => ({
191
+ id: Number(w.hwnd), title: w.title, className: w.className, process: w.process, pid: w.pid,
192
+ rect: w.rect ? [w.rect.x, w.rect.y, w.rect.w, w.rect.h] : null, isForeground: w.isForeground,
193
+ }))
194
+ const filtered = app ? all.filter((w) => String(w.process || '').toLowerCase().includes(String(app).toLowerCase())) : all
195
+ return { ok: true, count: filtered.length, windows: filtered, foreground: (await cuaLib('native')).foregroundInfo()?.title ?? null }
196
+ }
197
+
198
+ export async function focusWindow(target) {
199
+ if (isPaused()) return { ok: false, code: 'PAUSED_BY_USER', message: 'operator pause file present: ' + PATHS.pauseFile, file: PATHS.pauseFile }
200
+ const native = await cuaLib('native')
201
+ const win = native.findWindow(String(target))
202
+ if (!win) return { ok: false, code: 'WINDOW_NOT_FOUND', message: 'no window matches ' + JSON.stringify(target) }
203
+ const res = native.setForeground(win.hwnd)
204
+ log({ tool: 'pc_window', action: 'focus', window: win.title, ok: res?.ok === true })
205
+ return { ok: true, window: { id: Number(win.hwnd), title: win.title }, foreground: res?.foreground ?? null }
206
+ }
207
+
208
+ export async function screenshot(o = {}) {
209
+ const screen = await cuaLib('screen')
210
+ const shot = screen.capture({ target: o.target || 'screen', rect: o.rect, window: o.window, bringToFront: o.bringToFront })
211
+ if (!shot?.ok) return shot ?? { ok: false, code: 'CAPTURE_FAILED' }
212
+ const encoded = screen.pngForShot(shot.screenshotId, Number(o.scale ?? 1600))
213
+ if (!encoded?.png) return { ok: false, code: 'SHOT_EXPIRED' }
214
+ mkdirSync(PATHS.shotDir, { recursive: true })
215
+ const out = o.out && String(o.out).trim() !== '' ? String(o.out) : join(PATHS.shotDir, 'win-' + Date.now() + '.png')
216
+ screen.savePng(out, encoded.png)
217
+ log({ tool: 'pc_screen', target: o.target || 'screen', path: out, width: encoded.width, height: encoded.height })
218
+ return { ok: true, path: out, screenshotId: shot.screenshotId, width: encoded.width, height: encoded.height, sourceWidth: shot.width, sourceHeight: shot.height, scale: encoded.scale, rect: shot.rect, foreground: shot.foreground?.title ?? null }
219
+ }
220
+
221
+ export async function browser(o = {}) {
222
+ const op = String(o.op || 'title')
223
+ const url = String(o.url || '')
224
+ if (!url) return { ok: false, code: 'BAD_ARGS', message: 'url is required' }
225
+ const args = [PATHS.browserJs, op, url]
226
+ if (o.arg !== undefined && o.arg !== null && String(o.arg) !== '') args.push(String(o.arg))
227
+ if (o.profile) args.push('--profile', String(o.profile))
228
+ if (o.cdp) args.push('--cdp', String(o.cdp))
229
+ if (o.headful) args.push('--headful')
230
+ if (o.wait != null) args.push('--wait', String(o.wait))
231
+ const res = await run(PATHS.nodeBin, args, { timeoutMs: Number(o.timeoutMs ?? 60000) })
232
+ const text = res.stdout.trim()
233
+ let parsed = null
234
+ try { parsed = JSON.parse(text) } catch {}
235
+ log({ tool: 'pc_browser', op, ok: res.code === 0 })
236
+ if (parsed) return parsed
237
+ return { ok: res.code === 0, exitCode: res.code, stdout: text.slice(0, 3000), stderr: res.stderr.slice(0, 800) }
238
+ }
239
+
240
+
241
+ // ---------------------------------------------------------------- wait / app
242
+
243
+ function sleep(ms) {
244
+ return new Promise((r) => setTimeout(r, ms))
245
+ }
246
+
247
+ function parseColor(input) {
248
+ const m = String(input || '').trim().replace(/^#/, '')
249
+ if (!/^[0-9a-fA-F]{6}$/.test(m)) return null
250
+ return { r: parseInt(m.slice(0, 2), 16), g: parseInt(m.slice(2, 4), 16), b: parseInt(m.slice(4, 6), 16) }
251
+ }
252
+
253
+ function colorNear(rgba, offset, color, tol) {
254
+ return Math.abs(rgba[offset] - color.r) <= tol && Math.abs(rgba[offset + 1] - color.g) <= tol && Math.abs(rgba[offset + 2] - color.b) <= tol
255
+ }
256
+
257
+ async function checkCondition(cond) {
258
+ if (!cond || typeof cond !== 'object') return false
259
+ const native = await cuaLib('native')
260
+ if (cond.window_title_contains && cond.window_title_contains.pattern) {
261
+ const pattern = String(cond.window_title_contains.pattern).toLowerCase()
262
+ return native.listWindows().some((w) => String(w.title || '').toLowerCase().includes(pattern))
263
+ }
264
+ if (cond.pixel_matches) {
265
+ const p = cond.pixel_matches
266
+ const color = parseColor(p.color)
267
+ if (!color || p.x === undefined || p.y === undefined) return false
268
+ const frame = native.captureRegion(Number(p.x), Number(p.y), 1, 1)
269
+ return colorNear(frame.rgba, 0, color, Number(p.tolerance ?? 16))
270
+ }
271
+ if (cond.region_ratio) {
272
+ const r = cond.region_ratio
273
+ const color = parseColor(r.color)
274
+ if (!color || !Array.isArray(r.rect) || r.rect.length !== 4) return false
275
+ const [x, y, w, h] = r.rect.map(Number)
276
+ const frame = native.captureRegion(x, y, w, h)
277
+ let hits = 0
278
+ for (let i = 0; i < frame.width * frame.height; i += 1) if (colorNear(frame.rgba, i * 4, color, Number(r.tolerance ?? 16))) hits += 1
279
+ return hits / (frame.width * frame.height) >= Number(r.minRatio ?? 0.5)
280
+ }
281
+ return false
282
+ }
283
+
284
+ export async function waitFor(o = {}) {
285
+ if (isPaused()) return { ok: false, code: 'PAUSED_BY_USER', message: 'operator pause file present', file: PATHS.pauseFile }
286
+ const timeoutMs = Math.max(1, Math.min(Number(o.timeoutMs ?? 30000), 600000))
287
+ const pollMs = Math.max(30, Math.min(Number(o.pollMs ?? 120), 5000))
288
+ const started = Date.now()
289
+ let baseline = null
290
+ try {
291
+ while (Date.now() - started < timeoutMs) {
292
+ if (isPaused()) return { ok: false, code: 'PAUSED_BY_USER', message: 'operator pause file present', file: PATHS.pauseFile }
293
+ const ic = o.condition && o.condition.image_changed
294
+ if (ic) {
295
+ const rect = Array.isArray(ic.rect) && ic.rect.length === 4 ? ic.rect.map(Number) : null
296
+ if (!rect) return { ok: false, code: 'BAD_ARGS', message: 'image_changed requires rect [x,y,w,h]' }
297
+ const nativeMod = await cuaLib('native')
298
+ const frame = nativeMod.captureRegion(rect[0], rect[1], rect[2], rect[3])
299
+ if (!baseline) { baseline = frame; await sleep(pollMs); continue }
300
+ const ratio = frameDiff(baseline, frame, Number(ic.tolerance ?? 24))
301
+ if (ratio >= Number(ic.minChangeRatio ?? 0.02)) {
302
+ const acted = o.action ? await actByPoint(o.action) : null
303
+ log({ tool: 'pc_wait', matched: true, changeRatio: ratio })
304
+ return { ok: true, matched: true, changeRatio: ratio, elapsedMs: Date.now() - started, ...(acted ? { action: acted } : {}) }
305
+ }
306
+ await sleep(pollMs)
307
+ continue
308
+ }
309
+ if (await checkCondition(o.condition)) {
310
+ if (!o.action) return { ok: true, matched: true, elapsedMs: Date.now() - started }
311
+ const acted = await actByPoint(o.action)
312
+ log({ tool: 'pc_wait', matched: true, action: o.action?.action ?? null, elapsedMs: Date.now() - started })
313
+ return { ok: true, matched: true, elapsedMs: Date.now() - started, action: acted }
314
+ }
315
+ await sleep(pollMs)
316
+ }
317
+ } catch (error) {
318
+ return { ok: false, code: 'WAIT_ERROR', message: String(error?.message ?? error) }
319
+ }
320
+ log({ tool: 'pc_wait', matched: false, timeoutMs })
321
+ return { ok: false, code: 'TIMEOUT', elapsedMs: Date.now() - started, condition: o.condition ?? null }
322
+ }
323
+
324
+ async function runPowerShell(script, timeoutMs = 20000) {
325
+ const res = await run('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { timeoutMs })
326
+ return { code: res.code, out: String(res.stdout || '').trim(), err: String(res.stderr || '').trim() }
327
+ }
328
+
329
+ const PS_MOVE = "Add-Type -Namespace W -Name U -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint);'; [W.U]::MoveWindow([IntPtr]{HWND}, {X}, {Y}, {W}, {H}, \$true)"
330
+ const PS_SHOW = "Add-Type -Namespace W -Name U -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);'; [W.U]::ShowWindow([IntPtr]{HWND}, {CMD})"
331
+ const PS_CLOSE = "Add-Type -Namespace W -Name U -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);'; [W.U]::PostMessage([IntPtr]{HWND}, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)"
332
+
333
+ export async function appControl(o = {}) {
334
+ const action = String(o.action || 'list')
335
+ if (action !== 'list' && isPaused()) return { ok: false, code: 'PAUSED_BY_USER', message: 'operator pause file present', file: PATHS.pauseFile }
336
+ const app = o.app ? String(o.app) : ''
337
+ try {
338
+ if (action === 'list') {
339
+ const ps = "Get-Process | Where-Object { \$_.MainWindowTitle -ne '' } | Select-Object ProcessName,Id,MainWindowTitle | ConvertTo-Json -Compress"
340
+ const r = await runPowerShell(ps)
341
+ let list = []
342
+ try { list = JSON.parse(r.out || '[]') } catch {}
343
+ if (!Array.isArray(list)) list = [list]
344
+ return { ok: true, count: list.length, apps: list.map((x) => ({ process: x.ProcessName, pid: x.Id, title: x.MainWindowTitle })) }
345
+ }
346
+ if (action === 'launch') {
347
+ if (!app) return { ok: false, code: 'BAD_ARGS', message: 'app is required' }
348
+ const args = Array.isArray(o.args) ? o.args : []
349
+ const argLine = args.map((a) => '"' + String(a).replace(/"/g, '') + '"').join(' ')
350
+ const ps = '$p = Start-Process -FilePath ' + JSON.stringify(app) + (argLine ? ' -ArgumentList ' + argLine : '') + ' -PassThru; $p.Id'
351
+ const r = await runPowerShell(ps, 30000)
352
+ const pid = Number(String(r.out || '').trim().split(/\s+/).pop())
353
+ let window = null
354
+ for (let i = 0; i < 40 && o.activate !== false; i += 1) {
355
+ await sleep(250)
356
+ const native = await cuaLib('native')
357
+ const base = app.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase()
358
+ const wins = native.listWindows().filter((w) => w.pid === pid || (base !== '' && (String(w.process || '').toLowerCase().includes(base) || String(w.title || '').toLowerCase().includes(base))))
359
+ if (wins.length > 0) { window = wins[0]; break }
360
+ }
361
+ log({ tool: 'pc_app', action: 'launch', app, pid })
362
+ return { ok: true, action: 'launch', pid: Number.isFinite(pid) ? pid : null, window: window ? { id: Number(window.hwnd), title: window.title } : null }
363
+ }
364
+ const native = await cuaLib('native')
365
+ const win = o.target ? native.findWindow(String(o.target)) : null
366
+ if (action === 'close') {
367
+ if (win) {
368
+ const ps = PS_CLOSE.replace('{HWND}', String(win.hwnd))
369
+ const r = await runPowerShell(ps)
370
+ return { ok: r.code === 0, action: 'close', window: { id: Number(win.hwnd), title: win.title }, detail: r.out || r.err }
371
+ }
372
+ if (!app) return { ok: false, code: 'BAD_ARGS', message: 'target or app is required' }
373
+ const r = await runPowerShell('Stop-Process -Name ' + JSON.stringify(app.replace(/\.exe$/i, '')) + ' -Force -ErrorAction SilentlyContinue; "done"')
374
+ return { ok: r.code === 0, action: 'close', app, detail: r.out }
375
+ }
376
+ if (action === 'minimize' || action === 'maximize' || action === 'restore' || action === 'resize') {
377
+ if (!win) return { ok: false, code: 'WINDOW_NOT_FOUND', message: 'no window matches ' + JSON.stringify(o.target) }
378
+ if (action === 'resize') {
379
+ const w = Math.max(100, Number(o.width ?? 800))
380
+ const h = Math.max(100, Number(o.height ?? 600))
381
+ const ps = PS_MOVE.replace('{HWND}', String(win.hwnd)).replace('{X}', String(Number(win.rect?.x ?? 100))).replace('{Y}', String(Number(win.rect?.y ?? 100))).replace('{W}', String(w)).replace('{H}', String(h))
382
+ const r = await runPowerShell(ps)
383
+ return { ok: r.code === 0, action: 'resize', window: { id: Number(win.hwnd), title: win.title }, size: [w, h], detail: r.out || r.err }
384
+ }
385
+ const cmd = action === 'minimize' ? 6 : action === 'maximize' ? 3 : 9
386
+ const ps = PS_SHOW.replace('{HWND}', String(win.hwnd)).replace('{CMD}', String(cmd))
387
+ const r = await runPowerShell(ps)
388
+ log({ tool: 'pc_app', action, window: win.title })
389
+ return { ok: r.code === 0, action, window: { id: Number(win.hwnd), title: win.title }, detail: r.out || r.err }
390
+ }
391
+ return { ok: false, code: 'BAD_ACTION', message: 'unsupported action: ' + action }
392
+ } catch (error) {
393
+ return { ok: false, code: 'APP_ERROR', message: String(error?.message ?? error) }
394
+ }
395
+ }
396
+
397
+ // ---------------------------------------------------------------- safety / parity
398
+
399
+ export const DEFAULT_BLACKLIST = ['支付', '充值', '购买', '授权', '扫码', '付款', '收银', '转账', '下单', '结算', '微信支付', '支付宝']
400
+
401
+ export function blacklistHit(titles) {
402
+ for (const t of titles) {
403
+ if (!t) continue
404
+ const s = String(t)
405
+ for (const k of DEFAULT_BLACKLIST) if (s.includes(k)) return { keyword: k, title: s }
406
+ }
407
+ return null
408
+ }
409
+
410
+ async function guardTitles(x, y) {
411
+ const native = await cuaLib('native')
412
+ const fg = native.foregroundInfo()
413
+ let at = null
414
+ try { if (x !== undefined && y !== undefined) at = native.windowAtPoint(Number(x), Number(y)) } catch {}
415
+ const hit = blacklistHit([at && at.title, fg && fg.title])
416
+ if (!hit) return null
417
+ log({ tool: 'blacklist', keyword: hit.keyword, window: hit.title })
418
+ return { ok: false, code: 'BLOCKED_KEYWORD', keyword: hit.keyword, window: hit.title, message: 'refused: window title contains blacklisted keyword' }
419
+ }
420
+
421
+ export async function status() {
422
+ return runAgent(['status'], { timeoutMs: 15000 })
423
+ }
424
+
425
+ function frameDiff(a, b, tol) {
426
+ if (!a || !b || a.width !== b.width || a.height !== b.height) return 1
427
+ const n = a.width * a.height
428
+ let diff = 0
429
+ for (let i = 0; i < n; i += 1) {
430
+ const o = i * 4
431
+ if (Math.abs(a.rgba[o] - b.rgba[o]) > tol || Math.abs(a.rgba[o + 1] - b.rgba[o + 1]) > tol || Math.abs(a.rgba[o + 2] - b.rgba[o + 2]) > tol) diff += 1
432
+ }
433
+ return diff / n
434
+ }
435
+
436
+ // ---------------------------------------------------------------- clipboard / notify
437
+
438
+ export async function clipboard(o = {}) {
439
+ const mode = String(o.mode || 'get')
440
+ if (mode === 'get') {
441
+ const r = await runPowerShell('Get-Clipboard -Raw')
442
+ const out = { ok: r.code === 0, mode: 'get', text: r.out }
443
+ if (r.err) out.detail = r.err
444
+ return out
445
+ }
446
+ const text = String(o.text ?? '')
447
+ const b64 = Buffer.from(text, 'utf8').toString('base64')
448
+ const ps = "Set-Clipboard -Value ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64 + "'))); 'ok'"
449
+ const r = await runPowerShell(ps)
450
+ log({ tool: 'pc_clipboard', mode: 'set', length: text.length })
451
+ return { ok: r.code === 0, mode: 'set', length: text.length, detail: r.out || r.err }
452
+ }
453
+
454
+ export async function notify(o = {}) {
455
+ const title = String(o.title || 'DSH')
456
+ const message = String(o.message || '')
457
+ const b64t = Buffer.from(title, 'utf8').toString('base64')
458
+ const b64m = Buffer.from(message, 'utf8').toString('base64')
459
+ const ps = [
460
+ 'Add-Type -AssemblyName System.Windows.Forms',
461
+ 'Add-Type -AssemblyName System.Drawing',
462
+ "$t=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64t + "'))",
463
+ "$m=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64m + "'))",
464
+ '$n=New-Object System.Windows.Forms.NotifyIcon',
465
+ '$n.Icon=[System.Drawing.SystemIcons]::Information',
466
+ '$n.Visible=$true',
467
+ '$n.BalloonTipTitle=$t',
468
+ '$n.BalloonTipText=$m',
469
+ '$n.ShowBalloonTip(3000)',
470
+ 'Start-Sleep -Seconds 4',
471
+ '$n.Visible=$false',
472
+ '$n.Dispose()',
473
+ "'ok'",
474
+ ].join('; ')
475
+ const r = await runPowerShell(ps, 15000)
476
+ log({ tool: 'pc_notify', ok: r.code === 0 })
477
+ return { ok: r.code === 0, title, detail: r.out || r.err }
478
+ }
479
+
480
+ // ---------------------------------------------------------------- display / annotated shot
481
+
482
+ export async function display() {
483
+ const ps = [
484
+ "Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class WIN_TOOLS_DPI { [DllImport(\"user32.dll\")] public static extern bool SetProcessDPIAware(); }'",
485
+ '[WIN_TOOLS_DPI]::SetProcessDPIAware() | Out-Null',
486
+ 'Add-Type -AssemblyName System.Windows.Forms',
487
+ 'Add-Type -AssemblyName System.Drawing',
488
+ '$g=[System.Drawing.Graphics]::FromHwnd([IntPtr]::Zero)',
489
+ '$dpi=[double]$g.DpiX',
490
+ '$screens=[System.Windows.Forms.Screen]::AllScreens | ForEach-Object { [pscustomobject]@{ device=$_.DeviceName; primary=$_.Primary; x=$_.Bounds.X; y=$_.Bounds.Y; width=$_.Bounds.Width; height=$_.Bounds.Height } }',
491
+ '[pscustomobject]@{ dpi=$dpi; monitors=@($screens) } | ConvertTo-Json -Compress -Depth 4',
492
+ ].join('; ')
493
+ const r = await runPowerShell(ps)
494
+ try {
495
+ const data = JSON.parse(r.out)
496
+ return { ok: true, dpi: data.dpi, monitors: Array.isArray(data.monitors) ? data.monitors : [data.monitors] }
497
+ } catch {
498
+ return { ok: false, code: 'DISPLAY_ERROR', detail: r.err || r.out }
499
+ }
500
+ }
501
+
502
+ function collectBoxes(node, out) {
503
+ if (!node) return
504
+ const b = node.bounds
505
+ if (node.ref_id && b && Number(b.width) > 1 && Number(b.height) > 1) {
506
+ out.push({ ref: node.ref_id, role: node.role, name: node.name, x: Number(b.x), y: Number(b.y), w: Number(b.width), h: Number(b.height) })
507
+ }
508
+ for (const child of node.children || []) collectBoxes(child, out)
509
+ }
510
+
511
+ export async function annotatedScreen(o = {}) {
512
+ const shot = await screenshot(o)
513
+ if (!shot || !shot.ok) return shot
514
+ const snap = await runAgent(['snapshot', '--interactive-only', '--include-bounds', '--timeout-ms', String(o.timeoutMs ?? 6000)], { timeoutMs: Number(o.timeoutMs ?? 6000) + 15000 })
515
+ const boxes = []
516
+ collectBoxes(snap && snap.data && snap.data.tree, boxes)
517
+ const scale = shot.sourceWidth ? shot.width / shot.sourceWidth : 1
518
+ const scaled = boxes.slice(0, 80).map((b, i) => ({
519
+ i: i + 1,
520
+ x: Math.round(b.x * scale),
521
+ y: Math.round(b.y * scale),
522
+ w: Math.max(2, Math.round(b.w * scale)),
523
+ h: Math.max(2, Math.round(b.h * scale)),
524
+ }))
525
+ const out = shot.path.replace(/\.png$/i, '') + '-annotated.png'
526
+ const b64 = Buffer.from(JSON.stringify(scaled), 'utf8').toString('base64')
527
+ const ps = [
528
+ 'Add-Type -AssemblyName System.Drawing',
529
+ "$json=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64 + "'))",
530
+ '$boxes=$json | ConvertFrom-Json',
531
+ "$img=[System.Drawing.Image]::FromFile('" + shot.path.replace(/'/g, "''") + "')",
532
+ '$bmp=New-Object System.Drawing.Bitmap $img',
533
+ '$g=[System.Drawing.Graphics]::FromImage($bmp)',
534
+ '$pen=New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255,220,40)),2',
535
+ "$font=New-Object System.Drawing.Font('Consolas',11)",
536
+ '$br=[System.Drawing.Brushes]::Yellow',
537
+ 'foreach($b in $boxes){ $g.DrawRectangle($pen,[int]$b.x,[int]$b.y,[int]$b.w,[int]$b.h); $g.DrawString([string]$b.i,$font,$br,[single]$b.x,[single]$b.y) }',
538
+ "$bmp.Save('" + out.replace(/'/g, "''") + "',[System.Drawing.Imaging.ImageFormat]::Png)",
539
+ '$g.Dispose(); $bmp.Dispose(); $img.Dispose()',
540
+ "'ok'",
541
+ ].join('; ')
542
+ const r = await runPowerShell(ps, 30000)
543
+ log({ tool: 'pc_screen', annotate: true, boxes: scaled.length, ok: r.code === 0 })
544
+ const result = { ...shot, annotatedPath: out, boxes: scaled.length }
545
+ if (r.code !== 0) result.annotateError = r.err || r.out
546
+ return result
547
+ }
548
+
549
+ // ---------------------------------------------------------------- find / read
550
+
551
+ /**
552
+ * Search the accessibility tree without taking a full snapshot.
553
+ * @param {object} o - role/name/value/text/app/windowId filters.
554
+ * @returns {Promise<object>} CLI JSON envelope with candidate elements.
555
+ */
556
+ export async function findElements(o = {}) {
557
+ const a = ['find']
558
+ if (o.app) a.push('--app', String(o.app))
559
+ if (o.windowId) a.push('--window-id', String(o.windowId))
560
+ if (o.role) a.push('--role', String(o.role))
561
+ if (o.name) a.push('--name', String(o.name))
562
+ if (o.value) a.push('--value', String(o.value))
563
+ if (o.text) a.push('--text', String(o.text))
564
+ if (o.timeoutMs) a.push('--timeout-ms', String(o.timeoutMs))
565
+ const res = await runAgent(a, { timeoutMs: Number(o.timeoutMs ?? 15000) + 15000 })
566
+ log({ tool: 'pc_find', role: o.role ?? null, name: o.name ?? null, ok: res?.ok === true })
567
+ return res
568
+ }
569
+
570
+ /**
571
+ * Read one property or state of an element by ref.
572
+ * @param {object} o - ref plus what (text|value|title|bounds|role|states|visible|enabled|...).
573
+ */
574
+ export async function readElement(o = {}) {
575
+ const ref = String(o.ref || '')
576
+ if (!ref) return { ok: false, code: 'BAD_ARGS', message: 'ref is required' }
577
+ const what = String(o.what || 'text')
578
+ const states = ['visible', 'enabled', 'checked', 'focused', 'expanded', 'selected']
579
+ const a = states.includes(what) ? ['is', ref, '--property', what] : ['get', ref, '--property', what]
580
+ const res = await runAgent(a, { timeoutMs: Number(o.timeoutMs ?? 8000) + 12000 })
581
+ log({ tool: 'pc_read', ref, what, ok: res?.ok === true })
582
+ return res
583
+ }