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.
@@ -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
- [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
123
- }
124
- '@
125
-
126
- # Must precede every measurement: makes GetWindowRect report physical pixels,
127
- # matching what CopyFromScreen captures and what SetCursorPos expects.
128
- [void][Cap]::SetProcessDPIAware()
129
-
130
- $h = [IntPtr]::new([int64]${hwnd})
131
- if (-not [Cap]::IsWindow($h)) { 'ERR: bad window handle'; exit 0 }
132
- $wasIconic = [Cap]::IsIconic($h)
133
- [void][Cap]::ShowWindow($h, 9)
134
- [void][Cap]::SetForegroundWindow($h)
135
- Start-Sleep -Milliseconds 1100
136
-
137
- $wr = New-Object Cap+RECT
138
- [void][Cap]::GetWindowRect($h, [ref]$wr)
139
- $w = $wr.R - $wr.L; $ht = $wr.B - $wr.T
140
- if ($w -lt 400 -or $ht -lt 300) { 'ERR: window too small ' + $w + 'x' + $ht; exit 0 }
141
-
142
- $bmp = New-Object System.Drawing.Bitmap $w, $ht
143
- $g = [System.Drawing.Graphics]::FromImage($bmp)
144
- $g.CopyFromScreen($wr.L, $wr.T, 0, 0, $bmp.Size)
145
-
146
- $yMin = ${BAND.yMin}; $yMax = [Math]::Min(${BAND.yMax}, $ht - 1)
147
- $xMin = ${BAND.xMin}; $xMax = [Math]::Min(${BAND.xMax}, $w - 1)
148
- $hits = @()
149
- for ($y = $yMin; $y -le $yMax; $y++) {
150
- for ($x = $xMin; $x -le $xMax; $x++) {
151
- $c = $bmp.GetPixel($x, $y)
152
- $isGreen = ($c.G -ge 90) -and (($c.G - $c.R) -ge 35) -and (($c.G - $c.B) -ge 25)
153
- $isRed = ($c.R -ge 140) -and (($c.R - $c.G) -ge 60) -and (($c.R - $c.B) -ge 50)
154
- if ($isGreen) { $hits += [pscustomobject]@{ x=$x; y=$y; kind='g' } }
155
- elseif ($isRed) { $hits += [pscustomobject]@{ x=$x; y=$y; kind='r' } }
156
- }
157
- }
158
- $bmp.Dispose(); $g.Dispose()
159
-
160
- function Clusters($pts, $kind) {
161
- $sel = @($pts | Where-Object { $_.kind -eq $kind })
162
- if ($sel.Count -eq 0) { return @() }
163
- $xs = $sel | Sort-Object x | Select-Object -ExpandProperty x -Unique
164
- if ($xs.Count -eq 0) { return @() }
165
- $groups = @(); $cur = @($xs[0])
166
- for ($i = 1; $i -lt $xs.Count; $i++) {
167
- if (($xs[$i] - $xs[$i-1]) -le 4) { $cur += $xs[$i] }
168
- else { $groups += ,$cur; $cur = @($xs[$i]) }
169
- }
170
- $groups += ,$cur
171
- $out = @()
172
- foreach ($grp in $groups) {
173
- if ($grp.Count -lt 8) { continue }
174
- $mnx = ($grp | Measure-Object -Minimum).Minimum
175
- $mxx = ($grp | Measure-Object -Maximum).Maximum
176
- $sub = @($sel | Where-Object { $_.x -ge $mnx -and $_.x -le $mxx })
177
- $mny = ($sub | Measure-Object -Property y -Minimum).Minimum
178
- $mxy = ($sub | Measure-Object -Property y -Maximum).Maximum
179
- $cw = $mxx - $mnx + 1
180
- $chh = $mxy - $mny + 1
181
- # A transport button is a compact block, roughly square, 15..45 px.
182
- if ($cw -lt 15 -or $cw -gt 45 -or $chh -lt 15 -or $chh -gt 45) { continue }
183
- $out += [pscustomobject]@{
184
- x = [int](($mnx + $mxx) / 2); y = [int](($mny + $mxy) / 2)
185
- w = [int]$cw; h = [int]$chh
186
- }
187
- }
188
- return $out
189
- }
190
-
191
- [pscustomobject]@{
192
- winW = $w; winH = $ht; wasIconic = $wasIconic
193
- green = @(Clusters $hits 'g')
194
- red = @(Clusters $hits 'r')
195
- } | ConvertTo-Json -Compress -Depth 4
196
- `
197
- const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
198
- encoding: 'utf8',
199
- timeout: 90000,
200
- windowsHide: true,
201
- })
202
- const stdout = (r.stdout || '').trim()
203
- if (!stdout || stdout.startsWith('ERR')) {
204
- return { ok: false, green: [], red: [], error: stdout || r.stderr.slice(0, 300) || 'scan failed' }
205
- }
206
- try {
207
- const data = JSON.parse(stdout.split('\n').pop())
208
- return {
209
- ok: true,
210
- green: (data.green || []).filter(Boolean),
211
- red: (data.red || []).filter(Boolean),
212
- winW: data.winW,
213
- winH: data.winH,
214
- wasIconic: Boolean(data.wasIconic),
215
- }
216
- } catch (error) {
217
- return { ok: false, green: [], red: [], error: `bad scan payload: ${error.message}` }
218
- }
219
- }
220
-
221
- /**
222
- * Run a PowerShell snippet that has the `Cap` type (and therefore DPI awareness)
223
- * available. Kept local so calibration does not depend on the launcher module.
224
- */
225
- function runPowerShell(body, { timeoutMs = 30000 } = {}) {
226
- const script = `
227
- $ErrorActionPreference = 'Stop'
228
- Add-Type @'
229
- using System;
230
- using System.Runtime.InteropServices;
231
- public class Cap {
232
- [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
233
- [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
234
- [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
235
- [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
236
- }
237
- '@ -ErrorAction Stop
238
- [void][Cap]::SetProcessDPIAware()
239
- ${body}
240
- `
241
- const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
242
- encoding: 'utf8',
243
- timeout: timeoutMs,
244
- windowsHide: true,
245
- })
246
- return { ok: r.status === 0, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }
247
- }
248
-
249
- /**
250
- * Locate the transport buttons.
251
- *
252
- * The stop square always sits just right of the play triangle, so we anchor on
253
- * the green one and take the nearest red cluster to its right; that pairing is
254
- * stable even when other green/red glyphs exist elsewhere in the toolbar.
255
- *
256
- * @param {string} hwnd
257
- * @param {{useDefaultsOnFailure?: boolean}} [options]
258
- */
259
- export function calibrateAnchors(hwnd, { useDefaultsOnFailure = true, refresh = false } = {}) {
260
- let geometry
261
- if (!refresh && geometryByHwnd.has(hwnd)) {
262
- geometry = { ok: true, stdout: geometryByHwnd.get(hwnd) }
263
- } else {
264
- geometry = runPowerShell(
265
- `$r = New-Object Cap+RECT
266
- [void][Cap]::GetWindowRect([IntPtr]::new([int64]${hwnd}), [ref]$r)
267
- '' + ($r.R-$r.L) + 'x' + ($r.B-$r.T)`,
268
- )
269
- if (geometry.ok) geometryByHwnd.set(hwnd, geometry.stdout)
270
- }
271
- // Normalise hard: PowerShell returns CRLF and may append a BOM, and an unstable
272
- // key silently disables the cache.
273
- const key = geometry.ok
274
- ? geometry.stdout.replace(/[\uFEFF\r\n]+/g, ' ').trim().split(/\s+/).pop() || null
275
- : null
276
-
277
- if (!refresh && key) {
278
- const memoryHit = anchorCache.get(key)
279
- if (memoryHit) return { ...memoryHit, detail: { ...memoryHit.detail, cached: 'memory' } }
280
- const diskHit = loadDiskCache()[key]
281
- if (diskHit) {
282
- anchorCache.set(key, diskHit)
283
- return { ...diskHit, detail: { ...diskHit.detail, cached: 'disk' } }
284
- }
285
- }
286
-
287
- const scan = scanToolbar(hwnd)
288
- if (scan.ok && scan.green.length) {
289
- const start = scan.green.sort((a, b) => b.x - a.x)[0]
290
- const stop =
291
- scan.red
292
- .filter((c) => c.x > start.x && c.x - start.x < 130)
293
- .sort((a, b) => a.x - b.x)[0] ?? null
294
- const result = {
295
- start: { x: start.x, y: start.y },
296
- stop: stop ? { x: stop.x, y: stop.y } : { ...DEFAULT_ANCHORS.stop },
297
- method: 'measured',
298
- wasIconic: scan.wasIconic,
299
- detail: {
300
- window: `${scan.winW}x${scan.winH}`,
301
- greenClusters: scan.green.length,
302
- redClusters: scan.red.length,
303
- pickedStart: start,
304
- pickedStop: stop,
305
- },
306
- }
307
- if (key) {
308
- anchorCache.set(key, result)
309
- saveDiskCache({ ...loadDiskCache(), [key]: result })
310
- }
311
- return result
312
- }
313
- if (!useDefaultsOnFailure) {
314
- throw new Error(`could not locate toolbar buttons: ${scan.error ?? 'no green cluster'}`)
315
- }
316
- return {
317
- start: { ...DEFAULT_ANCHORS.start },
318
- stop: { ...DEFAULT_ANCHORS.stop },
319
- method: 'default',
320
- detail: {
321
- reason: scan.error ?? 'no button-sized green cluster on the toolbar',
322
- greenClusters: scan.green?.length ?? 0,
323
- redClusters: scan.red?.length ?? 0,
324
- window: scan.ok ? `${scan.winW}x${scan.winH}` : null,
325
- },
326
- }
327
- }
328
-
329
- /**
330
- * Report anchors without clicking — used by `ensp probe`.
331
- * @param {string} hwnd
332
- */
333
- export function inspectAnchors(hwnd) {
334
- const scan = scanToolbar(hwnd)
335
- return {
336
- ok: scan.ok,
337
- window: scan.ok ? `${scan.winW}x${scan.winH}` : null,
338
- wasIconic: scan.wasIconic,
339
- green: scan.green ?? [],
340
- red: scan.red ?? [],
341
- resolved: scan.ok ? calibrateAnchors(hwnd) : null,
342
- defaults: DEFAULT_ANCHORS,
343
- error: scan.error,
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
+ }