dsh-clean-desktop-shell 0.1.11 → 0.1.13
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/CONTRIBUTORS.md +25 -19
- package/README.en.md +422 -333
- package/README.md +363 -278
- package/electron/aumid.js +17 -17
- package/electron/crashGuard.js +60 -60
- package/electron/error.html +113 -113
- package/electron/main.js +203 -187
- package/electron/outage.js +60 -0
- package/electron/preload.js +216 -49
- package/electron/progress-preload.js +11 -11
- package/electron/progress.html +79 -79
- package/electron/progress.js +56 -56
- package/electron/service.js +559 -458
- package/electron/shortcut.js +122 -122
- package/electron/tray.js +232 -232
- package/electron/window.js +603 -264
- package/lib/client.js +41 -6
- package/lib/common.js +74 -74
- package/lib/icon.js +85 -51
- package/lib/index.js +90 -15
- package/lib/runtime.js +423 -423
- package/package.json +140 -127
- package/scripts/check-syntax.mjs +54 -54
- package/scripts/selftest-recovery.mjs +140 -0
- package/scripts/selftest-runtime.mjs +90 -90
- package/src/client/client.js +41 -6
- package/src/host/common.js +74 -74
- package/src/host/icon.js +85 -51
- package/src/host/index.js +90 -15
- package/src/host/runtime.js +423 -423
- package/version.txt +1 -1
package/electron/service.js
CHANGED
|
@@ -1,458 +1,559 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Local dsh web backend supervision.
|
|
3
|
-
*
|
|
4
|
-
* The shell owns the lifecycle of the dsh backend from the tray:
|
|
5
|
-
* - detect(): probe configured paths + default port
|
|
6
|
-
* - start() / stop() / restart()
|
|
7
|
-
* - status: 'running' | 'stopped' | 'starting' | 'error'
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
setStatus('
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
if (
|
|
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
|
-
|
|
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
|
-
return
|
|
458
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Local dsh web backend supervision.
|
|
3
|
+
*
|
|
4
|
+
* The shell owns the lifecycle of the dsh backend from the tray:
|
|
5
|
+
* - detect(): probe configured paths + default port
|
|
6
|
+
* - start() / stop() / restart()
|
|
7
|
+
* - status: 'running' | 'stopped' | 'starting' | 'error'
|
|
8
|
+
*
|
|
9
|
+
* Lifecycle vs responsiveness: every status carries a `cause` so consumers
|
|
10
|
+
* can tell a CONFIRMED lifecycle event from an OBSERVED state:
|
|
11
|
+
* - cause 'exit' — the managed child process really exited (or died while
|
|
12
|
+
* starting);
|
|
13
|
+
* - cause 'stop' — an explicit tray stop terminated the backend;
|
|
14
|
+
* - cause 'probe' — detect() found nothing answering on the port (an
|
|
15
|
+
* observation; the backend may merely be slow or external).
|
|
16
|
+
* Only 'exit'/'stop' mean the backend that owns a loaded page is gone; a
|
|
17
|
+
* 'probe'-derived 'stopped' must never be treated as a process exit.
|
|
18
|
+
*
|
|
19
|
+
* Shell/core decoupling: when a remote target URL is configured, no local
|
|
20
|
+
* backend is ever touched — this module only manages the local dsh CLI.
|
|
21
|
+
*/
|
|
22
|
+
import { spawn } from 'node:child_process'
|
|
23
|
+
import { access } from 'node:fs'
|
|
24
|
+
import { homedir } from 'node:os'
|
|
25
|
+
import { dirname, join } from 'node:path'
|
|
26
|
+
import { loadConfig, DEFAULT_TARGET_URL } from './config.js'
|
|
27
|
+
|
|
28
|
+
// The local backend endpoint — derived from the shared default, never a
|
|
29
|
+
// duplicated literal.
|
|
30
|
+
const LOCAL_URL = DEFAULT_TARGET_URL
|
|
31
|
+
const DEFAULT_PORT = Number(new URL(DEFAULT_TARGET_URL).port) || 80
|
|
32
|
+
|
|
33
|
+
// Long-lived backend output must not accumulate without bound — keep the
|
|
34
|
+
// tail only (enough for ready-line matching and diagnostics).
|
|
35
|
+
const OUTPUT_CAP = 64 * 1024
|
|
36
|
+
|
|
37
|
+
function cappedAppend(prev, chunk) {
|
|
38
|
+
const s = prev + chunk
|
|
39
|
+
return s.length > OUTPUT_CAP ? s.slice(-OUTPUT_CAP) : s
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let child = null
|
|
43
|
+
let currentStatus = 'stopped'
|
|
44
|
+
let lastError = null
|
|
45
|
+
let currentCause = null
|
|
46
|
+
let startResolver = null
|
|
47
|
+
|
|
48
|
+
// dsh 0.1.2+ gates the web index behind a per-process launch token: the
|
|
49
|
+
// startup line reads `dsh web: http://127.0.0.1:3080/?token=…`, and a loopback
|
|
50
|
+
// GET without that token (or the session cookie it mints) is answered 401, so
|
|
51
|
+
// the window used to come up blank. Two independent sources feed this state:
|
|
52
|
+
//
|
|
53
|
+
// 1. DSH_WEB_LAUNCH_URL — set by the DSH host half when the shell is launched
|
|
54
|
+
// from inside `dsh web` (plugin mode). This is the only way to reach the
|
|
55
|
+
// token of a backend this shell did not start, and it is why the plugin
|
|
56
|
+
// form works at all.
|
|
57
|
+
// 2. the ready banner printed by a backend this shell started itself.
|
|
58
|
+
//
|
|
59
|
+
// In-memory only: the launch URL is a per-process bootstrap secret, it rotates
|
|
60
|
+
// on every backend start, and it must never be persisted to config or disk.
|
|
61
|
+
const envLaunchUrl = process.env.DSH_WEB_LAUNCH_URL
|
|
62
|
+
let currentLaunchUrl = envLaunchUrl && /^https?:\/\//i.test(envLaunchUrl) ? envLaunchUrl : null
|
|
63
|
+
|
|
64
|
+
/** The launch URL of the backend this shell is bound to, or null. */
|
|
65
|
+
export function getAuthenticatedUrl() {
|
|
66
|
+
return currentLaunchUrl
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Status-change listeners (tray menu auto-refresh, window auto-reload, ...).
|
|
70
|
+
const listeners = new Set()
|
|
71
|
+
|
|
72
|
+
/** Subscribe to backend status changes. Returns an unsubscribe function. */
|
|
73
|
+
export function onStatusChange(cb) {
|
|
74
|
+
listeners.add(cb)
|
|
75
|
+
return () => listeners.delete(cb)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setStatus(next, error = null, cause = null) {
|
|
79
|
+
// A same-status re-emission with a different cause (e.g. probe-derived
|
|
80
|
+
// 'stopped' followed by the real child exit) must still notify — the
|
|
81
|
+
// cause is what upgrades an observation to a confirmed lifecycle event.
|
|
82
|
+
if (currentStatus !== next || lastError !== error || currentCause !== cause) {
|
|
83
|
+
currentStatus = next
|
|
84
|
+
lastError = error
|
|
85
|
+
currentCause = cause
|
|
86
|
+
for (const cb of listeners) {
|
|
87
|
+
try {
|
|
88
|
+
cb(getStatus())
|
|
89
|
+
} catch {
|
|
90
|
+
// listener errors must not break the state machine
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getStatus() {
|
|
97
|
+
return {
|
|
98
|
+
status: currentStatus,
|
|
99
|
+
cause: currentCause,
|
|
100
|
+
port: DEFAULT_PORT,
|
|
101
|
+
url: LOCAL_URL,
|
|
102
|
+
launchUrl: currentLaunchUrl,
|
|
103
|
+
error: lastError,
|
|
104
|
+
pid: child?.pid ?? null,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* HTTP probe that separates “something is listening” from “the web UI is
|
|
110
|
+
* authenticated/ready”. A bare 401 means the backend is alive but the shell
|
|
111
|
+
* still needs a launch-URL bootstrap, not that the real page is usable.
|
|
112
|
+
*/
|
|
113
|
+
export async function probeState(url, timeoutMs = 1500) {
|
|
114
|
+
try {
|
|
115
|
+
// AbortSignal.timeout is the standard self-cleaning timeout — no manual
|
|
116
|
+
// controller/timer pair to leak when fetch rejects first.
|
|
117
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'manual' })
|
|
118
|
+
return {
|
|
119
|
+
alive: res.status < 500,
|
|
120
|
+
authenticated: res.status !== 401,
|
|
121
|
+
status: res.status,
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
return {
|
|
125
|
+
alive: false,
|
|
126
|
+
authenticated: false,
|
|
127
|
+
status: null,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Simple HTTP probe — true when something responds on the port. */
|
|
133
|
+
export async function probe(url, timeoutMs = 1500) {
|
|
134
|
+
return (await probeState(url, timeoutMs)).alive
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Detect a reachable local dsh web service.
|
|
139
|
+
* Returns the URL when something already listens on the port,
|
|
140
|
+
* or null when the backend is down.
|
|
141
|
+
*
|
|
142
|
+
* Never demotes a shell-managed child: while one is alive, reachability is
|
|
143
|
+
* the liveness watch's job (window.js probes), so a transient probe failure
|
|
144
|
+
* here cannot corrupt lifecycle state. The status is likewise never
|
|
145
|
+
* promoted here — during 'starting' the launch URL is not minted yet, and
|
|
146
|
+
* only the ready line (or the start-timeout probe) may flip 'starting' →
|
|
147
|
+
* 'running'. Unmanaged results are observations and are reported with
|
|
148
|
+
* cause 'probe' — not confirmed exits.
|
|
149
|
+
*/
|
|
150
|
+
export async function detect() {
|
|
151
|
+
if (child && child.exitCode === null) {
|
|
152
|
+
// Managed child alive — lifecycle state owns itself; no probe, no
|
|
153
|
+
// status change (in particular: no promotion out of 'starting').
|
|
154
|
+
return LOCAL_URL
|
|
155
|
+
}
|
|
156
|
+
const state = await probeState(LOCAL_URL)
|
|
157
|
+
if (state.alive) {
|
|
158
|
+
setStatus('running', null, 'probe')
|
|
159
|
+
return LOCAL_URL
|
|
160
|
+
}
|
|
161
|
+
// The old process token (if any) belongs to a backend that is no longer
|
|
162
|
+
// listening. Keep it out of the next backend's bootstrap.
|
|
163
|
+
currentLaunchUrl = null
|
|
164
|
+
setStatus('stopped', null, 'probe')
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Start the local dsh backend. Resolves once the service answers
|
|
170
|
+
* (or a spawned CLI prints its ready line). Throws on failure.
|
|
171
|
+
*/
|
|
172
|
+
export async function start({ backendPath } = {}) {
|
|
173
|
+
// Already up? Two very different situations hide behind this:
|
|
174
|
+
// - plugin mode: the DSH host half started this very backend and handed us
|
|
175
|
+
// its launch URL through DSH_WEB_LAUNCH_URL — that credential is still
|
|
176
|
+
// valid, so it must survive here (only detect() clears it, and only when
|
|
177
|
+
// nothing answers on the port at all).
|
|
178
|
+
// - a backend started outside the shell: its banner went to someone else's
|
|
179
|
+
// terminal, so we simply have no token for it.
|
|
180
|
+
const up = await detect()
|
|
181
|
+
if (up) return up
|
|
182
|
+
|
|
183
|
+
// Backend path: explicit config → common locations → PATH.
|
|
184
|
+
const resolved = await resolveDshCommand(backendPath)
|
|
185
|
+
if (!resolved) {
|
|
186
|
+
setStatus('error', '未找到 dsh 后端。请在托盘「设置后端文件夹」中指定 dsh CLI 所在目录。')
|
|
187
|
+
throw new Error(lastError)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
setStatus('starting')
|
|
191
|
+
|
|
192
|
+
// Windows: .cmd/.bat cannot be spawned directly — EINVAL. Route them
|
|
193
|
+
// through the shell so `dsh.cmd web` behaves like a normal terminal.
|
|
194
|
+
const isCmd = process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved.command)
|
|
195
|
+
const command = isCmd ? `"${resolved.command}"` : resolved.command
|
|
196
|
+
|
|
197
|
+
let spawned = null
|
|
198
|
+
try {
|
|
199
|
+
spawned = spawn(command, [...resolved.args, 'web'], {
|
|
200
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
201
|
+
windowsHide: true,
|
|
202
|
+
shell: isCmd,
|
|
203
|
+
env: { ...process.env, ...(resolved.env || {}) },
|
|
204
|
+
})
|
|
205
|
+
} catch (err) {
|
|
206
|
+
// Synchronous spawn failures (e.g. EINVAL on Windows) must leave the
|
|
207
|
+
// state machine in 'error' — otherwise the tray is stuck on 'starting'.
|
|
208
|
+
setStatus('error', `后端启动失败:${err.message}`)
|
|
209
|
+
throw new Error(lastError)
|
|
210
|
+
}
|
|
211
|
+
child = spawned
|
|
212
|
+
|
|
213
|
+
child.on('error', (err) => {
|
|
214
|
+
setStatus('error', err.message)
|
|
215
|
+
if (startResolver) {
|
|
216
|
+
const r = startResolver
|
|
217
|
+
startResolver = null
|
|
218
|
+
r.reject(new Error(lastError))
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
child.on('exit', (code) => {
|
|
223
|
+
child = null
|
|
224
|
+
currentLaunchUrl = null
|
|
225
|
+
if (currentStatus === 'starting' && startResolver) {
|
|
226
|
+
const r = startResolver
|
|
227
|
+
startResolver = null
|
|
228
|
+
setStatus('error', `dsh 后端异常退出 (code ${code})`, 'exit')
|
|
229
|
+
r.reject(new Error(lastError))
|
|
230
|
+
} else {
|
|
231
|
+
// Confirmed lifecycle exit. Always (re)emit with cause 'exit' — when a
|
|
232
|
+
// probe-derived 'stopped' preceded the real exit, the cause upgrade is
|
|
233
|
+
// what tells listeners this was a genuine process death, not an
|
|
234
|
+
// observation.
|
|
235
|
+
setStatus('stopped', null, 'exit')
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// Wait for readiness: ready line on stdout/stderr, or the port answering.
|
|
240
|
+
return await new Promise((resolve, reject) => {
|
|
241
|
+
startResolver = { resolve, reject }
|
|
242
|
+
let stdout = ''
|
|
243
|
+
let stderr = ''
|
|
244
|
+
const timer = setTimeout(async () => {
|
|
245
|
+
if (!startResolver) return
|
|
246
|
+
const r = startResolver
|
|
247
|
+
startResolver = null
|
|
248
|
+
// Timeout does not mean failure — the port may already be up while
|
|
249
|
+
// the CLI never printed a parseable URL. Probe before giving up.
|
|
250
|
+
if (await probe(LOCAL_URL, 1500)) {
|
|
251
|
+
setStatus('running')
|
|
252
|
+
r.resolve(LOCAL_URL)
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
terminate(child)
|
|
256
|
+
setStatus('error', 'dsh 后端启动超时(45s)')
|
|
257
|
+
r.reject(new Error(lastError))
|
|
258
|
+
}, 45000)
|
|
259
|
+
|
|
260
|
+
const onData = () => {
|
|
261
|
+
const text = stdout + stderr
|
|
262
|
+
// Preferred: the exact `dsh web: http://127.0.0.1:<port>/?token=…`
|
|
263
|
+
// banner. Anchoring on the banner and requiring the token means we only
|
|
264
|
+
// ever adopt a credential the backend actually handed us, and only from
|
|
265
|
+
// loopback — a launch token is a local-process secret and must never be
|
|
266
|
+
// taken from a remote host or from an unrelated line in the buffer.
|
|
267
|
+
const tokened = text.match(/dsh web:\s+(https?:\/\/127\.0\.0\.1:\d+\/\?token=[^\s]+)/)
|
|
268
|
+
// Fallback for pre-0.1.2 backends, which print a bare loopback URL and
|
|
269
|
+
// need no credential at all: keep the full printed URL (query tail
|
|
270
|
+
// optional) so those still go ready the moment the line appears instead
|
|
271
|
+
// of waiting out the 45s startup timeout.
|
|
272
|
+
const bare = tokened ? null : text.match(/http:\/\/127\.0\.0\.1:\d+(?:\/[^\s"')]*[^\s"')]?)?/)
|
|
273
|
+
const url = tokened ? tokened[1] : bare && bare[0]
|
|
274
|
+
if (url && startResolver) {
|
|
275
|
+
clearTimeout(timer)
|
|
276
|
+
const r = startResolver
|
|
277
|
+
startResolver = null
|
|
278
|
+
currentLaunchUrl = url
|
|
279
|
+
setStatus('running')
|
|
280
|
+
r.resolve(url)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
child.stdout.on('data', (d) => {
|
|
284
|
+
stdout = cappedAppend(stdout, d.toString())
|
|
285
|
+
onData()
|
|
286
|
+
})
|
|
287
|
+
child.stderr.on('data', (d) => {
|
|
288
|
+
stderr = cappedAppend(stderr, d.toString())
|
|
289
|
+
onData()
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Stop the backend.
|
|
296
|
+
*
|
|
297
|
+
* - If the backend was spawned by us, terminate our child (tree kill — a
|
|
298
|
+
* shell-spawned .cmd can leave orphan node processes behind).
|
|
299
|
+
* - Otherwise (an external instance, e.g. the user ran `dsh web` themselves)
|
|
300
|
+
* find the process listening on the port and terminate it, but only when
|
|
301
|
+
* it looks like a node-based backend, never an unrelated program.
|
|
302
|
+
*/
|
|
303
|
+
export async function stop() {
|
|
304
|
+
// Any process launch token dies with its backend. Do not carry it into a
|
|
305
|
+
// future process (DSH regenerates the token on every `dsh web` start).
|
|
306
|
+
currentLaunchUrl = null
|
|
307
|
+
if (child) {
|
|
308
|
+
const proc = child
|
|
309
|
+
child = null
|
|
310
|
+
// Intentional lifecycle stop of the managed child — a confirmed exit
|
|
311
|
+
// (cause 'stop'), unlike an observed unreachability.
|
|
312
|
+
setStatus('stopped', null, 'stop')
|
|
313
|
+
await terminate(proc)
|
|
314
|
+
await ensurePortFree(DEFAULT_PORT)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// External instance: locate it by port and kill (node-only guard).
|
|
319
|
+
const pid = await findProcessOnPort(DEFAULT_PORT)
|
|
320
|
+
if (pid) {
|
|
321
|
+
const name = await processName(pid)
|
|
322
|
+
if (name && /node/i.test(name)) {
|
|
323
|
+
await killProcess(pid)
|
|
324
|
+
await ensurePortFree(DEFAULT_PORT)
|
|
325
|
+
setStatus('stopped', null, 'stop')
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// Nothing was terminated: no status change at all. Emitting 'stopped'
|
|
329
|
+
// here would fabricate a confirmed exit (cause 'stop') where none
|
|
330
|
+
// happened — a probe-derived 'stopped' must stay an observation.
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Terminate a spawned backend and wait for it to exit.
|
|
337
|
+
*
|
|
338
|
+
* On Windows `proc.kill()` only reaches the direct child — when the command
|
|
339
|
+
* is a .cmd shim, the node process it launched survives and keeps holding
|
|
340
|
+
* the port. `taskkill /T` (tree kill) is the recognized fix. POSIX: SIGTERM
|
|
341
|
+
* first, SIGKILL after a short grace period.
|
|
342
|
+
*/
|
|
343
|
+
async function terminate(proc) {
|
|
344
|
+
if (!proc || proc.exitCode !== null || proc.signalCode !== null) return
|
|
345
|
+
const exited = new Promise((resolve) => proc.once('exit', resolve))
|
|
346
|
+
if (process.platform === 'win32' && proc.pid) {
|
|
347
|
+
await killProcess(proc.pid)
|
|
348
|
+
} else {
|
|
349
|
+
try {
|
|
350
|
+
proc.kill('SIGTERM')
|
|
351
|
+
} catch {
|
|
352
|
+
// already gone
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const done = await Promise.race([exited.then(() => true), sleep(3000).then(() => false)])
|
|
356
|
+
if (!done && process.platform !== 'win32') {
|
|
357
|
+
try {
|
|
358
|
+
proc.kill('SIGKILL')
|
|
359
|
+
} catch {
|
|
360
|
+
// already gone
|
|
361
|
+
}
|
|
362
|
+
await Promise.race([exited, sleep(1000)])
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Wait until nothing answers on the port (or give up after ~6s). */
|
|
367
|
+
async function ensurePortFree(port, tries = 12) {
|
|
368
|
+
const url = `http://127.0.0.1:${port}`
|
|
369
|
+
for (let i = 0; i < tries; i++) {
|
|
370
|
+
if (!(await probe(url, 500))) return true
|
|
371
|
+
await sleep(500)
|
|
372
|
+
}
|
|
373
|
+
return !(await probe(url, 500))
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Find the PID listening on a TCP port (netstat on Windows, lsof elsewhere). */
|
|
377
|
+
function findProcessOnPort(port) {
|
|
378
|
+
return new Promise((resolve) => {
|
|
379
|
+
const isWin = process.platform === 'win32'
|
|
380
|
+
const cmd = isWin ? 'netstat' : 'lsof'
|
|
381
|
+
const args = isWin ? ['-ano'] : ['-ti', `:${port}`]
|
|
382
|
+
const p = spawn(cmd, args, { windowsHide: true })
|
|
383
|
+
let out = ''
|
|
384
|
+
p.stdout.on('data', (d) => {
|
|
385
|
+
out += d.toString()
|
|
386
|
+
})
|
|
387
|
+
p.on('error', () => resolve(null))
|
|
388
|
+
p.on('exit', () => {
|
|
389
|
+
if (isWin) {
|
|
390
|
+
const re = new RegExp(`\\bTCP\\s+[^\\s]*:${port}\\s+[^\\s]*\\s+LISTENING\\s+(\\d+)`)
|
|
391
|
+
const m = out.match(re)
|
|
392
|
+
resolve(m ? m[1] : null)
|
|
393
|
+
} else {
|
|
394
|
+
const pid = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean)
|
|
395
|
+
resolve(pid || null)
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Process image name for a PID (Windows tasklist; null elsewhere). */
|
|
402
|
+
function processName(pid) {
|
|
403
|
+
return new Promise((resolve) => {
|
|
404
|
+
if (process.platform !== 'win32') {
|
|
405
|
+
resolve(null)
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
const p = spawn('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
|
|
409
|
+
windowsHide: true,
|
|
410
|
+
})
|
|
411
|
+
let out = ''
|
|
412
|
+
p.stdout.on('data', (d) => {
|
|
413
|
+
out += d.toString()
|
|
414
|
+
})
|
|
415
|
+
p.on('error', () => resolve(null))
|
|
416
|
+
p.on('exit', () => {
|
|
417
|
+
// CSV row: "image name","pid","session name",...
|
|
418
|
+
const m = out.match(/"([^"]+)","(\d+)"/)
|
|
419
|
+
resolve(m ? m[1] : null)
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Force-kill a process (taskkill on Windows, kill -9 elsewhere). */
|
|
425
|
+
function killProcess(pid) {
|
|
426
|
+
return new Promise((resolve) => {
|
|
427
|
+
const isWin = process.platform === 'win32'
|
|
428
|
+
const cmd = isWin ? 'taskkill' : 'kill'
|
|
429
|
+
const args = isWin ? ['/PID', String(pid), '/F', '/T'] : ['-9', String(pid)]
|
|
430
|
+
const p = spawn(cmd, args, { windowsHide: true, stdio: 'ignore' })
|
|
431
|
+
p.on('error', () => resolve(false))
|
|
432
|
+
p.on('exit', () => resolve(true))
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Restart the local backend. */
|
|
437
|
+
export async function restart(options) {
|
|
438
|
+
await stop()
|
|
439
|
+
return await start(options)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Check whether a folder contains a usable dsh CLI.
|
|
444
|
+
* Returns the resolved executable path, or null.
|
|
445
|
+
*/
|
|
446
|
+
export async function findDshInFolder(folder) {
|
|
447
|
+
if (!folder) return null
|
|
448
|
+
const candidates = [
|
|
449
|
+
joinCmd(folder, 'dsh.cmd'),
|
|
450
|
+
joinCmd(folder, 'dsh'),
|
|
451
|
+
joinCmd(folder, 'bin', 'dsh.cmd'),
|
|
452
|
+
joinCmd(folder, 'node_modules', '.bin', 'dsh.cmd'),
|
|
453
|
+
]
|
|
454
|
+
for (const c of candidates) {
|
|
455
|
+
if (await exists(c)) return c
|
|
456
|
+
}
|
|
457
|
+
return null
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Candidate dsh CLI locations beyond the explicit config path:
|
|
462
|
+
* the DSH_BACKEND_DIR env var (documented, machine-independent) and the
|
|
463
|
+
* npm global bin dir. The previous list carried a developer-specific
|
|
464
|
+
* absolute path — removed in favor of config (backendPath) and env.
|
|
465
|
+
*/
|
|
466
|
+
function fallbackCandidates() {
|
|
467
|
+
const list = []
|
|
468
|
+
const envDir = process.env.DSH_BACKEND_DIR
|
|
469
|
+
if (envDir) list.push(join(envDir, 'dsh.cmd'), join(envDir, 'dsh'))
|
|
470
|
+
if (process.platform === 'win32') {
|
|
471
|
+
list.push(join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'npm', 'dsh.cmd'))
|
|
472
|
+
} else {
|
|
473
|
+
list.push('/usr/local/bin/dsh', join(homedir(), '.local', 'bin', 'dsh'))
|
|
474
|
+
}
|
|
475
|
+
return list
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Auto-detect the dsh install folder (the folder that contains the dsh
|
|
480
|
+
* CLI), following the same resolution order used to start the backend:
|
|
481
|
+
* config → DSH_BACKEND_DIR → PATH → npm global. Returns the folder or null.
|
|
482
|
+
*/
|
|
483
|
+
export async function detectInstallFolder() {
|
|
484
|
+
// 1) Explicit backend folder from config.
|
|
485
|
+
const cfgPath = getConfiguredBackendPath()
|
|
486
|
+
if (cfgPath) {
|
|
487
|
+
const found = await findDshInFolder(cfgPath)
|
|
488
|
+
if (found) return dirOf(found)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// 2) Candidates: DSH_BACKEND_DIR env, then npm global bin.
|
|
492
|
+
for (const c of fallbackCandidates()) {
|
|
493
|
+
if (await exists(c)) return dirOf(c)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 3) `dsh` on PATH → resolve where it actually lives.
|
|
497
|
+
const onPath = await commandPath('dsh')
|
|
498
|
+
if (onPath) return dirOf(onPath)
|
|
499
|
+
return null
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Locate a usable dsh CLI. */
|
|
503
|
+
async function resolveDshCommand(backendPath) {
|
|
504
|
+
// 1) Explicit backend folder from config — look for dsh(.cmd/.exe) inside.
|
|
505
|
+
if (backendPath) {
|
|
506
|
+
const found = await findDshInFolder(backendPath)
|
|
507
|
+
if (found) return { command: found, args: [] }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// 2) Candidates: DSH_BACKEND_DIR env, then npm global bin.
|
|
511
|
+
for (const c of fallbackCandidates()) {
|
|
512
|
+
if (await exists(c)) return { command: c, args: [] }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// 3) `dsh` on PATH — resolve to the real file so .cmd/.bat gets the
|
|
516
|
+
// shell treatment (a bare `spawn('dsh')` would ENOENT on Windows).
|
|
517
|
+
const onPath = await commandPath('dsh')
|
|
518
|
+
if (onPath) {
|
|
519
|
+
return { command: /\.(cmd|bat)$/i.test(onPath) ? onPath : 'dsh', args: [] }
|
|
520
|
+
}
|
|
521
|
+
return null
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function joinCmd(...parts) {
|
|
525
|
+
return parts.join(process.platform === 'win32' ? '\\' : '/')
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function dirOf(p) {
|
|
529
|
+
return dirname(p)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function getConfiguredBackendPath() {
|
|
533
|
+
try {
|
|
534
|
+
return loadConfig().backendPath || null
|
|
535
|
+
} catch {
|
|
536
|
+
return null
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Resolve a command on PATH to its absolute path (Windows: where.exe). */
|
|
541
|
+
function commandPath(cmd) {
|
|
542
|
+
return new Promise((resolve) => {
|
|
543
|
+
const finder = process.platform === 'win32' ? 'where' : 'which'
|
|
544
|
+
const c = spawn(finder, [cmd], { windowsHide: true })
|
|
545
|
+
let out = ''
|
|
546
|
+
c.stdout.on('data', (d) => {
|
|
547
|
+
out += d.toString()
|
|
548
|
+
})
|
|
549
|
+
c.on('error', () => resolve(null))
|
|
550
|
+
c.on('exit', () => {
|
|
551
|
+
const line = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean)
|
|
552
|
+
resolve(line || null)
|
|
553
|
+
})
|
|
554
|
+
})
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function exists(p) {
|
|
558
|
+
return new Promise((resolve) => access(p, (err) => resolve(!err)))
|
|
559
|
+
}
|