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.
@@ -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
- * Shell/core decoupling: when a remote target URL is configured, no local
10
- * backend is ever touched this module only manages the local dsh CLI.
11
- */
12
- import { spawn } from 'node:child_process'
13
- import { access } from 'node:fs'
14
- import { homedir } from 'node:os'
15
- import { dirname, join } from 'node:path'
16
- import { loadConfig, DEFAULT_TARGET_URL } from './config.js'
17
-
18
- // The local backend endpoint — derived from the shared default, never a
19
- // duplicated literal.
20
- const LOCAL_URL = DEFAULT_TARGET_URL
21
- const DEFAULT_PORT = Number(new URL(DEFAULT_TARGET_URL).port) || 80
22
-
23
- // Long-lived backend output must not accumulate without bound — keep the
24
- // tail only (enough for ready-line matching and diagnostics).
25
- const OUTPUT_CAP = 64 * 1024
26
-
27
- function cappedAppend(prev, chunk) {
28
- const s = prev + chunk
29
- return s.length > OUTPUT_CAP ? s.slice(-OUTPUT_CAP) : s
30
- }
31
-
32
- let child = null
33
- let currentStatus = 'stopped'
34
- let lastError = null
35
- let startResolver = null
36
-
37
- // Status-change listeners (tray menu auto-refresh, window auto-reload, ...).
38
- const listeners = new Set()
39
-
40
- /** Subscribe to backend status changes. Returns an unsubscribe function. */
41
- export function onStatusChange(cb) {
42
- listeners.add(cb)
43
- return () => listeners.delete(cb)
44
- }
45
-
46
- function setStatus(next, error = null) {
47
- if (currentStatus !== next || lastError !== error) {
48
- currentStatus = next
49
- lastError = error
50
- for (const cb of listeners) {
51
- try {
52
- cb(getStatus())
53
- } catch {
54
- // listener errors must not break the state machine
55
- }
56
- }
57
- }
58
- }
59
-
60
- export function getStatus() {
61
- return {
62
- status: currentStatus,
63
- port: DEFAULT_PORT,
64
- url: LOCAL_URL,
65
- error: lastError,
66
- pid: child?.pid ?? null,
67
- }
68
- }
69
-
70
- /** Simple HTTP probe — true when something responds on the port. */
71
- export async function probe(url, timeoutMs = 1500) {
72
- try {
73
- // AbortSignal.timeout is the standard self-cleaning timeout — no manual
74
- // controller/timer pair to leak when fetch rejects first.
75
- const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'follow' })
76
- return res.status < 500
77
- } catch {
78
- return false
79
- }
80
- }
81
-
82
- /**
83
- * Detect a reachable local dsh web service.
84
- * Returns the URL when something already listens on the port,
85
- * or null when the backend is down.
86
- */
87
- export async function detect() {
88
- const up = await probe(LOCAL_URL)
89
- if (up) {
90
- setStatus('running')
91
- return LOCAL_URL
92
- }
93
- setStatus('stopped')
94
- return null
95
- }
96
-
97
- /**
98
- * Start the local dsh backend. Resolves once the service answers
99
- * (or a spawned CLI prints its ready line). Throws on failure.
100
- */
101
- export async function start({ backendPath } = {}) {
102
- // Already up?
103
- const up = await detect()
104
- if (up) return up
105
-
106
- // Backend path: explicit config → common locations → PATH.
107
- const resolved = await resolveDshCommand(backendPath)
108
- if (!resolved) {
109
- setStatus('error', '未找到 dsh 后端。请在托盘「设置后端文件夹」中指定 dsh CLI 所在目录。')
110
- throw new Error(lastError)
111
- }
112
-
113
- setStatus('starting')
114
-
115
- // Windows: .cmd/.bat cannot be spawned directlyEINVAL. Route them
116
- // through the shell so `dsh.cmd web` behaves like a normal terminal.
117
- const isCmd = process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved.command)
118
- const command = isCmd ? `"${resolved.command}"` : resolved.command
119
-
120
- let spawned = null
121
- try {
122
- spawned = spawn(command, [...resolved.args, 'web'], {
123
- stdio: ['ignore', 'pipe', 'pipe'],
124
- windowsHide: true,
125
- shell: isCmd,
126
- env: { ...process.env, ...(resolved.env || {}) },
127
- })
128
- } catch (err) {
129
- // Synchronous spawn failures (e.g. EINVAL on Windows) must leave the
130
- // state machine in 'error' — otherwise the tray is stuck on 'starting'.
131
- setStatus('error', `后端启动失败:${err.message}`)
132
- throw new Error(lastError)
133
- }
134
- child = spawned
135
-
136
- child.on('error', (err) => {
137
- setStatus('error', err.message)
138
- if (startResolver) {
139
- const r = startResolver
140
- startResolver = null
141
- r.reject(new Error(lastError))
142
- }
143
- })
144
-
145
- child.on('exit', (code) => {
146
- child = null
147
- if (currentStatus === 'starting' && startResolver) {
148
- const r = startResolver
149
- startResolver = null
150
- setStatus('error', `dsh 后端异常退出 (code ${code})`)
151
- r.reject(new Error(lastError))
152
- } else if (currentStatus !== 'stopped') {
153
- setStatus('stopped')
154
- }
155
- })
156
-
157
- // Wait for readiness: ready line on stdout/stderr, or the port answering.
158
- return await new Promise((resolve, reject) => {
159
- startResolver = { resolve, reject }
160
- let stdout = ''
161
- let stderr = ''
162
- const timer = setTimeout(async () => {
163
- if (!startResolver) return
164
- const r = startResolver
165
- startResolver = null
166
- // Timeout does not mean failure — the port may already be up while
167
- // the CLI never printed a parseable URL. Probe before giving up.
168
- if (await probe(LOCAL_URL, 1500)) {
169
- setStatus('running')
170
- r.resolve(LOCAL_URL)
171
- return
172
- }
173
- terminate(child)
174
- setStatus('error', 'dsh 后端启动超时(45s)')
175
- r.reject(new Error(lastError))
176
- }, 45000)
177
-
178
- const onData = () => {
179
- const text = stdout + stderr
180
- const m = text.match(/http:\/\/127\.0\.0\.1:(\d+)/)
181
- if (m && startResolver) {
182
- clearTimeout(timer)
183
- const r = startResolver
184
- startResolver = null
185
- const url = `http://127.0.0.1:${m[1]}`
186
- setStatus('running')
187
- r.resolve(url)
188
- }
189
- }
190
- child.stdout.on('data', (d) => {
191
- stdout = cappedAppend(stdout, d.toString())
192
- onData()
193
- })
194
- child.stderr.on('data', (d) => {
195
- stderr = cappedAppend(stderr, d.toString())
196
- onData()
197
- })
198
- })
199
- }
200
-
201
- /**
202
- * Stop the backend.
203
- *
204
- * - If the backend was spawned by us, terminate our child (tree kill — a
205
- * shell-spawned .cmd can leave orphan node processes behind).
206
- * - Otherwise (an external instance, e.g. the user ran `dsh web` themselves)
207
- * find the process listening on the port and terminate it, but only when
208
- * it looks like a node-based backend, never an unrelated program.
209
- */
210
- export async function stop() {
211
- if (child) {
212
- const proc = child
213
- child = null
214
- setStatus('stopped')
215
- await terminate(proc)
216
- await ensurePortFree(DEFAULT_PORT)
217
- return
218
- }
219
-
220
- // External instance: locate it by port and kill (node-only guard).
221
- const pid = await findProcessOnPort(DEFAULT_PORT)
222
- if (pid) {
223
- const name = await processName(pid)
224
- if (name && /node/i.test(name)) {
225
- await killProcess(pid)
226
- await ensurePortFree(DEFAULT_PORT)
227
- }
228
- }
229
- setStatus('stopped')
230
- }
231
-
232
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
233
-
234
- /**
235
- * Terminate a spawned backend and wait for it to exit.
236
- *
237
- * On Windows `proc.kill()` only reaches the direct child — when the command
238
- * is a .cmd shim, the node process it launched survives and keeps holding
239
- * the port. `taskkill /T` (tree kill) is the recognized fix. POSIX: SIGTERM
240
- * first, SIGKILL after a short grace period.
241
- */
242
- async function terminate(proc) {
243
- if (!proc || proc.exitCode !== null || proc.signalCode !== null) return
244
- const exited = new Promise((resolve) => proc.once('exit', resolve))
245
- if (process.platform === 'win32' && proc.pid) {
246
- await killProcess(proc.pid)
247
- } else {
248
- try {
249
- proc.kill('SIGTERM')
250
- } catch {
251
- // already gone
252
- }
253
- }
254
- const done = await Promise.race([exited.then(() => true), sleep(3000).then(() => false)])
255
- if (!done && process.platform !== 'win32') {
256
- try {
257
- proc.kill('SIGKILL')
258
- } catch {
259
- // already gone
260
- }
261
- await Promise.race([exited, sleep(1000)])
262
- }
263
- }
264
-
265
- /** Wait until nothing answers on the port (or give up after ~6s). */
266
- async function ensurePortFree(port, tries = 12) {
267
- const url = `http://127.0.0.1:${port}`
268
- for (let i = 0; i < tries; i++) {
269
- if (!(await probe(url, 500))) return true
270
- await sleep(500)
271
- }
272
- return !(await probe(url, 500))
273
- }
274
-
275
- /** Find the PID listening on a TCP port (netstat on Windows, lsof elsewhere). */
276
- function findProcessOnPort(port) {
277
- return new Promise((resolve) => {
278
- const isWin = process.platform === 'win32'
279
- const cmd = isWin ? 'netstat' : 'lsof'
280
- const args = isWin ? ['-ano'] : ['-ti', `:${port}`]
281
- const p = spawn(cmd, args, { windowsHide: true })
282
- let out = ''
283
- p.stdout.on('data', (d) => {
284
- out += d.toString()
285
- })
286
- p.on('error', () => resolve(null))
287
- p.on('exit', () => {
288
- if (isWin) {
289
- const re = new RegExp(`\\bTCP\\s+[^\\s]*:${port}\\s+[^\\s]*\\s+LISTENING\\s+(\\d+)`)
290
- const m = out.match(re)
291
- resolve(m ? m[1] : null)
292
- } else {
293
- const pid = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean)
294
- resolve(pid || null)
295
- }
296
- })
297
- })
298
- }
299
-
300
- /** Process image name for a PID (Windows tasklist; null elsewhere). */
301
- function processName(pid) {
302
- return new Promise((resolve) => {
303
- if (process.platform !== 'win32') {
304
- resolve(null)
305
- return
306
- }
307
- const p = spawn('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
308
- windowsHide: true,
309
- })
310
- let out = ''
311
- p.stdout.on('data', (d) => {
312
- out += d.toString()
313
- })
314
- p.on('error', () => resolve(null))
315
- p.on('exit', () => {
316
- // CSV row: "image name","pid","session name",...
317
- const m = out.match(/"([^"]+)","(\d+)"/)
318
- resolve(m ? m[1] : null)
319
- })
320
- })
321
- }
322
-
323
- /** Force-kill a process (taskkill on Windows, kill -9 elsewhere). */
324
- function killProcess(pid) {
325
- return new Promise((resolve) => {
326
- const isWin = process.platform === 'win32'
327
- const cmd = isWin ? 'taskkill' : 'kill'
328
- const args = isWin ? ['/PID', String(pid), '/F', '/T'] : ['-9', String(pid)]
329
- const p = spawn(cmd, args, { windowsHide: true, stdio: 'ignore' })
330
- p.on('error', () => resolve(false))
331
- p.on('exit', () => resolve(true))
332
- })
333
- }
334
-
335
- /** Restart the local backend. */
336
- export async function restart(options) {
337
- await stop()
338
- return await start(options)
339
- }
340
-
341
- /**
342
- * Check whether a folder contains a usable dsh CLI.
343
- * Returns the resolved executable path, or null.
344
- */
345
- export async function findDshInFolder(folder) {
346
- if (!folder) return null
347
- const candidates = [
348
- joinCmd(folder, 'dsh.cmd'),
349
- joinCmd(folder, 'dsh'),
350
- joinCmd(folder, 'bin', 'dsh.cmd'),
351
- joinCmd(folder, 'node_modules', '.bin', 'dsh.cmd'),
352
- ]
353
- for (const c of candidates) {
354
- if (await exists(c)) return c
355
- }
356
- return null
357
- }
358
-
359
- /**
360
- * Candidate dsh CLI locations beyond the explicit config path:
361
- * the DSH_BACKEND_DIR env var (documented, machine-independent) and the
362
- * npm global bin dir. The previous list carried a developer-specific
363
- * absolute path — removed in favor of config (backendPath) and env.
364
- */
365
- function fallbackCandidates() {
366
- const list = []
367
- const envDir = process.env.DSH_BACKEND_DIR
368
- if (envDir) list.push(join(envDir, 'dsh.cmd'), join(envDir, 'dsh'))
369
- if (process.platform === 'win32') {
370
- list.push(join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'npm', 'dsh.cmd'))
371
- } else {
372
- list.push('/usr/local/bin/dsh', join(homedir(), '.local', 'bin', 'dsh'))
373
- }
374
- return list
375
- }
376
-
377
- /**
378
- * Auto-detect the dsh install folder (the folder that contains the dsh
379
- * CLI), following the same resolution order used to start the backend:
380
- * config DSH_BACKEND_DIR PATH → npm global. Returns the folder or null.
381
- */
382
- export async function detectInstallFolder() {
383
- // 1) Explicit backend folder from config.
384
- const cfgPath = getConfiguredBackendPath()
385
- if (cfgPath) {
386
- const found = await findDshInFolder(cfgPath)
387
- if (found) return dirOf(found)
388
- }
389
-
390
- // 2) Candidates: DSH_BACKEND_DIR env, then npm global bin.
391
- for (const c of fallbackCandidates()) {
392
- if (await exists(c)) return dirOf(c)
393
- }
394
-
395
- // 3) `dsh` on PATH → resolve where it actually lives.
396
- const onPath = await commandPath('dsh')
397
- if (onPath) return dirOf(onPath)
398
- return null
399
- }
400
-
401
- /** Locate a usable dsh CLI. */
402
- async function resolveDshCommand(backendPath) {
403
- // 1) Explicit backend folder from config — look for dsh(.cmd/.exe) inside.
404
- if (backendPath) {
405
- const found = await findDshInFolder(backendPath)
406
- if (found) return { command: found, args: [] }
407
- }
408
-
409
- // 2) Candidates: DSH_BACKEND_DIR env, then npm global bin.
410
- for (const c of fallbackCandidates()) {
411
- if (await exists(c)) return { command: c, args: [] }
412
- }
413
-
414
- // 3) `dsh` on PATH — resolve to the real file so .cmd/.bat gets the
415
- // shell treatment (a bare `spawn('dsh')` would ENOENT on Windows).
416
- const onPath = await commandPath('dsh')
417
- if (onPath) {
418
- return { command: /\.(cmd|bat)$/i.test(onPath) ? onPath : 'dsh', args: [] }
419
- }
420
- return null
421
- }
422
-
423
- function joinCmd(...parts) {
424
- return parts.join(process.platform === 'win32' ? '\\' : '/')
425
- }
426
-
427
- function dirOf(p) {
428
- return dirname(p)
429
- }
430
-
431
- function getConfiguredBackendPath() {
432
- try {
433
- return loadConfig().backendPath || null
434
- } catch {
435
- return null
436
- }
437
- }
438
-
439
- /** Resolve a command on PATH to its absolute path (Windows: where.exe). */
440
- function commandPath(cmd) {
441
- return new Promise((resolve) => {
442
- const finder = process.platform === 'win32' ? 'where' : 'which'
443
- const c = spawn(finder, [cmd], { windowsHide: true })
444
- let out = ''
445
- c.stdout.on('data', (d) => {
446
- out += d.toString()
447
- })
448
- c.on('error', () => resolve(null))
449
- c.on('exit', () => {
450
- const line = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean)
451
- resolve(line || null)
452
- })
453
- })
454
- }
455
-
456
- function exists(p) {
457
- return new Promise((resolve) => access(p, (err) => resolve(!err)))
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
+ }