dsh-ensp 0.1.9 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -9
- package/docs/SCENARIOS.md +53 -3
- package/docs/plugin-detail.en.md +1 -0
- package/docs/plugin-detail.zh.md +1 -0
- package/package.json +70 -70
- package/scripts/gen-scenarios-doc.mjs +6 -1
- package/src/cli/ensp.mjs +95 -2
- package/src/core/acceptance.mjs +345 -0
- package/src/core/calibrate.mjs +354 -345
- package/src/core/doctor.mjs +369 -0
- package/src/core/index.mjs +23 -0
- package/src/core/launcher.mjs +22 -1
- package/src/dsh/index.mjs +105 -1
- package/src/mcp/server.mjs +42 -0
package/src/core/calibrate.mjs
CHANGED
|
@@ -1,345 +1,354 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Toolbar anchor calibration.
|
|
3
|
-
*
|
|
4
|
-
* ## The DPI trap (root cause of every coordinate bug in this project)
|
|
5
|
-
*
|
|
6
|
-
* This machine runs at **150 % scaling**, and that broke coordinate handling in a
|
|
7
|
-
* way that produced confident, wrong answers:
|
|
8
|
-
*
|
|
9
|
-
* | call site | frame | value for the window |
|
|
10
|
-
* |---------------------------------|---------------------------|----------------------|
|
|
11
|
-
* | `GetWindowRect` in a DPI-unaware process | logical (scaled) | 213,133 1280x800 |
|
|
12
|
-
* | `GetWindowRect` after `SetProcessDPIAware` | physical | 320,200 1920x1200 |
|
|
13
|
-
* | `CopyFromScreen` | always physical pixels | 1920x1200 image |
|
|
14
|
-
*
|
|
15
|
-
* Mixing them silently shifts every click by the scale factor: a "measured"
|
|
16
|
-
* (1099,151) looked plausible but landed on blank toolbar, and a click that
|
|
17
|
-
* seemed to work was actually a coincidence.
|
|
18
|
-
*
|
|
19
|
-
* **Rule: this module declares DPI awareness first and then uses only physical
|
|
20
|
-
* pixels.** `launcher.clickToolbar` does the same, so anchors from here are
|
|
21
|
-
* directly usable there.
|
|
22
|
-
*
|
|
23
|
-
* ## Anchors
|
|
24
|
-
*
|
|
25
|
-
* Window-physical-relative, measured on eNSP 1.2 at 1920x1200 physical:
|
|
26
|
-
* start ("开启设备", green triangle) and stop ("停止设备", red square) are the
|
|
27
|
-
* transport pair at the right end of the toolbar.
|
|
28
|
-
*
|
|
29
|
-
* The calibration searches a band rather than a single row, because the toolbar's
|
|
30
|
-
* y offset inside the window depends on title-bar height and DPI.
|
|
31
|
-
*
|
|
32
|
-
* @module dsh-ensp/calibrate
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import { spawnSync } from 'node:child_process'
|
|
36
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
37
|
-
import { tmpdir } from 'node:os'
|
|
38
|
-
import path from 'node:path'
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Fallback anchors in **physical** pixels, window-relative.
|
|
42
|
-
* Derived from the measured 150 % layout; correct on this class of machine and
|
|
43
|
-
* close enough elsewhere that the band search normally overrides them.
|
|
44
|
-
*/
|
|
45
|
-
export const DEFAULT_ANCHORS = {
|
|
46
|
-
start: { x: 987, y: 226 },
|
|
47
|
-
stop: { x: 1049, y: 228 },
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Cache of measured anchors.
|
|
52
|
-
*
|
|
53
|
-
* Calibration costs a screenshot plus a PowerShell round trip (~1.3 s). The result
|
|
54
|
-
* only changes when the window size changes, so caching it by geometry turns the
|
|
55
|
-
* second and later runs into an instant decision. `resetCalibrationCache()` exists
|
|
56
|
-
* for the case where a user re-runs after changing DPI or eNSP version.
|
|
57
|
-
*/
|
|
58
|
-
const anchorCache = new Map()
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* `hwnd -> geometry` memo. Learning the cache key costs a PowerShell round trip
|
|
62
|
-
* (~1.4 s), so we remember it per window handle: a window does not change size
|
|
63
|
-
* between our own calls.
|
|
64
|
-
*/
|
|
65
|
-
const geometryByHwnd = new Map()
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* On-disk cache.
|
|
69
|
-
*
|
|
70
|
-
* Calibration is invoked from short-lived processes (the CLI, each MCP tool call),
|
|
71
|
-
* so a memory-only cache never hits. The window geometry is a stable key, and a
|
|
72
|
-
* stale file can only cause a wrong click if the user changed DPI *and* the window
|
|
73
|
-
* size stayed identical — `refresh: true` covers that case.
|
|
74
|
-
*/
|
|
75
|
-
const CACHE_FILE = path.join(tmpdir(), 'dsh-ensp-anchors.json')
|
|
76
|
-
|
|
77
|
-
function loadDiskCache() {
|
|
78
|
-
try {
|
|
79
|
-
return JSON.parse(readFileSync(CACHE_FILE, 'utf8'))
|
|
80
|
-
} catch {
|
|
81
|
-
return {}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function saveDiskCache(cache) {
|
|
86
|
-
try {
|
|
87
|
-
mkdirSync(path.dirname(CACHE_FILE), { recursive: true })
|
|
88
|
-
writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2))
|
|
89
|
-
} catch {
|
|
90
|
-
/* a cache that cannot be written is not an error */
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Forget every cached measurement, in memory and on disk. */
|
|
95
|
-
export function resetCalibrationCache() {
|
|
96
|
-
anchorCache.clear()
|
|
97
|
-
saveDiskCache({})
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Band to search, physical pixels, window-relative. */
|
|
101
|
-
const BAND = { yMin: 40, yMax: 130, xMin: 20, xMax: 1900 }
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Capture the window and locate the coloured transport buttons.
|
|
105
|
-
*
|
|
106
|
-
* @param {string} hwnd
|
|
107
|
-
*/
|
|
108
|
-
function scanToolbar(hwnd) {
|
|
109
|
-
const script = String.raw`
|
|
110
|
-
$ErrorActionPreference = 'Stop'
|
|
111
|
-
Add-Type -AssemblyName System.Drawing
|
|
112
|
-
Add-Type @'
|
|
113
|
-
using System;
|
|
114
|
-
using System.Runtime.InteropServices;
|
|
115
|
-
public class Cap {
|
|
116
|
-
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
|
117
|
-
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
|
118
|
-
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
119
|
-
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
|
|
120
|
-
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
|
121
|
-
[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
|
|
122
|
-
[
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
[
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
$
|
|
138
|
-
[void][Cap]::
|
|
139
|
-
$
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
$
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
$
|
|
148
|
-
$
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
$
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
$
|
|
171
|
-
$
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
$
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
[pscustomobject]@{
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
},
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Toolbar anchor calibration.
|
|
3
|
+
*
|
|
4
|
+
* ## The DPI trap (root cause of every coordinate bug in this project)
|
|
5
|
+
*
|
|
6
|
+
* This machine runs at **150 % scaling**, and that broke coordinate handling in a
|
|
7
|
+
* way that produced confident, wrong answers:
|
|
8
|
+
*
|
|
9
|
+
* | call site | frame | value for the window |
|
|
10
|
+
* |---------------------------------|---------------------------|----------------------|
|
|
11
|
+
* | `GetWindowRect` in a DPI-unaware process | logical (scaled) | 213,133 1280x800 |
|
|
12
|
+
* | `GetWindowRect` after `SetProcessDPIAware` | physical | 320,200 1920x1200 |
|
|
13
|
+
* | `CopyFromScreen` | always physical pixels | 1920x1200 image |
|
|
14
|
+
*
|
|
15
|
+
* Mixing them silently shifts every click by the scale factor: a "measured"
|
|
16
|
+
* (1099,151) looked plausible but landed on blank toolbar, and a click that
|
|
17
|
+
* seemed to work was actually a coincidence.
|
|
18
|
+
*
|
|
19
|
+
* **Rule: this module declares DPI awareness first and then uses only physical
|
|
20
|
+
* pixels.** `launcher.clickToolbar` does the same, so anchors from here are
|
|
21
|
+
* directly usable there.
|
|
22
|
+
*
|
|
23
|
+
* ## Anchors
|
|
24
|
+
*
|
|
25
|
+
* Window-physical-relative, measured on eNSP 1.2 at 1920x1200 physical:
|
|
26
|
+
* start ("开启设备", green triangle) and stop ("停止设备", red square) are the
|
|
27
|
+
* transport pair at the right end of the toolbar.
|
|
28
|
+
*
|
|
29
|
+
* The calibration searches a band rather than a single row, because the toolbar's
|
|
30
|
+
* y offset inside the window depends on title-bar height and DPI.
|
|
31
|
+
*
|
|
32
|
+
* @module dsh-ensp/calibrate
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { spawnSync } from 'node:child_process'
|
|
36
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
37
|
+
import { tmpdir } from 'node:os'
|
|
38
|
+
import path from 'node:path'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fallback anchors in **physical** pixels, window-relative.
|
|
42
|
+
* Derived from the measured 150 % layout; correct on this class of machine and
|
|
43
|
+
* close enough elsewhere that the band search normally overrides them.
|
|
44
|
+
*/
|
|
45
|
+
export const DEFAULT_ANCHORS = {
|
|
46
|
+
start: { x: 987, y: 226 },
|
|
47
|
+
stop: { x: 1049, y: 228 },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Cache of measured anchors.
|
|
52
|
+
*
|
|
53
|
+
* Calibration costs a screenshot plus a PowerShell round trip (~1.3 s). The result
|
|
54
|
+
* only changes when the window size changes, so caching it by geometry turns the
|
|
55
|
+
* second and later runs into an instant decision. `resetCalibrationCache()` exists
|
|
56
|
+
* for the case where a user re-runs after changing DPI or eNSP version.
|
|
57
|
+
*/
|
|
58
|
+
const anchorCache = new Map()
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `hwnd -> geometry` memo. Learning the cache key costs a PowerShell round trip
|
|
62
|
+
* (~1.4 s), so we remember it per window handle: a window does not change size
|
|
63
|
+
* between our own calls.
|
|
64
|
+
*/
|
|
65
|
+
const geometryByHwnd = new Map()
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* On-disk cache.
|
|
69
|
+
*
|
|
70
|
+
* Calibration is invoked from short-lived processes (the CLI, each MCP tool call),
|
|
71
|
+
* so a memory-only cache never hits. The window geometry is a stable key, and a
|
|
72
|
+
* stale file can only cause a wrong click if the user changed DPI *and* the window
|
|
73
|
+
* size stayed identical — `refresh: true` covers that case.
|
|
74
|
+
*/
|
|
75
|
+
const CACHE_FILE = path.join(tmpdir(), 'dsh-ensp-anchors.json')
|
|
76
|
+
|
|
77
|
+
function loadDiskCache() {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(readFileSync(CACHE_FILE, 'utf8'))
|
|
80
|
+
} catch {
|
|
81
|
+
return {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function saveDiskCache(cache) {
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(path.dirname(CACHE_FILE), { recursive: true })
|
|
88
|
+
writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2))
|
|
89
|
+
} catch {
|
|
90
|
+
/* a cache that cannot be written is not an error */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Forget every cached measurement, in memory and on disk. */
|
|
95
|
+
export function resetCalibrationCache() {
|
|
96
|
+
anchorCache.clear()
|
|
97
|
+
saveDiskCache({})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Band to search, physical pixels, window-relative. */
|
|
101
|
+
const BAND = { yMin: 40, yMax: 130, xMin: 20, xMax: 1900 }
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Capture the window and locate the coloured transport buttons.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} hwnd
|
|
107
|
+
*/
|
|
108
|
+
function scanToolbar(hwnd) {
|
|
109
|
+
const script = String.raw`
|
|
110
|
+
$ErrorActionPreference = 'Stop'
|
|
111
|
+
Add-Type -AssemblyName System.Drawing
|
|
112
|
+
Add-Type @'
|
|
113
|
+
using System;
|
|
114
|
+
using System.Runtime.InteropServices;
|
|
115
|
+
public class Cap {
|
|
116
|
+
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
|
117
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
|
118
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
119
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
|
|
120
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
|
121
|
+
[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
|
|
122
|
+
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h, uint msg, IntPtr wp, IntPtr lp);
|
|
123
|
+
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
|
|
124
|
+
}
|
|
125
|
+
'@
|
|
126
|
+
|
|
127
|
+
# Must precede every measurement: makes GetWindowRect report physical pixels,
|
|
128
|
+
# matching what CopyFromScreen captures and what SetCursorPos expects.
|
|
129
|
+
[void][Cap]::SetProcessDPIAware()
|
|
130
|
+
|
|
131
|
+
$h = [IntPtr]::new([int64]${hwnd})
|
|
132
|
+
if (-not [Cap]::IsWindow($h)) { 'ERR: bad window handle'; exit 0 }
|
|
133
|
+
$wasIconic = [Cap]::IsIconic($h)
|
|
134
|
+
# ShowWindow(SW_RESTORE) silently fails on this MFC frame and leaves the window at
|
|
135
|
+
# its 237x39 icon size, which then fails the size check below. SC_RESTORE is what
|
|
136
|
+
# the system menu sends and it does restore the frame.
|
|
137
|
+
if ($wasIconic) {
|
|
138
|
+
[void][Cap]::ShowWindow($h, 9)
|
|
139
|
+
[void][Cap]::SendMessage($h, 0x0112, [IntPtr]::new(0xF120), [IntPtr]::Zero)
|
|
140
|
+
Start-Sleep -Milliseconds 700
|
|
141
|
+
}
|
|
142
|
+
[void][Cap]::SetForegroundWindow($h)
|
|
143
|
+
Start-Sleep -Milliseconds 1100
|
|
144
|
+
|
|
145
|
+
$wr = New-Object Cap+RECT
|
|
146
|
+
[void][Cap]::GetWindowRect($h, [ref]$wr)
|
|
147
|
+
$w = $wr.R - $wr.L; $ht = $wr.B - $wr.T
|
|
148
|
+
if ($w -lt 400 -or $ht -lt 300) { 'ERR: window too small ' + $w + 'x' + $ht; exit 0 }
|
|
149
|
+
|
|
150
|
+
$bmp = New-Object System.Drawing.Bitmap $w, $ht
|
|
151
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
152
|
+
$g.CopyFromScreen($wr.L, $wr.T, 0, 0, $bmp.Size)
|
|
153
|
+
|
|
154
|
+
$yMin = ${BAND.yMin}; $yMax = [Math]::Min(${BAND.yMax}, $ht - 1)
|
|
155
|
+
$xMin = ${BAND.xMin}; $xMax = [Math]::Min(${BAND.xMax}, $w - 1)
|
|
156
|
+
$hits = @()
|
|
157
|
+
for ($y = $yMin; $y -le $yMax; $y++) {
|
|
158
|
+
for ($x = $xMin; $x -le $xMax; $x++) {
|
|
159
|
+
$c = $bmp.GetPixel($x, $y)
|
|
160
|
+
$isGreen = ($c.G -ge 90) -and (($c.G - $c.R) -ge 35) -and (($c.G - $c.B) -ge 25)
|
|
161
|
+
$isRed = ($c.R -ge 140) -and (($c.R - $c.G) -ge 60) -and (($c.R - $c.B) -ge 50)
|
|
162
|
+
if ($isGreen) { $hits += [pscustomobject]@{ x=$x; y=$y; kind='g' } }
|
|
163
|
+
elseif ($isRed) { $hits += [pscustomobject]@{ x=$x; y=$y; kind='r' } }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
$bmp.Dispose(); $g.Dispose()
|
|
167
|
+
|
|
168
|
+
function Clusters($pts, $kind) {
|
|
169
|
+
$sel = @($pts | Where-Object { $_.kind -eq $kind })
|
|
170
|
+
if ($sel.Count -eq 0) { return @() }
|
|
171
|
+
$xs = $sel | Sort-Object x | Select-Object -ExpandProperty x -Unique
|
|
172
|
+
if ($xs.Count -eq 0) { return @() }
|
|
173
|
+
$groups = @(); $cur = @($xs[0])
|
|
174
|
+
for ($i = 1; $i -lt $xs.Count; $i++) {
|
|
175
|
+
if (($xs[$i] - $xs[$i-1]) -le 4) { $cur += $xs[$i] }
|
|
176
|
+
else { $groups += ,$cur; $cur = @($xs[$i]) }
|
|
177
|
+
}
|
|
178
|
+
$groups += ,$cur
|
|
179
|
+
$out = @()
|
|
180
|
+
foreach ($grp in $groups) {
|
|
181
|
+
if ($grp.Count -lt 8) { continue }
|
|
182
|
+
$mnx = ($grp | Measure-Object -Minimum).Minimum
|
|
183
|
+
$mxx = ($grp | Measure-Object -Maximum).Maximum
|
|
184
|
+
$sub = @($sel | Where-Object { $_.x -ge $mnx -and $_.x -le $mxx })
|
|
185
|
+
$mny = ($sub | Measure-Object -Property y -Minimum).Minimum
|
|
186
|
+
$mxy = ($sub | Measure-Object -Property y -Maximum).Maximum
|
|
187
|
+
$cw = $mxx - $mnx + 1
|
|
188
|
+
$chh = $mxy - $mny + 1
|
|
189
|
+
# A transport button is a compact block, roughly square, 15..45 px.
|
|
190
|
+
if ($cw -lt 15 -or $cw -gt 45 -or $chh -lt 15 -or $chh -gt 45) { continue }
|
|
191
|
+
$out += [pscustomobject]@{
|
|
192
|
+
x = [int](($mnx + $mxx) / 2); y = [int](($mny + $mxy) / 2)
|
|
193
|
+
w = [int]$cw; h = [int]$chh
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return $out
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
[pscustomobject]@{
|
|
200
|
+
winW = $w; winH = $ht; wasIconic = $wasIconic
|
|
201
|
+
green = @(Clusters $hits 'g')
|
|
202
|
+
red = @(Clusters $hits 'r')
|
|
203
|
+
} | ConvertTo-Json -Compress -Depth 4
|
|
204
|
+
`
|
|
205
|
+
const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
206
|
+
encoding: 'utf8',
|
|
207
|
+
timeout: 90000,
|
|
208
|
+
windowsHide: true,
|
|
209
|
+
})
|
|
210
|
+
const stdout = (r.stdout || '').trim()
|
|
211
|
+
if (!stdout || stdout.startsWith('ERR')) {
|
|
212
|
+
return { ok: false, green: [], red: [], error: stdout || r.stderr.slice(0, 300) || 'scan failed' }
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const data = JSON.parse(stdout.split('\n').pop())
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
green: (data.green || []).filter(Boolean),
|
|
219
|
+
red: (data.red || []).filter(Boolean),
|
|
220
|
+
winW: data.winW,
|
|
221
|
+
winH: data.winH,
|
|
222
|
+
wasIconic: Boolean(data.wasIconic),
|
|
223
|
+
}
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return { ok: false, green: [], red: [], error: `bad scan payload: ${error.message}` }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Run a PowerShell snippet that has the `Cap` type (and therefore DPI awareness)
|
|
231
|
+
* available. Kept local so calibration does not depend on the launcher module.
|
|
232
|
+
*/
|
|
233
|
+
function runPowerShell(body, { timeoutMs = 30000 } = {}) {
|
|
234
|
+
const script = `
|
|
235
|
+
$ErrorActionPreference = 'Stop'
|
|
236
|
+
Add-Type @'
|
|
237
|
+
using System;
|
|
238
|
+
using System.Runtime.InteropServices;
|
|
239
|
+
public class Cap {
|
|
240
|
+
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
|
241
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
|
242
|
+
[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
|
|
243
|
+
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h, uint msg, IntPtr wp, IntPtr lp);
|
|
244
|
+
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
|
|
245
|
+
}
|
|
246
|
+
'@ -ErrorAction Stop
|
|
247
|
+
[void][Cap]::SetProcessDPIAware()
|
|
248
|
+
${body}
|
|
249
|
+
`
|
|
250
|
+
const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
251
|
+
encoding: 'utf8',
|
|
252
|
+
timeout: timeoutMs,
|
|
253
|
+
windowsHide: true,
|
|
254
|
+
})
|
|
255
|
+
return { ok: r.status === 0, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Locate the transport buttons.
|
|
260
|
+
*
|
|
261
|
+
* The stop square always sits just right of the play triangle, so we anchor on
|
|
262
|
+
* the green one and take the nearest red cluster to its right; that pairing is
|
|
263
|
+
* stable even when other green/red glyphs exist elsewhere in the toolbar.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} hwnd
|
|
266
|
+
* @param {{useDefaultsOnFailure?: boolean}} [options]
|
|
267
|
+
*/
|
|
268
|
+
export function calibrateAnchors(hwnd, { useDefaultsOnFailure = true, refresh = false } = {}) {
|
|
269
|
+
let geometry
|
|
270
|
+
if (!refresh && geometryByHwnd.has(hwnd)) {
|
|
271
|
+
geometry = { ok: true, stdout: geometryByHwnd.get(hwnd) }
|
|
272
|
+
} else {
|
|
273
|
+
geometry = runPowerShell(
|
|
274
|
+
`$r = New-Object Cap+RECT
|
|
275
|
+
[void][Cap]::GetWindowRect([IntPtr]::new([int64]${hwnd}), [ref]$r)
|
|
276
|
+
'' + ($r.R-$r.L) + 'x' + ($r.B-$r.T)`,
|
|
277
|
+
)
|
|
278
|
+
if (geometry.ok) geometryByHwnd.set(hwnd, geometry.stdout)
|
|
279
|
+
}
|
|
280
|
+
// Normalise hard: PowerShell returns CRLF and may append a BOM, and an unstable
|
|
281
|
+
// key silently disables the cache.
|
|
282
|
+
const key = geometry.ok
|
|
283
|
+
? geometry.stdout.replace(/[\uFEFF\r\n]+/g, ' ').trim().split(/\s+/).pop() || null
|
|
284
|
+
: null
|
|
285
|
+
|
|
286
|
+
if (!refresh && key) {
|
|
287
|
+
const memoryHit = anchorCache.get(key)
|
|
288
|
+
if (memoryHit) return { ...memoryHit, detail: { ...memoryHit.detail, cached: 'memory' } }
|
|
289
|
+
const diskHit = loadDiskCache()[key]
|
|
290
|
+
if (diskHit) {
|
|
291
|
+
anchorCache.set(key, diskHit)
|
|
292
|
+
return { ...diskHit, detail: { ...diskHit.detail, cached: 'disk' } }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const scan = scanToolbar(hwnd)
|
|
297
|
+
if (scan.ok && scan.green.length) {
|
|
298
|
+
const start = scan.green.sort((a, b) => b.x - a.x)[0]
|
|
299
|
+
const stop =
|
|
300
|
+
scan.red
|
|
301
|
+
.filter((c) => c.x > start.x && c.x - start.x < 130)
|
|
302
|
+
.sort((a, b) => a.x - b.x)[0] ?? null
|
|
303
|
+
const result = {
|
|
304
|
+
start: { x: start.x, y: start.y },
|
|
305
|
+
stop: stop ? { x: stop.x, y: stop.y } : { ...DEFAULT_ANCHORS.stop },
|
|
306
|
+
method: 'measured',
|
|
307
|
+
wasIconic: scan.wasIconic,
|
|
308
|
+
detail: {
|
|
309
|
+
window: `${scan.winW}x${scan.winH}`,
|
|
310
|
+
greenClusters: scan.green.length,
|
|
311
|
+
redClusters: scan.red.length,
|
|
312
|
+
pickedStart: start,
|
|
313
|
+
pickedStop: stop,
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
if (key) {
|
|
317
|
+
anchorCache.set(key, result)
|
|
318
|
+
saveDiskCache({ ...loadDiskCache(), [key]: result })
|
|
319
|
+
}
|
|
320
|
+
return result
|
|
321
|
+
}
|
|
322
|
+
if (!useDefaultsOnFailure) {
|
|
323
|
+
throw new Error(`could not locate toolbar buttons: ${scan.error ?? 'no green cluster'}`)
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
start: { ...DEFAULT_ANCHORS.start },
|
|
327
|
+
stop: { ...DEFAULT_ANCHORS.stop },
|
|
328
|
+
method: 'default',
|
|
329
|
+
detail: {
|
|
330
|
+
reason: scan.error ?? 'no button-sized green cluster on the toolbar',
|
|
331
|
+
greenClusters: scan.green?.length ?? 0,
|
|
332
|
+
redClusters: scan.red?.length ?? 0,
|
|
333
|
+
window: scan.ok ? `${scan.winW}x${scan.winH}` : null,
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Report anchors without clicking — used by `ensp probe`.
|
|
340
|
+
* @param {string} hwnd
|
|
341
|
+
*/
|
|
342
|
+
export function inspectAnchors(hwnd) {
|
|
343
|
+
const scan = scanToolbar(hwnd)
|
|
344
|
+
return {
|
|
345
|
+
ok: scan.ok,
|
|
346
|
+
window: scan.ok ? `${scan.winW}x${scan.winH}` : null,
|
|
347
|
+
wasIconic: scan.wasIconic,
|
|
348
|
+
green: scan.green ?? [],
|
|
349
|
+
red: scan.red ?? [],
|
|
350
|
+
resolved: scan.ok ? calibrateAnchors(hwnd) : null,
|
|
351
|
+
defaults: DEFAULT_ANCHORS,
|
|
352
|
+
error: scan.error,
|
|
353
|
+
}
|
|
354
|
+
}
|