dsh-clean-desktop-shell 0.1.0 → 0.1.2

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.
@@ -21,6 +21,29 @@ let currentStatus = 'stopped'
21
21
  let lastError = null
22
22
  let startResolver = null
23
23
 
24
+ // Status-change listeners (tray menu auto-refresh, window auto-reload, ...).
25
+ const listeners = new Set()
26
+
27
+ /** Subscribe to backend status changes. Returns an unsubscribe function. */
28
+ export function onStatusChange(cb) {
29
+ listeners.add(cb)
30
+ return () => listeners.delete(cb)
31
+ }
32
+
33
+ function setStatus(next, error = null) {
34
+ if (currentStatus !== next || lastError !== error) {
35
+ currentStatus = next
36
+ lastError = error
37
+ for (const cb of listeners) {
38
+ try {
39
+ cb(getStatus())
40
+ } catch {
41
+ // listener errors must not break the state machine
42
+ }
43
+ }
44
+ }
45
+ }
46
+
24
47
  export function getStatus() {
25
48
  return {
26
49
  status: currentStatus,
@@ -53,10 +76,10 @@ export async function detect() {
53
76
  const url = `http://127.0.0.1:${DEFAULT_PORT}`
54
77
  const up = await probe(url)
55
78
  if (up) {
56
- currentStatus = 'running'
79
+ setStatus('running')
57
80
  return url
58
81
  }
59
- currentStatus = 'stopped'
82
+ setStatus('stopped')
60
83
  return null
61
84
  }
62
85
 
@@ -72,51 +95,74 @@ export async function start({ backendPath } = {}) {
72
95
  // Backend path: explicit config → common locations → PATH.
73
96
  const resolved = await resolveDshCommand(backendPath)
74
97
  if (!resolved) {
75
- lastError = '未找到 dsh 后端。请在托盘「设置后端文件夹」中指定 dsh CLI 所在目录。'
76
- currentStatus = 'error'
98
+ setStatus('error', '未找到 dsh 后端。请在托盘「设置后端文件夹」中指定 dsh CLI 所在目录。')
77
99
  throw new Error(lastError)
78
100
  }
79
101
 
80
- currentStatus = 'starting'
81
- lastError = null
82
- child = spawn(resolved.command, [...resolved.args, 'web'], {
83
- stdio: ['ignore', 'pipe', 'pipe'],
84
- windowsHide: true,
85
- env: { ...process.env, ...(resolved.env || {}) },
86
- })
102
+ setStatus('starting')
103
+
104
+ // Windows: .cmd/.bat cannot be spawned directly — EINVAL. Route them
105
+ // through the shell so `dsh.cmd web` behaves like a normal terminal.
106
+ const isCmd = process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved.command)
107
+ const command = isCmd ? `"${resolved.command}"` : resolved.command
108
+
109
+ let spawned = null
110
+ try {
111
+ spawned = spawn(command, [...resolved.args, 'web'], {
112
+ stdio: ['ignore', 'pipe', 'pipe'],
113
+ windowsHide: true,
114
+ shell: isCmd,
115
+ env: { ...process.env, ...(resolved.env || {}) },
116
+ })
117
+ } catch (err) {
118
+ // Synchronous spawn failures (e.g. EINVAL on Windows) must leave the
119
+ // state machine in 'error' — otherwise the tray is stuck on 'starting'.
120
+ setStatus('error', `后端启动失败:${err.message}`)
121
+ throw new Error(lastError)
122
+ }
123
+ child = spawned
87
124
 
88
125
  child.on('error', (err) => {
89
- lastError = err.message
90
- currentStatus = 'error'
126
+ setStatus('error', err.message)
91
127
  if (startResolver) {
92
- startResolver.reject(new Error(lastError))
128
+ const r = startResolver
93
129
  startResolver = null
130
+ r.reject(new Error(lastError))
94
131
  }
95
132
  })
96
133
 
97
134
  child.on('exit', (code) => {
98
135
  child = null
99
136
  if (currentStatus === 'starting' && startResolver) {
100
- lastError = `dsh 后端异常退出 (code ${code})`
101
- currentStatus = 'error'
102
- startResolver.reject(new Error(lastError))
137
+ const r = startResolver
103
138
  startResolver = null
139
+ setStatus('error', `dsh 后端异常退出 (code ${code})`)
140
+ r.reject(new Error(lastError))
104
141
  } else if (currentStatus !== 'stopped') {
105
- currentStatus = 'stopped'
142
+ setStatus('stopped')
106
143
  }
107
144
  })
108
145
 
109
- // Wait for the ready line with a timeout.
146
+ // Wait for readiness: ready line on stdout/stderr, or the port answering.
110
147
  return await new Promise((resolve, reject) => {
111
148
  startResolver = { resolve, reject }
112
149
  let stdout = ''
113
150
  let stderr = ''
114
- const timer = setTimeout(() => {
115
- child?.kill()
151
+ const timer = setTimeout(async () => {
152
+ if (!startResolver) return
153
+ const r = startResolver
116
154
  startResolver = null
117
- lastError = 'dsh 后端启动超时(45s)'
118
- currentStatus = 'error'
119
- reject(new Error(lastError))
155
+ // Timeout does not mean failure — the port may already be up while
156
+ // the CLI never printed a parseable URL. Probe before giving up.
157
+ const url = `http://127.0.0.1:${DEFAULT_PORT}`
158
+ if (await probe(url, 1500)) {
159
+ setStatus('running')
160
+ r.resolve(url)
161
+ return
162
+ }
163
+ child?.kill()
164
+ setStatus('error', 'dsh 后端启动超时(45s)')
165
+ r.reject(new Error(lastError))
120
166
  }, 45000)
121
167
 
122
168
  const onData = () => {
@@ -124,9 +170,11 @@ export async function start({ backendPath } = {}) {
124
170
  const m = text.match(/http:\/\/127\.0\.0\.1:(\d+)/)
125
171
  if (m && startResolver) {
126
172
  clearTimeout(timer)
127
- currentStatus = 'running'
128
- startResolver.resolve(`http://127.0.0.1:${m[1]}`)
173
+ const r = startResolver
129
174
  startResolver = null
175
+ const url = `http://127.0.0.1:${m[1]}`
176
+ setStatus('running')
177
+ r.resolve(url)
130
178
  }
131
179
  }
132
180
  child.stdout.on('data', (d) => {
@@ -140,19 +188,110 @@ export async function start({ backendPath } = {}) {
140
188
  })
141
189
  }
142
190
 
143
- /** Stop the local backend (spawned by us). External instances are left alone. */
191
+ /**
192
+ * Stop the backend.
193
+ *
194
+ * - If the backend was spawned by us, kill our child (and force-free the
195
+ * port — a shell-spawned .cmd can leave orphan node processes behind).
196
+ * - Otherwise (an external instance, e.g. the user ran `dsh web` themselves)
197
+ * find the process listening on the port and terminate it, but only when
198
+ * it looks like a node-based backend, never an unrelated program.
199
+ */
144
200
  export async function stop() {
145
- if (!child) {
146
- currentStatus = 'stopped'
201
+ if (child) {
202
+ const proc = child
203
+ child = null
204
+ setStatus('stopped')
205
+ await new Promise((resolve) => {
206
+ proc.once('exit', resolve)
207
+ proc.kill()
208
+ setTimeout(resolve, 3000)
209
+ })
210
+ await ensurePortFree(DEFAULT_PORT)
147
211
  return
148
212
  }
149
- const proc = child
150
- child = null
151
- currentStatus = 'stopped'
152
- await new Promise((resolve) => {
153
- proc.once('exit', resolve)
154
- proc.kill()
155
- setTimeout(resolve, 3000)
213
+
214
+ // External instance: locate it by port and kill (node-only guard).
215
+ const pid = await findProcessOnPort(DEFAULT_PORT)
216
+ if (pid) {
217
+ const name = await processName(pid)
218
+ if (name && /node/i.test(name)) {
219
+ await killProcess(pid)
220
+ await ensurePortFree(DEFAULT_PORT)
221
+ }
222
+ }
223
+ setStatus('stopped')
224
+ }
225
+
226
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
227
+
228
+ /** Wait until nothing answers on the port (or give up after ~6s). */
229
+ async function ensurePortFree(port, tries = 12) {
230
+ const url = `http://127.0.0.1:${port}`
231
+ for (let i = 0; i < tries; i++) {
232
+ if (!(await probe(url, 500))) return true
233
+ await sleep(500)
234
+ }
235
+ return !(await probe(url, 500))
236
+ }
237
+
238
+ /** Find the PID listening on a TCP port (netstat on Windows, lsof elsewhere). */
239
+ function findProcessOnPort(port) {
240
+ return new Promise((resolve) => {
241
+ const isWin = process.platform === 'win32'
242
+ const cmd = isWin ? 'netstat' : 'lsof'
243
+ const args = isWin ? ['-ano'] : ['-ti', `:${port}`]
244
+ const p = spawn(cmd, args, { windowsHide: true })
245
+ let out = ''
246
+ p.stdout.on('data', (d) => {
247
+ out += d.toString()
248
+ })
249
+ p.on('error', () => resolve(null))
250
+ p.on('exit', () => {
251
+ if (isWin) {
252
+ const re = new RegExp(`\\bTCP\\s+[^\\s]*:${port}\\s+[^\\s]*\\s+LISTENING\\s+(\\d+)`)
253
+ const m = out.match(re)
254
+ resolve(m ? m[1] : null)
255
+ } else {
256
+ const pid = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean)
257
+ resolve(pid || null)
258
+ }
259
+ })
260
+ })
261
+ }
262
+
263
+ /** Process image name for a PID (Windows tasklist; null elsewhere). */
264
+ function processName(pid) {
265
+ return new Promise((resolve) => {
266
+ if (process.platform !== 'win32') {
267
+ resolve(null)
268
+ return
269
+ }
270
+ const p = spawn('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
271
+ windowsHide: true,
272
+ })
273
+ let out = ''
274
+ p.stdout.on('data', (d) => {
275
+ out += d.toString()
276
+ })
277
+ p.on('error', () => resolve(null))
278
+ p.on('exit', () => {
279
+ // CSV row: "image name","pid","session name",...
280
+ const m = out.match(/"([^"]+)","(\d+)"/)
281
+ resolve(m ? m[1] : null)
282
+ })
283
+ })
284
+ }
285
+
286
+ /** Force-kill a process (taskkill on Windows, kill -9 elsewhere). */
287
+ function killProcess(pid) {
288
+ return new Promise((resolve) => {
289
+ const isWin = process.platform === 'win32'
290
+ const cmd = isWin ? 'taskkill' : 'kill'
291
+ const args = isWin ? ['/PID', String(pid), '/F', '/T'] : ['-9', String(pid)]
292
+ const p = spawn(cmd, args, { windowsHide: true, stdio: 'ignore' })
293
+ p.on('error', () => resolve(false))
294
+ p.on('exit', () => resolve(true))
156
295
  })
157
296
  }
158
297
 
@@ -218,8 +357,12 @@ async function resolveDshCommand(backendPath) {
218
357
  if (found) return { command: found, args: [] }
219
358
  }
220
359
 
221
- // 2) `dsh` on PATH
222
- if (await commandExists('dsh')) return { command: 'dsh', args: [] }
360
+ // 2) `dsh` on PATH — resolve to the real file so .cmd/.bat gets the
361
+ // shell treatment (a bare `spawn('dsh')` would ENOENT on Windows).
362
+ const onPath = await commandPath('dsh')
363
+ if (onPath) {
364
+ return { command: /\.(cmd|bat)$/i.test(onPath) ? onPath : 'dsh', args: [] }
365
+ }
223
366
 
224
367
  // 3) Common locations (dev convenience).
225
368
  if (process.platform === 'win32') {
@@ -267,15 +410,6 @@ function commandPath(cmd) {
267
410
  })
268
411
  }
269
412
 
270
- function commandExists(cmd) {
271
- return new Promise((resolve) => {
272
- const finder = process.platform === 'win32' ? 'where' : 'which'
273
- const c = spawn(finder, [cmd], { stdio: 'ignore', windowsHide: true })
274
- c.on('error', () => resolve(false))
275
- c.on('exit', (code) => resolve(code === 0))
276
- })
277
- }
278
-
279
413
  function exists(p) {
280
414
  return new Promise((resolve) => access(p, (err) => resolve(!err)))
281
415
  }
package/electron/tray.js CHANGED
@@ -8,8 +8,18 @@ import { Tray, Menu, dialog, nativeImage } from 'electron'
8
8
  import { join } from 'node:path'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { loadConfig, saveConfig } from './config.js'
11
- import { getStatus, start, stop, restart, detect, findDshInFolder, detectInstallFolder } from './service.js'
12
- import { checkForUpdate, openRepo, openUrl } from './update.js'
11
+ import {
12
+ getStatus,
13
+ start,
14
+ stop,
15
+ restart,
16
+ detect,
17
+ findDshInFolder,
18
+ detectInstallFolder,
19
+ onStatusChange,
20
+ } from './service.js'
21
+ import { showProgress, setProgress, closeProgress } from './progress.js'
22
+ import { checkForUpdate, checkForUpdatesAuto, isAutoUpdateSupported, openRepo, openUrl } from './update.js'
13
23
 
14
24
  const trayIconPath = join(
15
25
  fileURLToPath(new URL('.', import.meta.url)),
@@ -20,15 +30,19 @@ const trayIconPath = join(
20
30
  let trayInstance = null
21
31
  let handlers = null
22
32
 
23
- export function createTray({ onShow, onToggleAutoStart, onQuit }) {
33
+ export function createTray({ onShow, onToggleAutoStart, onReload, onQuit }) {
24
34
  const icon = nativeImage.createFromPath(trayIconPath)
25
35
 
26
- handlers = { onShow, onToggleAutoStart, onQuit }
36
+ handlers = { onShow, onToggleAutoStart, onReload, onQuit }
27
37
 
28
38
  trayInstance = new Tray(icon)
29
39
  trayInstance.setToolTip('DSH Clean Desktop Shell')
30
40
  trayInstance.on('click', onShow)
31
41
 
42
+ // Keep the menu in sync whenever the backend state machine changes
43
+ // (starting → running / error / stopped happens asynchronously).
44
+ onStatusChange(() => refreshTrayMenu())
45
+
32
46
  refreshTrayMenu()
33
47
  return trayInstance
34
48
  }
@@ -36,34 +50,34 @@ export function createTray({ onShow, onToggleAutoStart, onQuit }) {
36
50
  /** Rebuild the context menu (call after backend state changes). */
37
51
  export function refreshTrayMenu() {
38
52
  if (!trayInstance || !handlers) return
39
- const { onShow, onToggleAutoStart, onQuit } = handlers
40
- const config = loadConfig()
53
+ const { onShow, onToggleAutoStart, onReload, onQuit } = handlers
41
54
  const st = getStatus()
42
55
  const label = statusLabel(st)
43
56
 
44
57
  const menu = Menu.buildFromTemplate([
45
58
  { label: '显示 / 打开窗口', click: onShow },
59
+ {
60
+ label: '刷新窗口',
61
+ click: onReload,
62
+ },
46
63
  { type: 'separator' },
47
64
  { label: `后端:${label}`, enabled: false },
48
65
  {
49
66
  label: '启动后端',
50
67
  enabled: st.status !== 'running' && st.status !== 'starting',
51
- click: async () => {
52
- try {
53
- await start({ backendPath: loadConfig().backendPath })
54
- } catch (err) {
55
- dialog.showErrorBox('后端启动失败', err.message)
56
- }
57
- refreshTrayMenu()
58
- },
68
+ click: () => startBackendWithProgress(),
59
69
  },
60
70
  {
61
71
  label: '重启后端',
62
72
  enabled: st.status === 'running' || st.status === 'starting',
63
73
  click: async () => {
74
+ showProgress({ title: '重启后端', message: '正在重启 dsh 后端…' })
64
75
  try {
65
76
  await restart({ backendPath: loadConfig().backendPath })
77
+ setProgress({ title: '重启后端', message: '后端已重启', state: 'ok' })
78
+ setTimeout(closeProgress, 1200)
66
79
  } catch (err) {
80
+ closeProgress()
67
81
  dialog.showErrorBox('后端重启失败', err.message)
68
82
  }
69
83
  refreshTrayMenu()
@@ -73,7 +87,15 @@ export function refreshTrayMenu() {
73
87
  label: '关闭后端',
74
88
  enabled: st.status === 'running' || st.status === 'starting',
75
89
  click: async () => {
76
- await stop()
90
+ showProgress({ title: '关闭后端', message: '正在关闭 dsh 后端…' })
91
+ try {
92
+ await stop()
93
+ setProgress({ title: '关闭后端', message: '后端已关闭', state: 'ok' })
94
+ setTimeout(closeProgress, 1200)
95
+ } catch (err) {
96
+ closeProgress()
97
+ dialog.showErrorBox('后端关闭失败', err.message)
98
+ }
77
99
  refreshTrayMenu()
78
100
  },
79
101
  },
@@ -87,44 +109,18 @@ export function refreshTrayMenu() {
87
109
  },
88
110
  {
89
111
  label: '设置后端安装文件夹…',
90
- click: async () => {
91
- // Default location: auto-detect the dsh install folder first.
92
- const detected = await detectInstallFolder()
93
- const defaultFolder = detected || config.backendPath || undefined
94
- const result = await dialog.showOpenDialog({
95
- title: '选择dsh后端安装文件夹,默认安装用自动探测',
96
- buttonLabel: '选择此文件夹',
97
- message: '默认安装用自动探测。可手动指定 dsh 后端安装文件夹(包含 dsh 可执行文件)。',
98
- properties: ['openDirectory'],
99
- defaultPath: defaultFolder,
100
- })
101
- if (!result.canceled && result.filePaths[0]) {
102
- const folder = result.filePaths[0]
103
- saveConfig({ ...loadConfig(), backendPath: folder })
104
- const found = await findDshInFolder(folder)
105
- if (found) {
106
- dialog.showMessageBox({
107
- type: 'info',
108
- title: '后端安装文件夹已设置',
109
- message: `已找到 dsh:\n${found}`,
110
- })
111
- } else {
112
- dialog.showMessageBox({
113
- type: 'warning',
114
- title: '未在此文件夹找到 dsh',
115
- message:
116
- '此文件夹内未找到 dsh 可执行文件。已保存该路径,但启动后端时可能失败——\n' +
117
- '请确认选择的是包含 dsh(dsh.cmd / bin/dsh.cmd / node_modules/.bin/dsh.cmd)的文件夹。',
118
- })
119
- }
120
- }
121
- refreshTrayMenu()
122
- },
112
+ click: () => chooseBackendFolder(),
123
113
  },
124
114
  { type: 'separator' },
125
115
  {
126
116
  label: '检查更新…',
127
117
  click: async () => {
118
+ // Windows (packaged): auto-download + install on restart.
119
+ // macOS / dev mode: manual page link (macOS needs a signature).
120
+ if (isAutoUpdateSupported()) {
121
+ await checkForUpdatesAuto()
122
+ return
123
+ }
128
124
  const r = await checkForUpdate()
129
125
  if (r.hasUpdate) {
130
126
  const choice = dialog.showMessageBoxSync({
@@ -181,3 +177,62 @@ function statusLabel(st) {
181
177
  return '未运行'
182
178
  }
183
179
  }
180
+
181
+ /**
182
+ * Start the backend with a progress window. Shared by the tray menu and
183
+ * the offline screen buttons. Returns true on success.
184
+ */
185
+ export async function startBackendWithProgress() {
186
+ showProgress({ title: '启动后端', message: '正在启动 dsh 后端…' })
187
+ try {
188
+ await start({ backendPath: loadConfig().backendPath })
189
+ setProgress({ title: '启动后端', message: '后端已启动', state: 'ok' })
190
+ setTimeout(closeProgress, 1200)
191
+ return true
192
+ } catch (err) {
193
+ closeProgress()
194
+ dialog.showErrorBox('后端启动失败', err.message)
195
+ return false
196
+ } finally {
197
+ refreshTrayMenu()
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Pick the dsh install folder via a native dialog (auto-detect default).
203
+ * Shared by the tray menu and the offline screen. Saves the choice to
204
+ * config and validates that a dsh executable exists inside.
205
+ */
206
+ export async function chooseBackendFolder() {
207
+ const config = loadConfig()
208
+ const detected = await detectInstallFolder()
209
+ const defaultFolder = detected || config.backendPath || undefined
210
+ const result = await dialog.showOpenDialog({
211
+ title: '选择dsh后端安装文件夹,默认安装用自动探测',
212
+ buttonLabel: '选择此文件夹',
213
+ message: '默认安装用自动探测。可手动指定 dsh 后端安装文件夹(包含 dsh 可执行文件)。',
214
+ properties: ['openDirectory'],
215
+ defaultPath: defaultFolder,
216
+ })
217
+ if (!result.canceled && result.filePaths[0]) {
218
+ const folder = result.filePaths[0]
219
+ saveConfig({ ...loadConfig(), backendPath: folder })
220
+ const found = await findDshInFolder(folder)
221
+ if (found) {
222
+ dialog.showMessageBox({
223
+ type: 'info',
224
+ title: '后端安装文件夹已设置',
225
+ message: `已找到 dsh:\n${found}`,
226
+ })
227
+ } else {
228
+ dialog.showMessageBox({
229
+ type: 'warning',
230
+ title: '未在此文件夹找到 dsh',
231
+ message:
232
+ '此文件夹内未找到 dsh 可执行文件。已保存该路径,但启动后端时可能失败——\n' +
233
+ '请确认选择的是包含 dsh(dsh.cmd / bin/dsh.cmd / node_modules/.bin/dsh.cmd)的文件夹。',
234
+ })
235
+ }
236
+ }
237
+ refreshTrayMenu()
238
+ }