@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/LICENSE +21 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/README.md +89 -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
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
|
+
}
|