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.
- package/LICENSE +21 -21
- package/README.en.md +168 -91
- package/README.md +161 -90
- package/cordis.patch.yml +4 -4
- package/electron/config.js +46 -48
- package/electron/error.html +113 -0
- package/electron/main.js +20 -15
- package/electron/preload.js +14 -1
- package/electron/progress-preload.js +11 -0
- package/electron/progress.html +79 -0
- package/electron/progress.js +56 -0
- package/electron/service.js +181 -47
- package/electron/tray.js +103 -48
- package/electron/update.js +201 -74
- package/electron/window.js +179 -5
- package/lib/client.js +10 -10
- package/lib/index.js +13 -13
- package/package.json +11 -3
- package/scripts/build.mjs +12 -12
- package/scripts/gen-icons.mjs +70 -70
- package/src/client/client.js +10 -10
- package/src/host/index.js +13 -13
- package/version.txt +1 -1
package/electron/service.js
CHANGED
|
@@ -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
|
-
|
|
79
|
+
setStatus('running')
|
|
57
80
|
return url
|
|
58
81
|
}
|
|
59
|
-
|
|
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
|
-
|
|
76
|
-
currentStatus = 'error'
|
|
98
|
+
setStatus('error', '未找到 dsh 后端。请在托盘「设置后端文件夹」中指定 dsh CLI 所在目录。')
|
|
77
99
|
throw new Error(lastError)
|
|
78
100
|
}
|
|
79
101
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
currentStatus = 'error'
|
|
126
|
+
setStatus('error', err.message)
|
|
91
127
|
if (startResolver) {
|
|
92
|
-
startResolver
|
|
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
|
-
|
|
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
|
-
|
|
142
|
+
setStatus('stopped')
|
|
106
143
|
}
|
|
107
144
|
})
|
|
108
145
|
|
|
109
|
-
// Wait for
|
|
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
|
-
|
|
151
|
+
const timer = setTimeout(async () => {
|
|
152
|
+
if (!startResolver) return
|
|
153
|
+
const r = startResolver
|
|
116
154
|
startResolver = null
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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 {
|
|
12
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|