dsh-ensp 0.1.3 → 0.1.7
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 +86 -5
- package/docs/SCENARIOS.md +1410 -2
- package/docs/UI-ANCHORS.md +52 -0
- package/docs/plugin-detail.en.md +3 -2
- package/docs/plugin-detail.zh.md +3 -2
- package/package.json +5 -15
- package/scripts/gen-scenarios-doc.mjs +173 -0
- package/src/cli/ensp.mjs +462 -410
- package/src/core/build.mjs +218 -299
- package/src/core/compat.mjs +226 -0
- package/src/core/index.mjs +44 -4
- package/src/core/labs.mjs +1019 -0
- package/src/core/launcher.mjs +884 -465
- package/src/core/models.mjs +212 -0
- package/src/core/presets.mjs +583 -0
- package/src/core/provision.mjs +81 -0
- package/src/core/topology.mjs +259 -315
- package/src/core/version.mjs +22 -0
- package/src/dsh/index.mjs +470 -232
- package/src/mcp/server.mjs +438 -406
package/src/core/launcher.mjs
CHANGED
|
@@ -1,465 +1,884 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* eNSP launcher: open a topology, start its devices, and hand the desktop back.
|
|
3
|
-
*
|
|
4
|
-
* ## Why this drives the GUI at all
|
|
5
|
-
*
|
|
6
|
-
* Starting devices has no API. It was tested exhaustively and ruled out:
|
|
7
|
-
*
|
|
8
|
-
* | Approach | Result |
|
|
9
|
-
* |---------------------------------|-----------------------------------------------------|
|
|
10
|
-
* | `bootmode="1"` in the .topo | devices still stay off |
|
|
11
|
-
* | VBoxManage `startvm` on a clone | VM runs, but no console: AR_Base has `uart1="off"` |
|
|
12
|
-
* | eNSP control port (65514/7301) | refuses connections / undocumented protocol |
|
|
13
|
-
* | writing `WM_COMMAND` ids | eNSP crashes; the toolbar has no enumerable ids |
|
|
14
|
-
*
|
|
15
|
-
* eNSP's console is **not** on the VM's serial port — a guest agent dials back to
|
|
16
|
-
* a `-consoleip/-consoleport` pair that only the eNSP client allocates. So the
|
|
17
|
-
* client must be the one to start devices, and the only way to ask it is the
|
|
18
|
-
* "Start all devices" toolbar button.
|
|
19
|
-
*
|
|
20
|
-
* ## Readiness is measured, never guessed
|
|
21
|
-
*
|
|
22
|
-
* Boot time varies enormously between models, images and host load, so **no fixed
|
|
23
|
-
* timeout decides success**. Each device is polled on its own console port until
|
|
24
|
-
* VRP actually answers with a prompt; a device that never answers is reported as
|
|
25
|
-
* such instead of being assumed ready. The overall deadline only bounds how long
|
|
26
|
-
* we are willing to wait, and it is generous.
|
|
27
|
-
*
|
|
28
|
-
* @module dsh-ensp/launcher
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import { spawn, spawnSync } from 'node:child_process'
|
|
32
|
-
import net from 'node:net'
|
|
33
|
-
|
|
34
|
-
import
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
[DllImport("user32.dll"
|
|
95
|
-
|
|
96
|
-
[DllImport("user32.dll")] public static extern bool
|
|
97
|
-
[DllImport("user32.dll")] public static extern
|
|
98
|
-
[DllImport("user32.dll")] public static extern
|
|
99
|
-
[DllImport("user32.dll")] public static extern bool
|
|
100
|
-
[DllImport("user32.dll")] public static extern bool
|
|
101
|
-
[DllImport("user32.dll")] public static extern bool
|
|
102
|
-
[DllImport("user32.dll")] public static extern IntPtr
|
|
103
|
-
[DllImport("user32.dll")] public static extern bool
|
|
104
|
-
[DllImport("user32.dll")] public static extern bool
|
|
105
|
-
[DllImport("user32.dll")] public static extern
|
|
106
|
-
[DllImport("
|
|
107
|
-
|
|
108
|
-
[
|
|
109
|
-
|
|
110
|
-
public static
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
return r.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
)
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
1
|
+
/**
|
|
2
|
+
* eNSP launcher: open a topology, start its devices, and hand the desktop back.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this drives the GUI at all
|
|
5
|
+
*
|
|
6
|
+
* Starting devices has no API. It was tested exhaustively and ruled out:
|
|
7
|
+
*
|
|
8
|
+
* | Approach | Result |
|
|
9
|
+
* |---------------------------------|-----------------------------------------------------|
|
|
10
|
+
* | `bootmode="1"` in the .topo | devices still stay off |
|
|
11
|
+
* | VBoxManage `startvm` on a clone | VM runs, but no console: AR_Base has `uart1="off"` |
|
|
12
|
+
* | eNSP control port (65514/7301) | refuses connections / undocumented protocol |
|
|
13
|
+
* | writing `WM_COMMAND` ids | eNSP crashes; the toolbar has no enumerable ids |
|
|
14
|
+
*
|
|
15
|
+
* eNSP's console is **not** on the VM's serial port — a guest agent dials back to
|
|
16
|
+
* a `-consoleip/-consoleport` pair that only the eNSP client allocates. So the
|
|
17
|
+
* client must be the one to start devices, and the only way to ask it is the
|
|
18
|
+
* "Start all devices" toolbar button.
|
|
19
|
+
*
|
|
20
|
+
* ## Readiness is measured, never guessed
|
|
21
|
+
*
|
|
22
|
+
* Boot time varies enormously between models, images and host load, so **no fixed
|
|
23
|
+
* timeout decides success**. Each device is polled on its own console port until
|
|
24
|
+
* VRP actually answers with a prompt; a device that never answers is reported as
|
|
25
|
+
* such instead of being assumed ready. The overall deadline only bounds how long
|
|
26
|
+
* we are willing to wait, and it is generous.
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-ensp/launcher
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
32
|
+
import net from 'node:net'
|
|
33
|
+
import os from 'node:os'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
|
|
36
|
+
import { calibrateAnchors, DEFAULT_ANCHORS } from './calibrate.mjs'
|
|
37
|
+
import { checkTopologySupport, describeBlocked, rootFromExe } from './compat.mjs'
|
|
38
|
+
import { readTopology } from './topology.mjs'
|
|
39
|
+
|
|
40
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
41
|
+
|
|
42
|
+
/** Does the host currently listen on `port`? */
|
|
43
|
+
function listens(port, host = '127.0.0.1', timeoutMs = 1200) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const socket = net.createConnection({ host, port })
|
|
46
|
+
const done = (value) => {
|
|
47
|
+
socket.destroy()
|
|
48
|
+
resolve(value)
|
|
49
|
+
}
|
|
50
|
+
socket.setTimeout(timeoutMs)
|
|
51
|
+
socket.once('connect', () => done(true))
|
|
52
|
+
socket.once('error', () => done(false))
|
|
53
|
+
socket.once('timeout', () => done(false))
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** PowerShell one-liner that lists eNSP's own listening console ports. */
|
|
58
|
+
function listenPortsScript() {
|
|
59
|
+
return (
|
|
60
|
+
'Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ' +
|
|
61
|
+
'Where-Object { $_.LocalPort -ge 2000 -and $_.LocalPort -le 2100 } | ' +
|
|
62
|
+
'ForEach-Object { $_.LocalPort } | Sort-Object -Unique'
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Snapshot of ports currently listening in the eNSP console range. */
|
|
67
|
+
function consolePortsSnapshot() {
|
|
68
|
+
const r = spawnSync('powershell', ['-NoProfile', '-Command', listenPortsScript()], {
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
windowsHide: true,
|
|
71
|
+
})
|
|
72
|
+
if (r.status !== 0) return []
|
|
73
|
+
return (r.stdout || '')
|
|
74
|
+
.split(/\s+/)
|
|
75
|
+
.map(Number)
|
|
76
|
+
.filter((n) => Number.isInteger(n) && n >= 2000 && n <= 2100)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Win32 helper source. Kept in one place because Add-Type is expensive and the
|
|
81
|
+
* same C# is reused for locating, focusing and clicking.
|
|
82
|
+
*/
|
|
83
|
+
const WIN32_SOURCE = String.raw`
|
|
84
|
+
using System;
|
|
85
|
+
using System.Text;
|
|
86
|
+
using System.Runtime.InteropServices;
|
|
87
|
+
|
|
88
|
+
public class EnspWin {
|
|
89
|
+
public delegate bool EnumProc(IntPtr h, IntPtr l);
|
|
90
|
+
|
|
91
|
+
// Declaring DPI awareness makes GetWindowRect/SetCursorPos use physical pixels,
|
|
92
|
+
// matching the calibration screenshot. Without it every click is scaled by the
|
|
93
|
+
// monitor factor (150% here) and lands in the wrong place.
|
|
94
|
+
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
|
95
|
+
|
|
96
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);
|
|
97
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint p);
|
|
98
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassNameW(IntPtr h, StringBuilder s, int n);
|
|
99
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
|
100
|
+
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r);
|
|
101
|
+
[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
|
|
102
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
|
103
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
|
|
104
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
105
|
+
[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
|
|
106
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
107
|
+
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
|
|
108
|
+
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
|
109
|
+
[DllImport("user32.dll")] public static extern void mouse_event(uint f, uint x, uint y, uint d, IntPtr e);
|
|
110
|
+
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
|
|
111
|
+
[DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(POINT p);
|
|
112
|
+
[DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr h, uint flags);
|
|
113
|
+
|
|
114
|
+
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
|
|
115
|
+
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }
|
|
116
|
+
|
|
117
|
+
/** PID that owns a window handle. */
|
|
118
|
+
public static uint PidOf(IntPtr h) {
|
|
119
|
+
if (h == IntPtr.Zero) return 0;
|
|
120
|
+
uint p; GetWindowThreadProcessId(h, out p); return p;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* PID of the top-level window that would receive a click at (x, y).
|
|
125
|
+
*
|
|
126
|
+
* This is how occlusion is detected: a click goes to whatever window owns the
|
|
127
|
+
* pixel, not to the window we think we are driving. When another application
|
|
128
|
+
* covers eNSP the returned PID is that application's, so the caller can refuse
|
|
129
|
+
* to click instead of silently clicking the wrong window.
|
|
130
|
+
*/
|
|
131
|
+
public static uint OwnerPidAt(int x, int y) {
|
|
132
|
+
var pt = new POINT(); pt.X = x; pt.Y = y;
|
|
133
|
+
IntPtr h = WindowFromPoint(pt);
|
|
134
|
+
if (h == IntPtr.Zero) return 0;
|
|
135
|
+
IntPtr root = GetAncestor(h, 2); // GA_ROOT
|
|
136
|
+
return PidOf(root != IntPtr.Zero ? root : h);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Display name of a PID, for error messages. Never throws. */
|
|
140
|
+
public static string ProcessNameOf(uint pid) {
|
|
141
|
+
try { return System.Diagnostics.Process.GetProcessById((int)pid).ProcessName; }
|
|
142
|
+
catch { return "pid " + pid; }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
public static IntPtr FindMain(uint pid) {
|
|
146
|
+
IntPtr found = IntPtr.Zero;
|
|
147
|
+
IntPtr fallback = IntPtr.Zero;
|
|
148
|
+
EnumWindows((h, l) => {
|
|
149
|
+
uint p; GetWindowThreadProcessId(h, out p);
|
|
150
|
+
if (p != pid) return true;
|
|
151
|
+
var sb = new StringBuilder(256); GetClassNameW(h, sb, 256);
|
|
152
|
+
if (!sb.ToString().StartsWith("Afx:")) return true;
|
|
153
|
+
RECT r; GetWindowRect(h, out r);
|
|
154
|
+
int w = r.R - r.L, hgt = r.B - r.T;
|
|
155
|
+
// Normal case: the full frame. A minimised frame collapses to a small
|
|
156
|
+
// rect (e.g. 237x39), so keep the first Afx window as a fallback and
|
|
157
|
+
// prefer the large one when it exists.
|
|
158
|
+
if (w > 400 && hgt > 300) { found = h; return false; }
|
|
159
|
+
if (fallback == IntPtr.Zero) fallback = h;
|
|
160
|
+
return true;
|
|
161
|
+
}, IntPtr.Zero);
|
|
162
|
+
return found != IntPtr.Zero ? found : fallback;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
public static bool FocusWindow(IntPtr h) {
|
|
166
|
+
IntPtr fg = GetForegroundWindow();
|
|
167
|
+
uint dummy;
|
|
168
|
+
uint fgt = GetWindowThreadProcessId(fg, out dummy);
|
|
169
|
+
uint myt = GetCurrentThreadId();
|
|
170
|
+
AttachThreadInput(myt, fgt, true);
|
|
171
|
+
ShowWindow(h, 9); // SW_RESTORE
|
|
172
|
+
BringWindowToTop(h);
|
|
173
|
+
bool ok = SetForegroundWindow(h);
|
|
174
|
+
AttachThreadInput(myt, fgt, false);
|
|
175
|
+
return ok;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public static void InitDpi() {
|
|
179
|
+
SetProcessDPIAware();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
public static void Click(int x, int y) {
|
|
183
|
+
// A synthetic click must look human enough for MFC to accept it: move first,
|
|
184
|
+
// settle, then hold the button down long enough to be past the double-click
|
|
185
|
+
// timer's lower bound. The previous 110 ms press was silently ignored.
|
|
186
|
+
SetCursorPos(x, y);
|
|
187
|
+
System.Threading.Thread.Sleep(150);
|
|
188
|
+
mouse_event(0x0002, 0, 0, 0, IntPtr.Zero); // LEFTDOWN
|
|
189
|
+
System.Threading.Thread.Sleep(90);
|
|
190
|
+
mouse_event(0x0004, 0, 0, 0, IntPtr.Zero); // LEFTUP
|
|
191
|
+
System.Threading.Thread.Sleep(120);
|
|
192
|
+
// Second, slightly offset move to force an WM_MOUSEMOVE on the button.
|
|
193
|
+
SetCursorPos(x + 1, y);
|
|
194
|
+
System.Threading.Thread.Sleep(60);
|
|
195
|
+
SetCursorPos(x, y);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
`
|
|
199
|
+
|
|
200
|
+
/** Run a PowerShell snippet that uses the helper above. */
|
|
201
|
+
function runPowerShell(body, { timeoutMs = 30000 } = {}) {
|
|
202
|
+
const script = `
|
|
203
|
+
$ErrorActionPreference = 'Stop'
|
|
204
|
+
Add-Type -TypeDefinition @'
|
|
205
|
+
${WIN32_SOURCE}
|
|
206
|
+
'@ -ErrorAction Stop
|
|
207
|
+
[EnspWin]::InitDpi()
|
|
208
|
+
${body}
|
|
209
|
+
`
|
|
210
|
+
const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
211
|
+
encoding: 'utf8',
|
|
212
|
+
timeout: timeoutMs,
|
|
213
|
+
windowsHide: true,
|
|
214
|
+
})
|
|
215
|
+
return { ok: r.status === 0, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* PID of a *live* eNSP client, or null.
|
|
220
|
+
*
|
|
221
|
+
* eNSP leaves zombie processes behind: the process object survives with
|
|
222
|
+
* `Threads.Count === 0` and holds the client's single-instance lock, so a new
|
|
223
|
+
* launch inherits an empty canvas. Such a PID must never be reused.
|
|
224
|
+
*
|
|
225
|
+
* @returns {number|null}
|
|
226
|
+
*/
|
|
227
|
+
export function enspPid() {
|
|
228
|
+
const r = spawnSync(
|
|
229
|
+
'powershell',
|
|
230
|
+
[
|
|
231
|
+
'-NoProfile',
|
|
232
|
+
'-Command',
|
|
233
|
+
'Get-Process eNSP_Client -ErrorAction SilentlyContinue | ' +
|
|
234
|
+
'Where-Object { $_.Threads.Count -gt 0 } | ' +
|
|
235
|
+
'Select-Object -First 1 -ExpandProperty Id',
|
|
236
|
+
],
|
|
237
|
+
{ encoding: 'utf8', windowsHide: true },
|
|
238
|
+
)
|
|
239
|
+
const pid = Number((r.stdout || '').trim())
|
|
240
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Is this window handle still valid and owned by a live process?
|
|
245
|
+
*
|
|
246
|
+
* @param {string} hwnd
|
|
247
|
+
* @returns {boolean}
|
|
248
|
+
*/
|
|
249
|
+
export function windowAlive(hwnd) {
|
|
250
|
+
const r = runPowerShell(
|
|
251
|
+
`$h = [IntPtr]::new([int64]${hwnd})
|
|
252
|
+
if (-not [EnspWin]::IsWindow($h)) { 'no'; exit 0 }
|
|
253
|
+
$owned = Get-Process eNSP_Client -ErrorAction SilentlyContinue | Where-Object { $_.Threads.Count -gt 0 }
|
|
254
|
+
if ($owned) { 'yes' } else { 'no' }`,
|
|
255
|
+
{ timeoutMs: 20000 },
|
|
256
|
+
)
|
|
257
|
+
return r.ok && r.stdout.trim().endsWith('yes')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Every eNSP client PID, including zero-thread zombies. */
|
|
261
|
+
export function enspPids() {
|
|
262
|
+
const r = spawnSync(
|
|
263
|
+
'powershell',
|
|
264
|
+
['-NoProfile', '-Command', 'Get-Process eNSP_Client -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id'],
|
|
265
|
+
{ encoding: 'utf8', windowsHide: true },
|
|
266
|
+
)
|
|
267
|
+
return (r.stdout || '')
|
|
268
|
+
.split(/\s+/)
|
|
269
|
+
.map(Number)
|
|
270
|
+
.filter((n) => Number.isInteger(n) && n > 0)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Launch the eNSP client, optionally opening a topology.
|
|
275
|
+
*
|
|
276
|
+
* @param {object} [options]
|
|
277
|
+
* @param {string} [options.topology] - absolute path to a `.topo` file
|
|
278
|
+
* @param {string} [options.exspPath] - override the client executable
|
|
279
|
+
* @param {number} [options.readyTimeoutMs=90000] - how long to wait for the window to exist
|
|
280
|
+
* @returns {Promise<{pid: number, hwnd: string}>}
|
|
281
|
+
*/
|
|
282
|
+
export async function launchEnsp({ topology, exspPath, readyTimeoutMs = 120000 } = {}) {
|
|
283
|
+
const exe = exspPath ?? process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
|
|
284
|
+
const target = topology ? path.resolve(topology) : null
|
|
285
|
+
|
|
286
|
+
// Reject topologies whose devices have no installed image *before* starting the
|
|
287
|
+
// client. Opening one otherwise looks fine for a few seconds — eNSP paints the
|
|
288
|
+
// canvas and then exits by itself, which surfaces later as a pile of
|
|
289
|
+
// "console never answered" errors that point at the wrong thing.
|
|
290
|
+
if (target) {
|
|
291
|
+
const parsed = await readTopology(target)
|
|
292
|
+
const support = checkTopologySupport(parsed, { root: rootFromExe(exe) })
|
|
293
|
+
if (!support.ok) throw new Error(describeBlocked(support))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Reuse an existing window only when it renders something; a blank window is a
|
|
297
|
+
// dead instance that would otherwise be reported as success.
|
|
298
|
+
const existing = enspPid()
|
|
299
|
+
if (existing) {
|
|
300
|
+
const hwnd = locateWindow(existing)
|
|
301
|
+
if (hwnd && (!target || topologyRendered(hwnd))) {
|
|
302
|
+
return { pid: existing, hwnd, reused: true, rendered: Boolean(hwnd) }
|
|
303
|
+
}
|
|
304
|
+
if (hwnd && target) {
|
|
305
|
+
// A live window showing nothing: reopen the file in it first.
|
|
306
|
+
reopenTopology(target)
|
|
307
|
+
if (await waitForRender(hwnd, { attempts: 6, intervalMs: 2500 })) {
|
|
308
|
+
return { pid: existing, hwnd, reused: true, rendered: true }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// Give up on it: stale clients hold eNSP's single-instance lock and make every
|
|
312
|
+
// new window open blank.
|
|
313
|
+
killAllClients()
|
|
314
|
+
await sleep(2500)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Launch once and wait for a frame window. */
|
|
318
|
+
const boot = async () => {
|
|
319
|
+
startDetached(exe, target)
|
|
320
|
+
const started = Date.now()
|
|
321
|
+
const deadline = started + readyTimeoutMs
|
|
322
|
+
while (Date.now() < deadline) {
|
|
323
|
+
const pid = enspPid()
|
|
324
|
+
if (pid) {
|
|
325
|
+
const hwnd = locateWindow(pid)
|
|
326
|
+
if (hwnd) return { pid, hwnd, waitMs: Date.now() - started }
|
|
327
|
+
}
|
|
328
|
+
await sleep(250)
|
|
329
|
+
}
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Up to three attempts: the client occasionally opens with an empty canvas on a
|
|
334
|
+
// cold start, and a clean relaunch always renders. Verified empirically.
|
|
335
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
336
|
+
const booted = await boot()
|
|
337
|
+
if (!booted) {
|
|
338
|
+
killAllClients()
|
|
339
|
+
await sleep(1500)
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
if (!target) return { ...booted, reused: false, rendered: true }
|
|
343
|
+
if (await waitForRender(booted.hwnd, { attempts: 6, intervalMs: 2000 })) {
|
|
344
|
+
return { ...booted, reused: false, rendered: true }
|
|
345
|
+
}
|
|
346
|
+
// Blank canvas: tear the instance down and try again from a clean state.
|
|
347
|
+
killAllClients()
|
|
348
|
+
await sleep(2500)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
throw new Error(
|
|
352
|
+
'eNSP opened but never rendered the topology after 3 attempts. ' +
|
|
353
|
+
'Close every eNSP window and retry; a leftover client holds the single-instance lock.',
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Force-terminate every eNSP client process.
|
|
359
|
+
*
|
|
360
|
+
* Zombie processes (zero threads) cannot be killed by any API; they linger until
|
|
361
|
+
* the OS reaps them. This reports what survived so the caller can decide whether
|
|
362
|
+
* retrying is worthwhile.
|
|
363
|
+
*
|
|
364
|
+
* @returns {{killed: boolean, survivors: number[]}}
|
|
365
|
+
*/
|
|
366
|
+
function killAllClients() {
|
|
367
|
+
spawnSync('taskkill', ['/F', '/IM', 'eNSP_Client.exe'], { windowsHide: true, encoding: 'utf8' })
|
|
368
|
+
return { killed: true, survivors: enspPids() }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Poll until the canvas actually shows the topology.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} hwnd
|
|
375
|
+
* @param {{attempts?: number, intervalMs?: number}} [options]
|
|
376
|
+
* @returns {Promise<boolean>}
|
|
377
|
+
*/
|
|
378
|
+
async function waitForRender(hwnd, { attempts = 6, intervalMs = 2000 } = {}) {
|
|
379
|
+
for (let i = 0; i < attempts; i++) {
|
|
380
|
+
await sleep(intervalMs)
|
|
381
|
+
if (topologyRendered(hwnd)) return true
|
|
382
|
+
}
|
|
383
|
+
return false
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Start the client so that it outlives this Node process.
|
|
388
|
+
*
|
|
389
|
+
* `spawn(..., {detached:true})` is not enough on Windows: eNSP is a GUI application
|
|
390
|
+
* that terminates when the console session that launched it goes away. Going
|
|
391
|
+
* through `cmd /c start` hands ownership to the shell, which is what a double-click
|
|
392
|
+
* in Explorer does, and the client then survives its launcher.
|
|
393
|
+
*
|
|
394
|
+
* @param {string} exe
|
|
395
|
+
* @param {string|null} [topology]
|
|
396
|
+
*/
|
|
397
|
+
function startDetached(exe, topology) {
|
|
398
|
+
// Win32_Process.Create via WMI is the only launch path measured to leave the
|
|
399
|
+
// client fully independent of this Node process:
|
|
400
|
+
//
|
|
401
|
+
// spawn(..., {detached:true}) -> client dies with its launcher
|
|
402
|
+
// cmd /c start -> client dies with its launcher
|
|
403
|
+
// Start-Process -WindowStyle Hidden -> client exits before creating a window
|
|
404
|
+
// Start-Process (visible) -> unreliable when called from a hidden shell
|
|
405
|
+
// WMI Win32_Process.Create -> survives; returns the real PID
|
|
406
|
+
//
|
|
407
|
+
// The client must outlive the tool call, otherwise every follow-up (start
|
|
408
|
+
// devices, push config) finds a dead console.
|
|
409
|
+
const cmdline = topology ? `"${exe}" "${topology}"` : `"${exe}"`
|
|
410
|
+
|
|
411
|
+
// Two launch paths, tried in order. WMI is the cleanest when it works: one
|
|
412
|
+
// call, a real PID, and the client owns no parent. On some machines WMI
|
|
413
|
+
// refuses the request outright with `ReturnValue 8` (the process could not be
|
|
414
|
+
// started, usually a permissions or token change); `Start-Process -PassThru`
|
|
415
|
+
// from the same hidden PowerShell then works and the client still outlives it.
|
|
416
|
+
const attempts = [
|
|
417
|
+
`
|
|
418
|
+
$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
|
|
419
|
+
CommandLine = '${cmdline.replace(/'/g, "''")}'
|
|
420
|
+
}
|
|
421
|
+
if ($r.ReturnValue -ne 0) { 'ERR ' + $r.ReturnValue; exit 0 }
|
|
422
|
+
'pid=' + $r.ProcessId
|
|
423
|
+
`,
|
|
424
|
+
`
|
|
425
|
+
$p = Start-Process -FilePath '${exe.replace(/'/g, "''")}'${topology ? ` -ArgumentList '${topology.replace(/'/g, "''")}'` : ''} -PassThru
|
|
426
|
+
'pid=' + $p.Id
|
|
427
|
+
`,
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
const failures = []
|
|
431
|
+
for (const body of attempts) {
|
|
432
|
+
const r = runPowerShell(body, { timeoutMs: 30000 })
|
|
433
|
+
if (r.ok) {
|
|
434
|
+
const m = /pid=(\d+)/.exec(r.stdout)
|
|
435
|
+
if (m) return Number(m[1])
|
|
436
|
+
}
|
|
437
|
+
failures.push(r.stdout || r.stderr || `exit ${r.status}`)
|
|
438
|
+
}
|
|
439
|
+
throw new Error(`could not launch eNSP: ${failures.join(' / ')}`)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Ask the running client to open a topology again.
|
|
444
|
+
*
|
|
445
|
+
* A second instance is normally refused, so this relaunches the client with the
|
|
446
|
+
* same file; the window handle changes and the caller re-locates it.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} topology
|
|
449
|
+
*/
|
|
450
|
+
function reopenTopology(topology) {
|
|
451
|
+
const exe = process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
|
|
452
|
+
startDetached(exe, topology)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Take a screenshot of the client's canvas, cropped to the drawing area.
|
|
457
|
+
*
|
|
458
|
+
* Used by `topologyRendered` and by the probe diagnostics. The canvas excludes the
|
|
459
|
+
* left device palette and the toolbar, so anything found inside it came from the
|
|
460
|
+
* open topology.
|
|
461
|
+
*
|
|
462
|
+
* @param {string} hwnd
|
|
463
|
+
* @returns {string} absolute path to the PNG, or '' on failure
|
|
464
|
+
*/
|
|
465
|
+
export function captureCanvas(hwnd) {
|
|
466
|
+
const out = path.join(os.tmpdir(), `dsh-ensp-canvas-${Date.now()}.png`)
|
|
467
|
+
const r = runPowerShell(
|
|
468
|
+
`
|
|
469
|
+
$h = [IntPtr]::new([int64]${hwnd})
|
|
470
|
+
[void][EnspWin]::FocusWindow($h)
|
|
471
|
+
Start-Sleep -Milliseconds 900
|
|
472
|
+
$wr = New-Object EnspWin+RECT
|
|
473
|
+
[void][EnspWin]::GetWindowRect($h, [ref]$wr)
|
|
474
|
+
$w = $wr.R - $wr.L; $ht = $wr.B - $wr.T
|
|
475
|
+
if ($w -lt 400 -or $ht -lt 300) { 'ERR small'; exit 0 }
|
|
476
|
+
Add-Type -AssemblyName System.Drawing
|
|
477
|
+
$bmp = New-Object System.Drawing.Bitmap $w, $ht
|
|
478
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
479
|
+
$g.CopyFromScreen($wr.L, $wr.T, 0, 0, $bmp.Size)
|
|
480
|
+
$bmp.Save('${out.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png)
|
|
481
|
+
$bmp.Dispose(); $g.Dispose()
|
|
482
|
+
'ok'
|
|
483
|
+
`,
|
|
484
|
+
{ timeoutMs: 40000 },
|
|
485
|
+
)
|
|
486
|
+
return r.ok ? out : ''
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Does the open topology show devices on the canvas?
|
|
491
|
+
*
|
|
492
|
+
* eNSP paints with DirectX, so there is no window tree to query. Two independent
|
|
493
|
+
* signals are combined, because either alone is weak:
|
|
494
|
+
*
|
|
495
|
+
* 1. **Icon colour.** Device icons are a saturated blue on a pale canvas; the
|
|
496
|
+
* empty canvas has no such pixels.
|
|
497
|
+
* 2. **Link lines.** Rendered links are near-black thin strokes across the canvas.
|
|
498
|
+
*
|
|
499
|
+
* This is the check that gates "the topology actually opened". Callers retry on a
|
|
500
|
+
* negative result rather than reporting success for a blank window.
|
|
501
|
+
*
|
|
502
|
+
* @param {string} hwnd
|
|
503
|
+
* @returns {boolean}
|
|
504
|
+
*/
|
|
505
|
+
export function topologyRendered(hwnd) {
|
|
506
|
+
const png = captureCanvas(hwnd)
|
|
507
|
+
if (!png) return false
|
|
508
|
+
const r = runPowerShell(
|
|
509
|
+
`
|
|
510
|
+
Add-Type -AssemblyName System.Drawing
|
|
511
|
+
$bmp = [System.Drawing.Bitmap]::FromFile('${png.replace(/\\/g, '\\\\')}')
|
|
512
|
+
$w = $bmp.Width; $ht = $bmp.Height
|
|
513
|
+
# Canvas only: skip the palette column and the toolbar band.
|
|
514
|
+
$x0 = [int]($w * 0.22); $x1 = [int]($w * 0.97)
|
|
515
|
+
$y0 = [int]($ht * 0.10); $y1 = [int]($ht * 0.92)
|
|
516
|
+
$icon = 0; $dark = 0
|
|
517
|
+
for ($y = $y0; $y -lt $y1; $y += 2) {
|
|
518
|
+
for ($x = $x0; $x -lt $x1; $x += 2) {
|
|
519
|
+
$c = $bmp.GetPixel($x, $y)
|
|
520
|
+
if ($c.B -gt 140 -and ($c.B - $c.R) -gt 55 -and ($c.B - $c.G) -gt 25) { $icon++ }
|
|
521
|
+
elseif ($c.R -lt 90 -and $c.G -lt 90 -and $c.B -lt 90) { $dark++ }
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
$bmp.Dispose()
|
|
525
|
+
'icon=' + $icon + ' dark=' + $dark
|
|
526
|
+
`,
|
|
527
|
+
{ timeoutMs: 40000 },
|
|
528
|
+
)
|
|
529
|
+
if (!r.ok) return false
|
|
530
|
+
const m = /icon=(\d+) dark=(\d+)/.exec(r.stdout)
|
|
531
|
+
if (!m) return false
|
|
532
|
+
const icon = Number(m[1])
|
|
533
|
+
const dark = Number(m[2])
|
|
534
|
+
// A rendered lab shows dozens of icon pixels and a few hundred link pixels.
|
|
535
|
+
return icon >= 15 && dark >= 40
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Locate the eNSP main frame window for a PID.
|
|
540
|
+
* @param {number} pid
|
|
541
|
+
* @returns {string|null} the HWND as a decimal string (JS has no uintptr type)
|
|
542
|
+
*/
|
|
543
|
+
export function locateWindow(pid) {
|
|
544
|
+
const r = runPowerShell(`[EnspWin]::FindMain([uint32]${pid}).ToString()`, { timeoutMs: 20000 })
|
|
545
|
+
if (!r.ok) return null
|
|
546
|
+
const value = r.stdout.split(/\s+/).pop()
|
|
547
|
+
return value && value !== '0' ? value : null
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Bring eNSP to the foreground so its toolbar can be clicked.
|
|
552
|
+
*
|
|
553
|
+
* Focus is unavoidable: driving the toolbar while the window is occluded or
|
|
554
|
+
* minimised was observed to kill the process. The caller is expected to
|
|
555
|
+
* {@link minimiseEnsp} afterwards so the user gets the desktop back.
|
|
556
|
+
*
|
|
557
|
+
* @param {string} hwnd
|
|
558
|
+
* @returns {boolean} whether the window ended up in front
|
|
559
|
+
*/
|
|
560
|
+
export function focusEnsp(hwnd) {
|
|
561
|
+
const r = runPowerShell(
|
|
562
|
+
`
|
|
563
|
+
$h = [IntPtr]::new([int64]${hwnd})
|
|
564
|
+
$ok = [EnspWin]::FocusWindow($h)
|
|
565
|
+
Start-Sleep -Milliseconds 250
|
|
566
|
+
($ok -and ([EnspWin]::GetForegroundWindow() -eq $h)).ToString()
|
|
567
|
+
`,
|
|
568
|
+
{ timeoutMs: 20000 },
|
|
569
|
+
)
|
|
570
|
+
return r.ok && r.stdout.split(/\s+/).pop() === 'True'
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Minimise the eNSP window, handing the desktop back to the user.
|
|
575
|
+
* @param {string} hwnd
|
|
576
|
+
*/
|
|
577
|
+
export function minimiseEnsp(hwnd) {
|
|
578
|
+
runPowerShell(
|
|
579
|
+
`[EnspWin]::ShowWindow([IntPtr]::new([int64]${hwnd}), 6) | Out-Null; 'ok'`,
|
|
580
|
+
{ timeoutMs: 15000 },
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Is the eNSP window covered by something else?
|
|
586
|
+
*
|
|
587
|
+
* Calibration reads the screen, so an occluded window produces a screenshot of
|
|
588
|
+
* whatever is on top of it and the measured anchors are garbage. Rather than
|
|
589
|
+
* clicking on a guess, probe several points of the window frame and report the
|
|
590
|
+
* first foreign process that owns one of them.
|
|
591
|
+
*
|
|
592
|
+
* @param {string} hwnd
|
|
593
|
+
* @returns {{occluded: boolean, by?: string, at?: string}}
|
|
594
|
+
*/
|
|
595
|
+
export function checkOcclusion(hwnd) {
|
|
596
|
+
const r = runPowerShell(
|
|
597
|
+
`
|
|
598
|
+
$h = [IntPtr]::new([int64]${hwnd})
|
|
599
|
+
$rect = New-Object EnspWin+RECT
|
|
600
|
+
[void][EnspWin]::GetWindowRect($h, [ref]$rect)
|
|
601
|
+
$self = [EnspWin]::PidOf($h)
|
|
602
|
+
$w = $rect.R - $rect.L
|
|
603
|
+
$hg = $rect.B - $rect.T
|
|
604
|
+
# A grid rather than a handful of points: partial coverage of the toolbar strip
|
|
605
|
+
# is exactly the case that corrupts calibration, and it is easy to miss when
|
|
606
|
+
# only the middle of the window is sampled.
|
|
607
|
+
$xs = @(0.5, 0.2, 0.35, 0.65, 0.8)
|
|
608
|
+
$ys = @(0.06, 0.25, 0.5, 0.75)
|
|
609
|
+
foreach ($fy in $ys) {
|
|
610
|
+
foreach ($fx in $xs) {
|
|
611
|
+
$px = $rect.L + [int]($w * $fx)
|
|
612
|
+
$py = $rect.T + [int]($hg * $fy)
|
|
613
|
+
$owner = [EnspWin]::OwnerPidAt($px, $py)
|
|
614
|
+
if ($owner -ne 0 -and $owner -ne $self) {
|
|
615
|
+
'OCCLUDED ' + $px + ',' + $py + ' ' + $owner + ' ' + [EnspWin]::ProcessNameOf($owner)
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
'clear'
|
|
621
|
+
`,
|
|
622
|
+
{ timeoutMs: 20000 },
|
|
623
|
+
)
|
|
624
|
+
const text = r.stdout.split('\n').filter(Boolean).pop() || ''
|
|
625
|
+
if (text.startsWith('OCCLUDED')) {
|
|
626
|
+
const f = text.split(' ')
|
|
627
|
+
return { occluded: true, at: f[1], by: `${f[3] ?? ''} (${f[4] ?? 'unknown'})`.trim() }
|
|
628
|
+
}
|
|
629
|
+
return { occluded: false }
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Click the "start all devices" toolbar button.
|
|
634
|
+
*
|
|
635
|
+
* Coordinates are **window-relative** and were measured against the client's
|
|
636
|
+
* 1280×800 layout; the toolbar is a fixed strip whose origin does not move with
|
|
637
|
+
* the window. See `docs/UI-ANCHORS.md` for how they were obtained and how to
|
|
638
|
+
* re-measure after an eNSP or DPI change.
|
|
639
|
+
*
|
|
640
|
+
* ## The click is verified, not assumed
|
|
641
|
+
*
|
|
642
|
+
* A synthetic click lands on whichever window owns the pixel. When another
|
|
643
|
+
* application covers eNSP the mouse events go there and eNSP never sees them,
|
|
644
|
+
* yet every step reports success and the caller then waits for devices that
|
|
645
|
+
* were never started. That failure was observed for real (a full-screen VMware
|
|
646
|
+
* window sitting on top). So the owner of the pixel is looked up with
|
|
647
|
+
* `WindowFromPoint` first: if it is not the eNSP process, no click is sent and
|
|
648
|
+
* the error names the process that is in the way.
|
|
649
|
+
*
|
|
650
|
+
* @param {string} hwnd
|
|
651
|
+
* @param {{button?: 'start'|'stop', anchors?: object}} [options]
|
|
652
|
+
* @returns {{ok: boolean, clicked: boolean, detail: string, occludedBy?: string}}
|
|
653
|
+
*/
|
|
654
|
+
export function clickToolbar(hwnd, options = {}) {
|
|
655
|
+
const { button = 'start' } = options
|
|
656
|
+
const anchors = options.anchors ?? {
|
|
657
|
+
start: { x: DEFAULT_ANCHORS.start.x, y: DEFAULT_ANCHORS.start.y },
|
|
658
|
+
stop: { x: DEFAULT_ANCHORS.stop.x, y: DEFAULT_ANCHORS.stop.y },
|
|
659
|
+
}
|
|
660
|
+
const { x: ox, y: oy } = anchors[button] ?? anchors.start
|
|
661
|
+
const r = runPowerShell(
|
|
662
|
+
`
|
|
663
|
+
$h = [IntPtr]::new([int64]${hwnd})
|
|
664
|
+
$rect = New-Object EnspWin+RECT
|
|
665
|
+
[void][EnspWin]::GetWindowRect($h, [ref]$rect)
|
|
666
|
+
# Anchors are measured in the SAME frame as the window rect (origin = window
|
|
667
|
+
# top-left, title bar included), which is also the frame the calibration
|
|
668
|
+
# screenshot uses. Do not add a client-area offset here.
|
|
669
|
+
$cx = $rect.L + ${ox}
|
|
670
|
+
$cy = $rect.T + ${oy}
|
|
671
|
+
$self = [EnspWin]::PidOf($h)
|
|
672
|
+
$owner = [EnspWin]::OwnerPidAt($cx, $cy)
|
|
673
|
+
if ($owner -ne 0 -and $owner -ne $self) {
|
|
674
|
+
'OCCLUDED ' + $cx + ',' + $cy + ' ' + $owner + ' ' + [EnspWin]::ProcessNameOf($owner)
|
|
675
|
+
exit 3
|
|
676
|
+
}
|
|
677
|
+
[EnspWin]::Click($cx, $cy)
|
|
678
|
+
$after = [EnspWin]::OwnerPidAt($cx, $cy)
|
|
679
|
+
if ($after -ne 0 -and $after -ne $self) {
|
|
680
|
+
'STOLEN ' + $cx + ',' + $cy + ' ' + $after + ' ' + [EnspWin]::ProcessNameOf($after)
|
|
681
|
+
exit 4
|
|
682
|
+
}
|
|
683
|
+
'clicked ' + $cx + ',' + $cy
|
|
684
|
+
`,
|
|
685
|
+
{ timeoutMs: 20000 },
|
|
686
|
+
)
|
|
687
|
+
const detail = r.stdout.split('\n').filter(Boolean).pop() || r.stderr || ''
|
|
688
|
+
const fields = detail.split(' ')
|
|
689
|
+
if (fields[0] === 'OCCLUDED' || fields[0] === 'STOLEN') {
|
|
690
|
+
const who = `${fields[3] ?? ''} (${fields[4] ?? 'unknown'})`.trim()
|
|
691
|
+
return {
|
|
692
|
+
ok: false,
|
|
693
|
+
clicked: false,
|
|
694
|
+
occludedBy: who,
|
|
695
|
+
detail:
|
|
696
|
+
`eNSP is covered by another window at ${fields[1]}: ${who}. ` +
|
|
697
|
+
'The mouse events would go to that window, so nothing was clicked. ' +
|
|
698
|
+
'Bring eNSP to the front (restore it from the taskbar) and try again.',
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return { ok: r.ok, clicked: r.ok, detail }
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Wait until one device console answers VRP with a prompt.
|
|
706
|
+
*
|
|
707
|
+
* Readiness means "the device replied", not "enough time has passed".
|
|
708
|
+
*
|
|
709
|
+
* @param {object} options
|
|
710
|
+
* @param {number} options.port - console TCP port from the .topo
|
|
711
|
+
* @param {number} [options.timeoutMs=180000] - per-device ceiling, generous on purpose
|
|
712
|
+
* @param {number} [options.pollMs=1500]
|
|
713
|
+
* @returns {Promise<{port: number, ready: boolean, prompt: string|null, waitedMs: number, error?: string}>}
|
|
714
|
+
*/
|
|
715
|
+
export async function waitForConsole({ port, timeoutMs = 180000, pollMs = 1500 }) {
|
|
716
|
+
const started = Date.now()
|
|
717
|
+
let lastError = null
|
|
718
|
+
while (Date.now() - started < timeoutMs) {
|
|
719
|
+
if (await listens(port)) {
|
|
720
|
+
// Port is open; now prove VRP is actually talking on it.
|
|
721
|
+
const answer = await new Promise((resolve) => {
|
|
722
|
+
const socket = net.createConnection({ host: '127.0.0.1', port })
|
|
723
|
+
let buf = ''
|
|
724
|
+
const finish = (value) => {
|
|
725
|
+
socket.destroy()
|
|
726
|
+
resolve(value)
|
|
727
|
+
}
|
|
728
|
+
socket.setEncoding('utf8')
|
|
729
|
+
socket.setTimeout(4000)
|
|
730
|
+
socket.on('connect', () => setTimeout(() => socket.write('\r'), 400))
|
|
731
|
+
socket.on('data', (chunk) => {
|
|
732
|
+
buf += chunk
|
|
733
|
+
// A prompt at the end of the buffer means VRP is alive.
|
|
734
|
+
if (/[<\[][\w~\-./:]*[>\]]\s*$/.test(buf.replace(/\r/g, ''))) {
|
|
735
|
+
const prompt = (buf.match(/[<\[][\w~\-./:]*[>\]]/g) || []).pop() ?? null
|
|
736
|
+
finish({ prompt })
|
|
737
|
+
}
|
|
738
|
+
})
|
|
739
|
+
socket.on('error', (e) => finish({ error: e.message }))
|
|
740
|
+
socket.on('timeout', () => finish({ error: 'no reply' }))
|
|
741
|
+
})
|
|
742
|
+
if (answer.prompt) {
|
|
743
|
+
return { port, ready: true, prompt: answer.prompt, waitedMs: Date.now() - started }
|
|
744
|
+
}
|
|
745
|
+
lastError = answer.error ?? 'no prompt yet'
|
|
746
|
+
}
|
|
747
|
+
await sleep(pollMs)
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
port,
|
|
751
|
+
ready: false,
|
|
752
|
+
prompt: null,
|
|
753
|
+
waitedMs: Date.now() - started,
|
|
754
|
+
error: lastError ?? 'console never opened',
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Start every device of a topology and verify each one individually.
|
|
760
|
+
*
|
|
761
|
+
* The sequence is: focus → click start → **poll each console until it answers**.
|
|
762
|
+
* Devices are verified concurrently so a slow model does not serialise the wait,
|
|
763
|
+
* and the result distinguishes "all ready" from "some never came up" so a caller
|
|
764
|
+
* can act on the difference.
|
|
765
|
+
*
|
|
766
|
+
* @param {object} options
|
|
767
|
+
* @param {Array<{name: string, comPort: number|null}>} options.devices
|
|
768
|
+
* @param {string} options.hwnd
|
|
769
|
+
* @param {number} [options.perDeviceTimeoutMs=180000]
|
|
770
|
+
* @param {boolean} [options.minimise=true] - restore the desktop as soon as the
|
|
771
|
+
* click has been delivered (devices keep booting in the background)
|
|
772
|
+
* @param {(event: object) => void} [options.onProgress]
|
|
773
|
+
* @returns {Promise<{clicked: boolean, devices: Array<object>, allReady: boolean}>}
|
|
774
|
+
*/
|
|
775
|
+
export async function startAllDevices({
|
|
776
|
+
devices,
|
|
777
|
+
hwnd,
|
|
778
|
+
perDeviceTimeoutMs = 180000,
|
|
779
|
+
minimise = true,
|
|
780
|
+
onProgress,
|
|
781
|
+
}) {
|
|
782
|
+
const report = (event) => onProgress?.(event)
|
|
783
|
+
const targets = devices.filter((d) => d.comPort)
|
|
784
|
+
|
|
785
|
+
const before = new Set(consolePortsSnapshot())
|
|
786
|
+
|
|
787
|
+
// Locate the toolbar buttons by colour instead of trusting hard-coded pixels;
|
|
788
|
+
// this survives eNSP upgrades, DPI changes and different display sizes.
|
|
789
|
+
// The window must still exist: a stale handle means the client already exited,
|
|
790
|
+
// and clicking a dead handle does nothing while reporting success below.
|
|
791
|
+
if (!hwnd || !windowAlive(hwnd)) {
|
|
792
|
+
throw new Error('eNSP window is gone; reopen the topology before starting devices')
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
report({ phase: 'calibrate', hwnd })
|
|
796
|
+
// Calibration photographs the screen, so an occluded window yields anchors
|
|
797
|
+
// measured on the wrong pixels. Check first, and say who is in the way.
|
|
798
|
+
const occlusion = checkOcclusion(hwnd)
|
|
799
|
+
if (occlusion.occluded) {
|
|
800
|
+
throw new Error(
|
|
801
|
+
`eNSP is covered by ${occlusion.by} at ${occlusion.at}, so its toolbar cannot be located or clicked. ` +
|
|
802
|
+
'Windows does not let another process raise a foreign window, so this has to be fixed by hand: ' +
|
|
803
|
+
'minimise or close the window that is on top, bring eNSP to the front, then run the command again.',
|
|
804
|
+
)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
let anchors
|
|
808
|
+
try {
|
|
809
|
+
anchors = calibrateAnchors(hwnd)
|
|
810
|
+
} catch (error) {
|
|
811
|
+
anchors = { ...DEFAULT_ANCHORS, method: 'default', detail: { reason: error.message } }
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// A default anchor means calibration failed, and an unmeasured click can land on
|
|
815
|
+
// any toolbar control (including the one that closes the document). Refuse
|
|
816
|
+
// instead of guessing.
|
|
817
|
+
if (anchors.method !== 'measured') {
|
|
818
|
+
throw new Error(
|
|
819
|
+
`toolbar calibration failed (${anchors.detail?.reason ?? 'unknown'}). ` +
|
|
820
|
+
'Make sure the eNSP window is visible and run ensp_probe to re-measure.',
|
|
821
|
+
)
|
|
822
|
+
}
|
|
823
|
+
report({ phase: 'calibrated', method: anchors.method, detail: anchors.detail })
|
|
824
|
+
|
|
825
|
+
report({ phase: 'focus', hwnd })
|
|
826
|
+
const focused = focusEnsp(hwnd)
|
|
827
|
+
if (!focused) report({ phase: 'focus-failed', hwnd })
|
|
828
|
+
|
|
829
|
+
report({ phase: 'click', button: 'start', anchor: anchors.start })
|
|
830
|
+
const click = clickToolbar(hwnd, { button: 'start', anchors })
|
|
831
|
+
if (!click.clicked) {
|
|
832
|
+
// Never fall through to waiting on consoles: without a click no device will
|
|
833
|
+
// ever boot, and a long wait would hide the real cause.
|
|
834
|
+
throw new Error(click.detail || 'the start button could not be clicked')
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Hand the desktop back immediately; boot continues without the window.
|
|
838
|
+
if (minimise) {
|
|
839
|
+
minimiseEnsp(hwnd)
|
|
840
|
+
report({ phase: 'minimised' })
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
report({ phase: 'wait', devices: targets.length })
|
|
844
|
+
const results = await Promise.all(
|
|
845
|
+
targets.map(async (device) => {
|
|
846
|
+
const outcome = await waitForConsole({ port: device.comPort, timeoutMs: perDeviceTimeoutMs })
|
|
847
|
+
report({ phase: 'device', name: device.name, ...outcome })
|
|
848
|
+
return { name: device.name, model: device.model, ...outcome }
|
|
849
|
+
}),
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
const after = new Set(consolePortsSnapshot())
|
|
853
|
+
const newPorts = [...after].filter((p) => !before.has(p))
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
clicked: click.ok,
|
|
857
|
+
clickDetail: click.detail,
|
|
858
|
+
anchors,
|
|
859
|
+
focused,
|
|
860
|
+
newPorts,
|
|
861
|
+
devices: results,
|
|
862
|
+
allReady: results.length > 0 && results.every((r) => r.ready),
|
|
863
|
+
ready: results.filter((r) => r.ready).length,
|
|
864
|
+
total: results.length,
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Stop every device (red square next to the green triangle).
|
|
870
|
+
* @param {string} hwnd
|
|
871
|
+
*/
|
|
872
|
+
export function stopAllDevices(hwnd, options = {}) {
|
|
873
|
+
let anchors = options.anchors
|
|
874
|
+
if (!anchors) {
|
|
875
|
+
try {
|
|
876
|
+
anchors = calibrateAnchors(hwnd)
|
|
877
|
+
} catch {
|
|
878
|
+
anchors = { ...DEFAULT_ANCHORS, method: 'default', detail: {} }
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
const focused = focusEnsp(hwnd)
|
|
882
|
+
const result = clickToolbar(hwnd, { button: 'stop', anchors })
|
|
883
|
+
return { focused, anchors, ...result }
|
|
884
|
+
}
|