@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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screen capture, PNG encoding, scaling, and pixel probes for dsh-cua-win.
|
|
3
|
+
*
|
|
4
|
+
* Every capture keeps TWO artifacts:
|
|
5
|
+
* 1. the full-resolution RGBA frame in an in-memory LRU shot store (this is
|
|
6
|
+
* what pixel_matches / region_ratio / image_changed compare against —
|
|
7
|
+
* physical-pixel exact);
|
|
8
|
+
* 2. the (possibly downscaled) PNG handed to the model as a DSH attachment,
|
|
9
|
+
* so tokens stay sane (default longest edge 1600px).
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-cua-win/lib/screen
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { dirname } from 'node:path'
|
|
15
|
+
import { PNG } from 'pngjs'
|
|
16
|
+
import {
|
|
17
|
+
CONST,
|
|
18
|
+
captureRegion,
|
|
19
|
+
findWindow,
|
|
20
|
+
foregroundInfo,
|
|
21
|
+
native,
|
|
22
|
+
setForeground,
|
|
23
|
+
virtualScreen,
|
|
24
|
+
windowRect,
|
|
25
|
+
} from './native.js'
|
|
26
|
+
|
|
27
|
+
/** In-memory shot store: id -> full-res frame. Bounded, oldest dropped. */
|
|
28
|
+
const SHOT_LIMIT = 8
|
|
29
|
+
const shots = new Map()
|
|
30
|
+
let shotSeq = 0
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Store one frame and return its id.
|
|
34
|
+
* @param {{rgba:Uint8Array,width:number,height:number,rect:object}} frame
|
|
35
|
+
* @returns {string} shot id.
|
|
36
|
+
*/
|
|
37
|
+
function rememberShot(frame) {
|
|
38
|
+
shotSeq += 1
|
|
39
|
+
const id = `shot-${shotSeq}`
|
|
40
|
+
shots.set(id, frame)
|
|
41
|
+
while (shots.size > SHOT_LIMIT) {
|
|
42
|
+
const oldest = shots.keys().next().value
|
|
43
|
+
shots.delete(oldest)
|
|
44
|
+
}
|
|
45
|
+
return id
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @returns a stored shot by id, or null. */
|
|
49
|
+
export function getShot(id) {
|
|
50
|
+
return typeof id === 'string' ? shots.get(id) ?? null : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Biliear-rescale an RGBA frame to a target long edge.
|
|
55
|
+
* @param {Uint8Array} rgba
|
|
56
|
+
* @param {number} w
|
|
57
|
+
* @param {number} h
|
|
58
|
+
* @param {number} longEdge - target longest side in pixels (<=0 = keep).
|
|
59
|
+
* @returns {{rgba:Uint8Array,width:number,height:number,scale:number}}
|
|
60
|
+
*/
|
|
61
|
+
export function rescale(rgba, w, h, longEdge) {
|
|
62
|
+
const target = Number.isFinite(longEdge) && longEdge > 0 ? Math.round(longEdge) : 0
|
|
63
|
+
if (!target || (w <= target && h <= target)) return { rgba, width: w, height: h, scale: 1 }
|
|
64
|
+
const ratio = Math.min(target / w, target / h)
|
|
65
|
+
const nw = Math.max(1, Math.round(w * ratio))
|
|
66
|
+
const nh = Math.max(1, Math.round(h * ratio))
|
|
67
|
+
const out = new Uint8Array(nw * nh * 4)
|
|
68
|
+
for (let y = 0; y < nh; y += 1) {
|
|
69
|
+
const sy = Math.min(h - 1, (y + 0.5) / ratio - 0.5)
|
|
70
|
+
const y0 = Math.max(0, Math.floor(sy))
|
|
71
|
+
const y1 = Math.min(h - 1, y0 + 1)
|
|
72
|
+
const fy = sy - y0
|
|
73
|
+
for (let x = 0; x < nw; x += 1) {
|
|
74
|
+
const sx = Math.min(w - 1, (x + 0.5) / ratio - 0.5)
|
|
75
|
+
const x0 = Math.max(0, Math.floor(sx))
|
|
76
|
+
const x1 = Math.min(w - 1, x0 + 1)
|
|
77
|
+
const fx = sx - x0
|
|
78
|
+
const i = (y * nw + x) * 4
|
|
79
|
+
for (let c = 0; c < 3; c += 1) {
|
|
80
|
+
const p00 = rgba[(y0 * w + x0) * 4 + c]
|
|
81
|
+
const p01 = rgba[(y0 * w + x1) * 4 + c]
|
|
82
|
+
const p10 = rgba[(y1 * w + x0) * 4 + c]
|
|
83
|
+
const p11 = rgba[(y1 * w + x1) * 4 + c]
|
|
84
|
+
out[i + c] = (p00 * (1 - fx) + p01 * fx) * (1 - fy) + (p10 * (1 - fx) + p11 * fx) * fy
|
|
85
|
+
}
|
|
86
|
+
out[i + 3] = 255
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { rgba: out, width: nw, height: nh, scale: ratio }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Encode an RGBA frame as PNG.
|
|
94
|
+
* @param {Uint8Array} rgba
|
|
95
|
+
* @param {number} w
|
|
96
|
+
* @param {number} h
|
|
97
|
+
* @returns {Buffer}
|
|
98
|
+
*/
|
|
99
|
+
export function encodePng(rgba, w, h) {
|
|
100
|
+
const png = new PNG({ width: w, height: h })
|
|
101
|
+
png.data.set(rgba)
|
|
102
|
+
return PNG.sync.write(png, { deflateLevel: 6 })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Capture the screen (or a window, or a rect) at full resolution.
|
|
107
|
+
*
|
|
108
|
+
* target:
|
|
109
|
+
* - "screen": the whole virtual desktop.
|
|
110
|
+
* - "window": a window's rect. The window is brought to the foreground FIRST
|
|
111
|
+
* (BitBlt sees whatever is on top — an occluded window is captured as its
|
|
112
|
+
* occluder), and the result reports who ended up in the foreground.
|
|
113
|
+
* - "rect": an explicit physical-pixel rect [x, y, w, h].
|
|
114
|
+
*
|
|
115
|
+
* @param {object} options - {target, window, rect, bringToFront}
|
|
116
|
+
* @returns {object} {ok, screenshotId, width, height, rect, foreground, scaled, scale, message}
|
|
117
|
+
*/
|
|
118
|
+
export function capture(options = {}) {
|
|
119
|
+
const target = options.target === 'window' || options.target === 'rect' ? options.target : 'screen'
|
|
120
|
+
const vs = virtualScreen()
|
|
121
|
+
let rect
|
|
122
|
+
let foreground = foregroundInfo()
|
|
123
|
+
let bringToFront = options.bringToFront !== false
|
|
124
|
+
|
|
125
|
+
if (target === 'window') {
|
|
126
|
+
const win = findWindow(options.window)
|
|
127
|
+
if (!win) return { ok: false, code: 'WINDOW_NOT_FOUND', message: `no window matches ${JSON.stringify(options.window)}` }
|
|
128
|
+
const wr = windowRect(win.hwnd)
|
|
129
|
+
if (!wr) return { ok: false, code: 'WINDOW_NOT_FOUND', message: 'window rect unavailable' }
|
|
130
|
+
rect = wr
|
|
131
|
+
if (bringToFront) {
|
|
132
|
+
const fg = setForeground(win.hwnd)
|
|
133
|
+
foreground = foregroundInfo()
|
|
134
|
+
if (!fg.foreground) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false, code: 'FOREGROUND_FAILED',
|
|
137
|
+
message: `could not bring "${win.title}" to the foreground (current: "${foreground?.title ?? 'unknown'}"); capture aborted because BitBlt would photograph the occluder`,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} else if (target === 'rect') {
|
|
142
|
+
const r = Array.isArray(options.rect) ? options.rect.map(Number) : null
|
|
143
|
+
if (!r || r.length !== 4 || r.some((v) => !Number.isFinite(v))) {
|
|
144
|
+
return { ok: false, code: 'INVALID_ARGS', message: 'rect must be [x, y, w, h] in physical pixels' }
|
|
145
|
+
}
|
|
146
|
+
rect = { x: r[0], y: r[1], w: Math.round(r[2]), h: Math.round(r[3]) }
|
|
147
|
+
if (rect.w <= 0 || rect.h <= 0) return { ok: false, code: 'INVALID_ARGS', message: 'rect w/h must be positive' }
|
|
148
|
+
} else {
|
|
149
|
+
rect = { x: vs.left, y: vs.top, w: vs.width, h: vs.height }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Clamp capture to the virtual desktop so an off-screen rect cannot hang GDI.
|
|
153
|
+
const x1 = Math.max(vs.left, rect.x)
|
|
154
|
+
const y1 = Math.max(vs.top, rect.y)
|
|
155
|
+
const x2 = Math.min(vs.left + vs.width, rect.x + rect.w)
|
|
156
|
+
const y2 = Math.min(vs.top + vs.height, rect.y + rect.h)
|
|
157
|
+
const w = Math.max(1, Math.round(x2 - x1))
|
|
158
|
+
const h = Math.max(1, Math.round(y2 - y1))
|
|
159
|
+
|
|
160
|
+
let frame
|
|
161
|
+
try {
|
|
162
|
+
frame = captureRegion(x1, y1, w, h)
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return { ok: false, code: 'CAPTURE_FAILED', message: String(error?.message ?? error) }
|
|
165
|
+
}
|
|
166
|
+
const screenshotId = rememberShot({ ...frame, rect: { x: x1, y: y1, w, h } })
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
screenshotId,
|
|
170
|
+
width: w,
|
|
171
|
+
height: h,
|
|
172
|
+
rect: { x: x1, y: y1, w, h },
|
|
173
|
+
foreground: foreground ? { title: foreground.title, pid: foreground.pid, process: foreground.process } : null,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Produce the model-facing PNG for a capture result.
|
|
179
|
+
* @param {string} screenshotId
|
|
180
|
+
* @param {number} longEdge - target longest edge.
|
|
181
|
+
* @returns {{png:Buffer,width:number,height:number,scale:number}|null}
|
|
182
|
+
*/
|
|
183
|
+
export function pngForShot(screenshotId, longEdge) {
|
|
184
|
+
const shot = getShot(screenshotId)
|
|
185
|
+
if (!shot) return null
|
|
186
|
+
const scaled = rescale(shot.rgba, shot.width, shot.height, longEdge)
|
|
187
|
+
return { png: encodePng(scaled.rgba, scaled.width, scaled.height), width: scaled.width, height: scaled.height, scale: scaled.scale }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------- probes
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Parse "#rrggbb" / "rrggbb" into [r,g,b].
|
|
194
|
+
* @param {string} color
|
|
195
|
+
* @returns {number[]|null}
|
|
196
|
+
*/
|
|
197
|
+
export function parseColor(color) {
|
|
198
|
+
if (typeof color !== 'string') return null
|
|
199
|
+
const hex = color.startsWith('#') ? color.slice(1) : color
|
|
200
|
+
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null
|
|
201
|
+
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* pixel_matches: single physical pixel color within tolerance of target.
|
|
206
|
+
* Reads directly from a captured frame (no GDI round-trip inside the poll
|
|
207
|
+
* loop's comparison step — the loop captures, this only compares).
|
|
208
|
+
*
|
|
209
|
+
* @param {object} frame - {rgba,width,height,rect}
|
|
210
|
+
* @param {number} x - physical screen x.
|
|
211
|
+
* @param {number} y - physical screen y.
|
|
212
|
+
* @param {number[]} rgb - [r,g,b].
|
|
213
|
+
* @param {number} tolerance - per-channel.
|
|
214
|
+
* @returns {boolean}
|
|
215
|
+
*/
|
|
216
|
+
export function pixelMatches(frame, x, y, rgb, tolerance) {
|
|
217
|
+
if (!frame) return false
|
|
218
|
+
const { rgba, width, height, rect } = frame
|
|
219
|
+
const px = Math.round(x) - rect.x
|
|
220
|
+
const py = Math.round(y) - rect.y
|
|
221
|
+
if (px < 0 || py < 0 || px >= width || py >= height) return false
|
|
222
|
+
const i = (py * width + px) * 4
|
|
223
|
+
const t = Number.isFinite(tolerance) ? Math.abs(tolerance) : 0
|
|
224
|
+
return (
|
|
225
|
+
Math.abs(rgba[i] - rgb[0]) <= t
|
|
226
|
+
&& Math.abs(rgba[i + 1] - rgb[1]) <= t
|
|
227
|
+
&& Math.abs(rgba[i + 2] - rgb[2]) <= t
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* region_ratio: share of pixels in a rect within tolerance of the color.
|
|
233
|
+
* @param {object} frame - full-frame or rect-frame with .rect.
|
|
234
|
+
* @param {number[]} region - [x,y,w,h] physical.
|
|
235
|
+
* @param {number[]} rgb
|
|
236
|
+
* @param {number} tolerance
|
|
237
|
+
* @returns {number} ratio in [0,1]; -1 when the region misses the frame.
|
|
238
|
+
*/
|
|
239
|
+
export function regionRatio(frame, region, rgb, tolerance) {
|
|
240
|
+
if (!frame) return -1
|
|
241
|
+
const { rgba, width, height, rect } = frame
|
|
242
|
+
const [rx, ry, rw, rh] = region
|
|
243
|
+
const x1 = Math.max(0, Math.round(rx) - rect.x)
|
|
244
|
+
const y1 = Math.max(0, Math.round(ry) - rect.y)
|
|
245
|
+
const x2 = Math.min(width, Math.round(rx + rw) - rect.x)
|
|
246
|
+
const y2 = Math.min(height, Math.round(ry + rh) - rect.y)
|
|
247
|
+
if (x2 <= x1 || y2 <= y1) return -1
|
|
248
|
+
const t = Number.isFinite(tolerance) ? Math.abs(tolerance) : 0
|
|
249
|
+
let hits = 0
|
|
250
|
+
const total = (x2 - x1) * (y2 - y1)
|
|
251
|
+
for (let y = y1; y < y2; y += 1) {
|
|
252
|
+
for (let x = x1; x < x2; x += 1) {
|
|
253
|
+
const i = (y * width + x) * 4
|
|
254
|
+
if (
|
|
255
|
+
Math.abs(rgba[i] - rgb[0]) <= t
|
|
256
|
+
&& Math.abs(rgba[i + 1] - rgb[1]) <= t
|
|
257
|
+
&& Math.abs(rgba[i + 2] - rgb[2]) <= t
|
|
258
|
+
) hits += 1
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return hits / total
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* image_changed: fraction of differing pixels vs a baseline shot, clipped to
|
|
266
|
+
* a rect when given.
|
|
267
|
+
* @param {object} frame - current.
|
|
268
|
+
* @param {object} baseline - stored shot.
|
|
269
|
+
* @param {number[]|undefined} region - [x,y,w,h] physical.
|
|
270
|
+
* @returns {number} change ratio in [0,1]; -1 when not comparable.
|
|
271
|
+
*/
|
|
272
|
+
export function imageChangeRatio(frame, baseline, region) {
|
|
273
|
+
if (!frame || !baseline) return -1
|
|
274
|
+
const { rgba, width, height, rect } = frame
|
|
275
|
+
const b = baseline
|
|
276
|
+
if (b.width !== width || b.height !== height) return -1
|
|
277
|
+
const x1 = Math.max(0, region ? Math.round(region[0]) - rect.x : 0)
|
|
278
|
+
const y1 = Math.max(0, region ? Math.round(region[1]) - rect.y : 0)
|
|
279
|
+
const x2 = Math.min(width, region ? Math.round(region[0] + region[2]) - rect.x : width)
|
|
280
|
+
const y2 = Math.min(height, region ? Math.round(region[1] + region[3]) - rect.y : height)
|
|
281
|
+
if (x2 <= x1 || y2 <= y1) return -1
|
|
282
|
+
let diff = 0
|
|
283
|
+
const total = (x2 - x1) * (y2 - y1)
|
|
284
|
+
for (let y = y1; y < y2; y += 1) {
|
|
285
|
+
for (let x = x1; x < x2; x += 1) {
|
|
286
|
+
const i = (y * width + x) * 4
|
|
287
|
+
if (
|
|
288
|
+
rgba[i] !== b.rgba[i] || rgba[i + 1] !== b.rgba[i + 1] || rgba[i + 2] !== b.rgba[i + 2]
|
|
289
|
+
) diff += 1
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return diff / total
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Save a PNG to disk, creating the directory. Returns the absolute path. */
|
|
296
|
+
export function savePng(path, png) {
|
|
297
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
298
|
+
writeFileSync(path, png)
|
|
299
|
+
return path
|
|
300
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wobuxcm/dsh-win-tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Self-contained Windows desktop-control plugin for DeepSeek Harness: accessibility refs, SendInput pixel actions, GDI screenshots, browser DOM, clipboard, notifications, in-process waits and an operator pause switch.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"lib/",
|
|
14
|
+
"bin/",
|
|
15
|
+
"LICENSES/",
|
|
16
|
+
"THIRD-PARTY-NOTICES.md",
|
|
17
|
+
"cordis.patch.yml",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"dsh": {
|
|
21
|
+
"bundle": {
|
|
22
|
+
"patch": "./cordis.patch.yml"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/wobuxcm/dsh-win-tools.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/wobuxcm/dsh-win-tools#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/wobuxcm/dsh-win-tools/issues"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"koffi": "^2.10.1",
|
|
38
|
+
"pngjs": "^7.0.0"
|
|
39
|
+
},
|
|
40
|
+
"optionalDependencies": {
|
|
41
|
+
"playwright-core": "^1.65.0"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"author": "cjl15",
|
|
45
|
+
"keywords": [
|
|
46
|
+
"dsh",
|
|
47
|
+
"dsh-plugin",
|
|
48
|
+
"deepseek-harness",
|
|
49
|
+
"windows",
|
|
50
|
+
"computer-use",
|
|
51
|
+
"accessibility",
|
|
52
|
+
"sendinput",
|
|
53
|
+
"automation"
|
|
54
|
+
],
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=20"
|
|
57
|
+
}
|
|
58
|
+
}
|