@wobuxcm/dsh-win-tools 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }