dsh-clean-desktop-shell 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +32 -0
- package/README.md +20 -0
- package/electron/config.js +40 -10
- package/electron/crashGuard.js +60 -0
- package/electron/main.js +15 -7
- package/electron/service.js +91 -48
- package/electron/shortcut.js +47 -55
- package/electron/update.js +237 -34
- package/electron/window.js +7 -5
- package/lib/common.js +21 -0
- package/lib/icon.js +1 -13
- package/lib/runtime.js +82 -33
- package/package.json +4 -3
- package/scripts/check-syntax.mjs +54 -0
- package/src/host/common.js +21 -0
- package/src/host/icon.js +1 -13
- package/src/host/runtime.js +82 -33
- package/version.txt +1 -1
package/electron/update.js
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Update handling —
|
|
2
|
+
* Update handling — three install forms, three update sources.
|
|
3
3
|
*
|
|
4
4
|
* Windows (packaged app): uses electron-updater to download the new
|
|
5
5
|
* installer from GitHub Releases in the background and install it on
|
|
6
6
|
* restart. Progress is shown in the small progress window.
|
|
7
7
|
*
|
|
8
|
-
* macOS
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* macOS (packaged app): manual check that opens the GitHub Releases page
|
|
9
|
+
* (macOS auto-update needs a Developer ID signature which this project
|
|
10
|
+
* does not have yet).
|
|
11
|
+
*
|
|
12
|
+
* Plugin mode (npm-installed, !app.isPackaged): checks the npm registry for
|
|
13
|
+
* dist-tags.latest and offers a one-click update executed through the
|
|
14
|
+
* package manager that actually installed the plugin (inferred from the
|
|
15
|
+
* lockfile next to the install, vercel-style), with command variants to
|
|
16
|
+
* absorb environment and pnpm-version differences.
|
|
11
17
|
*/
|
|
12
18
|
import { app, shell, dialog } from 'electron'
|
|
13
|
-
import { readFileSync } from 'node:fs'
|
|
14
|
-
import {
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { spawn } from 'node:child_process'
|
|
21
|
+
import { basename, dirname, join } from 'node:path'
|
|
15
22
|
import { fileURLToPath } from 'node:url'
|
|
23
|
+
import semver from 'semver'
|
|
16
24
|
import { showProgress, setProgress, closeProgress } from './progress.js'
|
|
17
25
|
import { loadConfig } from './config.js'
|
|
18
26
|
|
|
@@ -25,13 +33,16 @@ const NPM_REGISTRY_API = 'https://registry.npmjs.org/dsh-clean-desktop-shell'
|
|
|
25
33
|
// the Electron runtime version (e.g. 33.4.11). Read the plugin's own version
|
|
26
34
|
// from its package.json instead.
|
|
27
35
|
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
28
|
-
const
|
|
36
|
+
const PKG_INFO = (() => {
|
|
29
37
|
try {
|
|
30
|
-
return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
|
|
38
|
+
return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
|
|
31
39
|
} catch {
|
|
32
40
|
return null
|
|
33
41
|
}
|
|
34
42
|
})()
|
|
43
|
+
const PKG_VERSION = PKG_INFO?.version ?? null
|
|
44
|
+
// Read from the plugin's own manifest — never hardcode the package name.
|
|
45
|
+
const PKG_NAME = PKG_INFO?.name ?? 'dsh-clean-desktop-shell'
|
|
35
46
|
|
|
36
47
|
let autoUpdater = null
|
|
37
48
|
let updaterPromise = null
|
|
@@ -132,18 +143,44 @@ export async function checkForUpdatesAuto() {
|
|
|
132
143
|
const isPlugin = !app.isPackaged
|
|
133
144
|
const r = isPlugin ? await checkForUpdateNpm() : await checkForUpdate()
|
|
134
145
|
if (r.hasUpdate) {
|
|
146
|
+
if (!isPlugin) {
|
|
147
|
+
const choice = dialog.showMessageBoxSync({
|
|
148
|
+
type: 'info',
|
|
149
|
+
title: '发现新版本',
|
|
150
|
+
message: `当前版本 ${r.current},最新版本 ${r.latest}。`,
|
|
151
|
+
detail: 'macOS 自动更新需要代码签名,当前请前往 GitHub Releases 手动下载。',
|
|
152
|
+
buttons: ['前往下载', '取消'],
|
|
153
|
+
defaultId: 0,
|
|
154
|
+
cancelId: 1,
|
|
155
|
+
})
|
|
156
|
+
if (choice === 0) openUrl(r.url)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Plugin mode (Plan B + A): offer a one-click update executed through the
|
|
161
|
+
// package manager that actually installed this package, and show the
|
|
162
|
+
// equivalent manual command plus the DSH market path.
|
|
163
|
+
const plan = detectPluginUpdatePlan()
|
|
164
|
+
const variants = plan ? updateCommandVariants(plan.pm) : []
|
|
165
|
+
const cmdLine = variants[0] || null
|
|
166
|
+
const marketHint = '① DSH 网页「设置 → 插件市场 → 已安装」→「更新」(完成后按提示重启)。'
|
|
167
|
+
const detail = cmdLine
|
|
168
|
+
? `${marketHint}\n② 或在目录 ${plan.profileRoot} 下执行:\n${cmdLine}\n(按 Ctrl+C 可复制本对话框全部文字)`
|
|
169
|
+
: marketHint
|
|
135
170
|
const choice = dialog.showMessageBoxSync({
|
|
136
171
|
type: 'info',
|
|
137
172
|
title: '发现新版本',
|
|
138
173
|
message: `当前版本 ${r.current},最新版本 ${r.latest}。`,
|
|
139
|
-
detail
|
|
140
|
-
|
|
141
|
-
: 'macOS 自动更新需要代码签名,当前请前往 GitHub Releases 手动下载。',
|
|
142
|
-
buttons: isPlugin ? ['打开 DSH 网页', '稍后'] : ['前往下载', '取消'],
|
|
174
|
+
detail,
|
|
175
|
+
buttons: cmdLine ? ['立即更新', '打开 DSH 网页', '稍后'] : ['打开 DSH 网页', '稍后'],
|
|
143
176
|
defaultId: 0,
|
|
144
|
-
cancelId: 1,
|
|
177
|
+
cancelId: cmdLine ? 2 : 1,
|
|
145
178
|
})
|
|
146
|
-
if (choice === 0)
|
|
179
|
+
if (cmdLine && choice === 0) {
|
|
180
|
+
await updatePluginViaPackageManager(plan, variants, r)
|
|
181
|
+
} else if (choice === (cmdLine ? 1 : 0)) {
|
|
182
|
+
openUrl(loadConfig().targetUrl)
|
|
183
|
+
}
|
|
147
184
|
} else if (r.latest) {
|
|
148
185
|
dialog.showMessageBoxSync({
|
|
149
186
|
type: 'info',
|
|
@@ -161,11 +198,16 @@ export async function checkForUpdatesAuto() {
|
|
|
161
198
|
}
|
|
162
199
|
}
|
|
163
200
|
|
|
164
|
-
/**
|
|
165
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Version parsing/comparison via semver — the industry-standard
|
|
203
|
+
* implementation (update-notifier, npm itself). Coercion strips the 'v'
|
|
204
|
+
* prefix and any prerelease/build suffix; gt compares correctly across
|
|
205
|
+
* digit-width differences that a hand-rolled tuple or string compare
|
|
206
|
+
* would get wrong.
|
|
207
|
+
*/
|
|
208
|
+
function coerceVersion(v) {
|
|
166
209
|
if (!v) return null
|
|
167
|
-
|
|
168
|
-
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
|
|
210
|
+
return semver.coerce(String(v))
|
|
169
211
|
}
|
|
170
212
|
|
|
171
213
|
/**
|
|
@@ -179,11 +221,7 @@ function currentVersion() {
|
|
|
179
221
|
|
|
180
222
|
/** True when a is strictly newer than b. */
|
|
181
223
|
function isNewer(a, b) {
|
|
182
|
-
|
|
183
|
-
for (let i = 0; i < 3; i++) {
|
|
184
|
-
if (a[i] !== b[i]) return a[i] > b[i]
|
|
185
|
-
}
|
|
186
|
-
return false
|
|
224
|
+
return !!(a && b && semver.gt(a, b))
|
|
187
225
|
}
|
|
188
226
|
|
|
189
227
|
/**
|
|
@@ -197,17 +235,14 @@ export async function checkForUpdate() {
|
|
|
197
235
|
let url = REPO_URL
|
|
198
236
|
|
|
199
237
|
try {
|
|
200
|
-
const controller = new AbortController()
|
|
201
|
-
const timer = setTimeout(() => controller.abort(), 8000)
|
|
202
238
|
const res = await fetch(RELEASES_API, {
|
|
203
|
-
signal:
|
|
239
|
+
signal: AbortSignal.timeout(8000),
|
|
204
240
|
headers: { Accept: 'application/vnd.github+json' },
|
|
205
241
|
})
|
|
206
|
-
clearTimeout(timer)
|
|
207
242
|
if (res.ok) {
|
|
208
243
|
const data = await res.json()
|
|
209
244
|
tag = data.tag_name || null
|
|
210
|
-
latest =
|
|
245
|
+
latest = coerceVersion(tag)
|
|
211
246
|
if (data.html_url) url = data.html_url
|
|
212
247
|
}
|
|
213
248
|
} catch {
|
|
@@ -245,10 +280,7 @@ export async function checkForUpdateNpm() {
|
|
|
245
280
|
const url = 'https://www.npmjs.com/package/dsh-clean-desktop-shell'
|
|
246
281
|
|
|
247
282
|
try {
|
|
248
|
-
const
|
|
249
|
-
const timer = setTimeout(() => controller.abort(), 8000)
|
|
250
|
-
const res = await fetch(NPM_REGISTRY_API, { signal: controller.signal })
|
|
251
|
-
clearTimeout(timer)
|
|
283
|
+
const res = await fetch(NPM_REGISTRY_API, { signal: AbortSignal.timeout(8000) })
|
|
252
284
|
if (res.ok) {
|
|
253
285
|
const data = await res.json()
|
|
254
286
|
latestStr = data['dist-tags']?.latest || null
|
|
@@ -257,8 +289,8 @@ export async function checkForUpdateNpm() {
|
|
|
257
289
|
// Network error — no update known.
|
|
258
290
|
}
|
|
259
291
|
|
|
260
|
-
const currentV =
|
|
261
|
-
const latestV =
|
|
292
|
+
const currentV = coerceVersion(current)
|
|
293
|
+
const latestV = coerceVersion(latestStr)
|
|
262
294
|
return {
|
|
263
295
|
hasUpdate: isNewer(latestV, currentV),
|
|
264
296
|
latest: latestStr ? `v${latestStr}` : null,
|
|
@@ -266,3 +298,174 @@ export async function checkForUpdateNpm() {
|
|
|
266
298
|
url,
|
|
267
299
|
}
|
|
268
300
|
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Locate the directory whose package.json declares this plugin as a dependency
|
|
304
|
+
* (the DSH profile root: <root>/node_modules/<pkg>), and infer the package
|
|
305
|
+
* manager that manages it from the lockfile found there (vercel-style).
|
|
306
|
+
* @returns {{ pm: 'pnpm'|'npm'|'yarn'|'bun', profileRoot: string } | null}
|
|
307
|
+
*/
|
|
308
|
+
function detectPluginUpdatePlan() {
|
|
309
|
+
try {
|
|
310
|
+
const nm = dirname(PKG_ROOT)
|
|
311
|
+
if (basename(nm) !== 'node_modules') return null
|
|
312
|
+
const profileRoot = dirname(nm)
|
|
313
|
+
if (!existsSync(join(profileRoot, 'package.json'))) return null
|
|
314
|
+
const pm = existsSync(join(profileRoot, 'pnpm-lock.yaml')) ? 'pnpm'
|
|
315
|
+
: existsSync(join(profileRoot, 'package-lock.json')) ? 'npm'
|
|
316
|
+
: existsSync(join(profileRoot, 'yarn.lock')) ? 'yarn'
|
|
317
|
+
: (existsSync(join(profileRoot, 'bun.lockb')) || existsSync(join(profileRoot, 'bun.lock'))) ? 'bun'
|
|
318
|
+
: null
|
|
319
|
+
return pm ? { pm, profileRoot } : null
|
|
320
|
+
} catch {
|
|
321
|
+
return null
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Command candidates for the detected package manager, ordered from the most
|
|
327
|
+
* explicit to the most tolerant. Environment differences (pnpm only reachable
|
|
328
|
+
* via corepack) and pnpm version/flag differences are absorbed by trying them
|
|
329
|
+
* in order — the first variant that exits 0 wins. Quoting via JSON.stringify
|
|
330
|
+
* keeps paths/args safe in cmd.exe, PowerShell and POSIX shells alike.
|
|
331
|
+
*/
|
|
332
|
+
function updateCommandVariants(pm) {
|
|
333
|
+
const q = JSON.stringify(PKG_NAME)
|
|
334
|
+
switch (pm) {
|
|
335
|
+
case 'pnpm':
|
|
336
|
+
return [
|
|
337
|
+
`pnpm update ${q} --latest --config.minimumReleaseAge=0`,
|
|
338
|
+
`pnpm update ${q} --latest`,
|
|
339
|
+
`pnpm update ${q}`,
|
|
340
|
+
`corepack pnpm update ${q} --latest`,
|
|
341
|
+
]
|
|
342
|
+
case 'npm':
|
|
343
|
+
return [`npm install ${q}@latest`]
|
|
344
|
+
case 'yarn':
|
|
345
|
+
return [`yarn up ${q}@latest`, `yarn upgrade ${q} --latest`]
|
|
346
|
+
case 'bun':
|
|
347
|
+
return [`bun update ${q}`, `bun add ${q}@latest`]
|
|
348
|
+
default:
|
|
349
|
+
return []
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Kill a shell-spawned child and its whole subtree. On Windows the direct
|
|
355
|
+
* child is a cmd.exe wrapper — killing it alone leaves the actual worker
|
|
356
|
+
* (e.g. pnpm/node) running; `taskkill /T` is the recognized tree kill.
|
|
357
|
+
*/
|
|
358
|
+
function treeKill(child) {
|
|
359
|
+
if (process.platform === 'win32' && child.pid) {
|
|
360
|
+
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
|
|
361
|
+
windowsHide: true,
|
|
362
|
+
stdio: 'ignore',
|
|
363
|
+
})
|
|
364
|
+
} else {
|
|
365
|
+
try {
|
|
366
|
+
child.kill('SIGKILL')
|
|
367
|
+
} catch {
|
|
368
|
+
// already gone
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Run a shell command, capturing tail output. Node's spawn with shell:true
|
|
375
|
+
* always uses cmd.exe on Windows (regardless of the user's login shell being
|
|
376
|
+
* PowerShell or cmd), which is also required for .cmd shims like pnpm — a
|
|
377
|
+
* bare spawn would throw EINVAL. Output is kept as a bounded tail so a
|
|
378
|
+
* chatty build cannot grow memory without limit. Resolves { ok, out, err }.
|
|
379
|
+
*/
|
|
380
|
+
function runShell(cmd, cwd, timeoutMs = 5 * 60 * 1000) {
|
|
381
|
+
const CAP = 16 * 1024
|
|
382
|
+
const cap = (s) => (s.length > CAP ? s.slice(-CAP) : s)
|
|
383
|
+
return new Promise((resolve) => {
|
|
384
|
+
let out = ''
|
|
385
|
+
let err = ''
|
|
386
|
+
let settled = false
|
|
387
|
+
const child = spawn(cmd, {
|
|
388
|
+
cwd,
|
|
389
|
+
shell: true,
|
|
390
|
+
windowsHide: true, // no console flash from the GUI process
|
|
391
|
+
})
|
|
392
|
+
const timer = setTimeout(() => {
|
|
393
|
+
if (!settled) treeKill(child)
|
|
394
|
+
}, timeoutMs)
|
|
395
|
+
child.stdout?.on('data', (d) => { out = cap(out + d) })
|
|
396
|
+
child.stderr?.on('data', (d) => { err = cap(err + d) })
|
|
397
|
+
child.on('error', () => {
|
|
398
|
+
settled = true
|
|
399
|
+
clearTimeout(timer)
|
|
400
|
+
resolve({ ok: false, out: tail(out), err: tail(err) })
|
|
401
|
+
})
|
|
402
|
+
child.on('close', (code) => {
|
|
403
|
+
if (settled) return
|
|
404
|
+
settled = true
|
|
405
|
+
clearTimeout(timer)
|
|
406
|
+
resolve({ ok: code === 0, out: tail(out), err: tail(err) })
|
|
407
|
+
})
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function tail(s, n = 4000) {
|
|
412
|
+
return s.length > n ? s.slice(-n) : s
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Read the currently installed plugin version from disk (post-update check). */
|
|
416
|
+
function installedPluginVersion() {
|
|
417
|
+
try {
|
|
418
|
+
return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version || null
|
|
419
|
+
} catch {
|
|
420
|
+
return null
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* One-click plugin update: try each command variant until one exits 0, then
|
|
426
|
+
* verify the on-disk version actually changed. Three outcomes — success,
|
|
427
|
+
* "ran but version unchanged" (constraint/cooldown), or failure with the
|
|
428
|
+
* attempted commands and output tail for manual retry.
|
|
429
|
+
*/
|
|
430
|
+
async function updatePluginViaPackageManager(plan, variants, check) {
|
|
431
|
+
showProgress({ title: '插件更新', message: `正在通过 ${plan.pm} 更新插件…` })
|
|
432
|
+
let result = null
|
|
433
|
+
for (const cmd of variants) {
|
|
434
|
+
setProgress({ title: '插件更新', message: `正在执行:${cmd}`, state: 'busy' })
|
|
435
|
+
result = await runShell(cmd, plan.profileRoot)
|
|
436
|
+
if (result.ok) break
|
|
437
|
+
}
|
|
438
|
+
closeProgress()
|
|
439
|
+
|
|
440
|
+
const newVer = installedPluginVersion()
|
|
441
|
+
if (result?.ok && newVer && newVer !== check.current) {
|
|
442
|
+
dialog.showMessageBoxSync({
|
|
443
|
+
type: 'info',
|
|
444
|
+
title: '更新完成',
|
|
445
|
+
message: `插件已更新到 ${newVer}。`,
|
|
446
|
+
detail: '重启 dsh web 后生效(可从本应用托盘菜单重启后端)。',
|
|
447
|
+
})
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
if (result?.ok) {
|
|
451
|
+
dialog.showMessageBoxSync({
|
|
452
|
+
type: 'warning',
|
|
453
|
+
title: '版本未变化',
|
|
454
|
+
message: `命令执行成功,但插件版本仍为 ${check.current}。`,
|
|
455
|
+
detail: '可能被 package.json 的版本约束或新版本冷却策略限制,可稍后重试,或到 DSH 插件市场更新。',
|
|
456
|
+
})
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
const logTail = [result?.out, result?.err].filter(Boolean).join('\n').trim()
|
|
460
|
+
dialog.showMessageBoxSync({
|
|
461
|
+
type: 'warning',
|
|
462
|
+
title: '自动更新失败',
|
|
463
|
+
message: '无法自动更新插件,请手动执行或到 DSH 插件市场更新。',
|
|
464
|
+
detail: [
|
|
465
|
+
`已依次尝试:\n${variants.join('\n')}`,
|
|
466
|
+
logTail ? `命令输出(末尾):\n${logTail}` : '',
|
|
467
|
+
`手动命令(在目录 ${plan.profileRoot} 下):\n${variants[0]}`,
|
|
468
|
+
'按 Ctrl+C 可复制本对话框全部文字。',
|
|
469
|
+
].filter(Boolean).join('\n\n'),
|
|
470
|
+
})
|
|
471
|
+
}
|
package/electron/window.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { app, BrowserWindow, ipcMain } from 'electron'
|
|
19
19
|
import { existsSync } from 'node:fs'
|
|
20
20
|
import { dirname, join } from 'node:path'
|
|
21
|
-
import { fileURLToPath } from 'node:url'
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
22
22
|
import { probe, onStatusChange, detect } from './service.js'
|
|
23
23
|
import { startBackendWithProgress, chooseBackendFolder } from './tray.js'
|
|
24
24
|
import { APP_USER_MODEL_ID } from './aumid.js'
|
|
@@ -35,8 +35,11 @@ const TASKBAR_ICO = join(PKG_ROOT, 'build', 'icon.ico')
|
|
|
35
35
|
const ICON_PATH = existsSync(TASKBAR_ICO)
|
|
36
36
|
? TASKBAR_ICO
|
|
37
37
|
: fileURLToPath(new URL('../build/icon.png', import.meta.url))
|
|
38
|
-
// Local fallback page shown while the backend is down.
|
|
38
|
+
// Local fallback page shown while the backend is down. The file URL is
|
|
39
|
+
// precomputed so "is the offline page showing?" is an exact comparison,
|
|
40
|
+
// not a substring sniff over arbitrary web content.
|
|
39
41
|
const ERROR_PAGE = fileURLToPath(new URL('./error.html', import.meta.url))
|
|
42
|
+
const ERROR_PAGE_URL = pathToFileURL(ERROR_PAGE).href
|
|
40
43
|
|
|
41
44
|
// How often we re-probe the backend while the window is in "offline" mode.
|
|
42
45
|
const RECONNECT_INTERVAL_MS = 2500
|
|
@@ -214,8 +217,7 @@ export function createMainWindow({ target }) {
|
|
|
214
217
|
// Instant flip when the backend state machine changes (tray stop/start).
|
|
215
218
|
const unsub = onStatusChange((st) => {
|
|
216
219
|
if (win.isDestroyed()) return
|
|
217
|
-
const
|
|
218
|
-
const isOffline = current.includes('error.html')
|
|
220
|
+
const isOffline = win.webContents.getURL().startsWith(ERROR_PAGE_URL)
|
|
219
221
|
if ((st.status === 'stopped' || st.status === 'error') && !isOffline) {
|
|
220
222
|
// Backend went down while a real page is showing — go dark at once.
|
|
221
223
|
showOffline(win)
|
|
@@ -230,7 +232,7 @@ export function createMainWindow({ target }) {
|
|
|
230
232
|
if (!isMainFrame || code === ERR_ABORTED) return
|
|
231
233
|
// Offline screen already showing — just keep re-probing, do not
|
|
232
234
|
// reload the offline page again (avoids a reload loop if it fails).
|
|
233
|
-
if (win.webContents.getURL().
|
|
235
|
+
if (win.webContents.getURL().startsWith(ERROR_PAGE_URL)) {
|
|
234
236
|
startReconnect(win, target)
|
|
235
237
|
return
|
|
236
238
|
}
|
package/lib/common.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* location electron/ and build/ live in the published package.
|
|
7
7
|
*/
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
9
10
|
import { dirname, join } from 'node:path'
|
|
10
11
|
import { fileURLToPath } from 'node:url'
|
|
11
12
|
|
|
@@ -51,3 +52,23 @@ export function runtimeRoot() {
|
|
|
51
52
|
export function launchLogPath() {
|
|
52
53
|
return join(dshHome(), 'desktop-shell-launch.log')
|
|
53
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Download a file to disk via curl — shared by runtime provisioning and
|
|
58
|
+
* icon patching. Chosen over native fetch because undici (Node's fetch)
|
|
59
|
+
* ignores HTTP(S)_PROXY env vars unless a proxy dispatcher is wired in,
|
|
60
|
+
* while curl honors them out of the box (users behind Clash/v2ray rely on
|
|
61
|
+
* that). --max-time keeps a stalled proxy from hanging forever; --retry
|
|
62
|
+
* rides out transient failures.
|
|
63
|
+
*/
|
|
64
|
+
export function fetchFile(url, dest, timeoutSec = 600) {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
const child = spawn(
|
|
67
|
+
'curl',
|
|
68
|
+
['-L', '--fail', '--silent', '--show-error', '--retry', '2', '--max-time', String(timeoutSec), '-o', dest, url],
|
|
69
|
+
{ windowsHide: true, stdio: 'ignore' },
|
|
70
|
+
)
|
|
71
|
+
child.on('error', () => resolve(false))
|
|
72
|
+
child.on('exit', (code) => resolve(code === 0))
|
|
73
|
+
})
|
|
74
|
+
}
|
package/lib/icon.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { spawn } from 'node:child_process'
|
|
15
15
|
import { existsSync, writeFileSync } from 'node:fs'
|
|
16
16
|
import { join } from 'node:path'
|
|
17
|
-
import { PKG_ROOT, isWin, runtimeRoot } from './common.js'
|
|
17
|
+
import { PKG_ROOT, isWin, runtimeRoot, fetchFile } from './common.js'
|
|
18
18
|
|
|
19
19
|
export async function patchExeIcon(ctx, exe) {
|
|
20
20
|
if (!isWin) return
|
|
@@ -49,15 +49,3 @@ export async function patchExeIcon(ctx, exe) {
|
|
|
49
49
|
ctx.logger.warn('[clean-desktop-shell] rcedit patch failed — taskbar icon stays default')
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
-
|
|
53
|
-
function fetchFile(url, dest) {
|
|
54
|
-
return new Promise((resolve) => {
|
|
55
|
-
const child = spawn(
|
|
56
|
-
'curl',
|
|
57
|
-
['-L', '--fail', '--silent', '--show-error', '--retry', '2', '--max-time', '600', '-o', dest, url],
|
|
58
|
-
{ windowsHide: true, stdio: 'ignore' },
|
|
59
|
-
)
|
|
60
|
-
child.on('error', () => resolve(false))
|
|
61
|
-
child.on('exit', (code) => resolve(code === 0))
|
|
62
|
-
})
|
|
63
|
-
}
|
package/lib/runtime.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* 4. drop stale electron-v* dirs (no unbounded disk growth)
|
|
14
14
|
*/
|
|
15
15
|
import { spawn } from 'node:child_process'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { createReadStream } from 'node:fs'
|
|
16
18
|
import {
|
|
17
19
|
cpSync,
|
|
18
20
|
existsSync,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
rmSync,
|
|
24
26
|
symlinkSync,
|
|
25
27
|
} from 'node:fs'
|
|
28
|
+
import { pipeline } from 'node:stream/promises'
|
|
26
29
|
import { basename, join } from 'node:path'
|
|
27
30
|
import {
|
|
28
31
|
PKG_ROOT,
|
|
@@ -33,6 +36,7 @@ import {
|
|
|
33
36
|
isWin,
|
|
34
37
|
isMac,
|
|
35
38
|
runtimeRoot,
|
|
39
|
+
fetchFile,
|
|
36
40
|
} from './common.js'
|
|
37
41
|
|
|
38
42
|
function electronVersion() {
|
|
@@ -124,17 +128,26 @@ function ensureExecutable(ctx, target) {
|
|
|
124
128
|
async function downloadRuntime(ctx, version, root, dir) {
|
|
125
129
|
const tmpZip = join(root, `.electron-${version}.zip.tmp`)
|
|
126
130
|
rmSync(tmpZip, { force: true })
|
|
127
|
-
const
|
|
128
|
-
const
|
|
131
|
+
const candidates = await runtimeCandidates(version)
|
|
132
|
+
const zip = zipName(version)
|
|
133
|
+
const innerName = zip.replace(/\.zip$/, '')
|
|
129
134
|
|
|
130
|
-
for (const
|
|
131
|
-
ctx.logger.info(`[clean-desktop-shell] downloading electron ${version} from ${url}`)
|
|
132
|
-
if (!(await fetchFile(url, tmpZip))) {
|
|
135
|
+
for (const c of candidates) {
|
|
136
|
+
ctx.logger.info(`[clean-desktop-shell] downloading electron ${version} from ${c.url}`)
|
|
137
|
+
if (!(await fetchFile(c.url, tmpZip))) {
|
|
133
138
|
// Failed download — drop the partial file so a later run starts clean.
|
|
134
139
|
rmSync(tmpZip, { force: true })
|
|
135
140
|
continue
|
|
136
141
|
}
|
|
137
142
|
|
|
143
|
+
// Integrity check against the source's own SHASUMS256.txt (same check
|
|
144
|
+
// the `electron` package's installer performs). A mismatch means the
|
|
145
|
+
// bytes are corrupt or tampered — the source is skipped entirely.
|
|
146
|
+
if (!(await verifySha256(ctx, tmpZip, c.shasums, zip))) {
|
|
147
|
+
rmSync(tmpZip, { force: true })
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
|
|
138
151
|
// Extract into a scratch dir, never into the shared runtime root: a
|
|
139
152
|
// half-extracted zip there would be indistinguishable from a real
|
|
140
153
|
// runtime, and the sibling electron-v* dirs must not be disturbed.
|
|
@@ -142,7 +155,7 @@ async function downloadRuntime(ctx, version, root, dir) {
|
|
|
142
155
|
const ok = await extractZip(ctx, tmpZip, scratch)
|
|
143
156
|
rmSync(tmpZip, { force: true })
|
|
144
157
|
if (!ok) {
|
|
145
|
-
ctx.logger.warn(`[clean-desktop-shell] no extractor succeeded for ${url}`)
|
|
158
|
+
ctx.logger.warn(`[clean-desktop-shell] no extractor succeeded for ${c.url}`)
|
|
146
159
|
rmSync(scratch, { recursive: true, force: true })
|
|
147
160
|
continue
|
|
148
161
|
}
|
|
@@ -169,6 +182,54 @@ async function downloadRuntime(ctx, version, root, dir) {
|
|
|
169
182
|
throw new Error('electron download failed from all sources')
|
|
170
183
|
}
|
|
171
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Verify a downloaded zip against the source's SHASUMS256.txt — the same
|
|
187
|
+
* integrity check the `electron` package's own installer performs. Returns
|
|
188
|
+
* false on a checksum mismatch (fatal for that source); a temporarily
|
|
189
|
+
* unreachable SHASUMS file only warns, so a reachable zip is not wasted
|
|
190
|
+
* over an unrelated blocker.
|
|
191
|
+
*/
|
|
192
|
+
async function verifySha256(ctx, file, shasumsUrl, name) {
|
|
193
|
+
let expected = null
|
|
194
|
+
let reason = ''
|
|
195
|
+
try {
|
|
196
|
+
const res = await fetch(shasumsUrl, { signal: AbortSignal.timeout(10000) })
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
reason = `HTTP ${res.status}`
|
|
199
|
+
} else {
|
|
200
|
+
const text = await res.text()
|
|
201
|
+
for (const line of text.split('\n')) {
|
|
202
|
+
const m = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i)
|
|
203
|
+
if (m && m[2].trim() === name) {
|
|
204
|
+
expected = m[1].toLowerCase()
|
|
205
|
+
break
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (!expected) reason = 'entry not found'
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
reason = err?.message || 'fetch failed'
|
|
212
|
+
}
|
|
213
|
+
if (!expected) {
|
|
214
|
+
ctx.logger.warn(
|
|
215
|
+
`[clean-desktop-shell] SHASUMS256.txt unavailable from ${shasumsUrl} (${reason}) — skipping integrity check`,
|
|
216
|
+
)
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
// Stream the hash — the zip can be ~270 MB and must not be read whole
|
|
220
|
+
// into memory.
|
|
221
|
+
const hash = createHash('sha256')
|
|
222
|
+
await pipeline(createReadStream(file), hash)
|
|
223
|
+
const actual = hash.digest('hex')
|
|
224
|
+
if (actual !== expected) {
|
|
225
|
+
ctx.logger.warn(
|
|
226
|
+
`[clean-desktop-shell] checksum mismatch for ${name}: expected ${expected}, got ${actual}`,
|
|
227
|
+
)
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
|
|
172
233
|
/** The dir that directly holds the electron payload right after extraction. */
|
|
173
234
|
function findPayload(scratch, wrapper) {
|
|
174
235
|
if (existsSync(join(wrapper, EXE_TOP))) return wrapper
|
|
@@ -212,21 +273,25 @@ function clearQuarantine(ctx, target) {
|
|
|
212
273
|
* (direct connection, 3s each). The fastest reachable source goes first —
|
|
213
274
|
* this naturally prefers the domestic npmmirror mirror on CN networks,
|
|
214
275
|
* the official GitHub source on international/well-proxied networks, and
|
|
215
|
-
* never wastes a full download on a dead source.
|
|
276
|
+
* never wastes a full download on a dead source. Each candidate carries
|
|
277
|
+
* its own SHASUMS256.txt location for the post-download integrity check.
|
|
216
278
|
*/
|
|
217
|
-
async function
|
|
279
|
+
async function runtimeCandidates(version) {
|
|
280
|
+
const base = (name, urlBase) => ({
|
|
281
|
+
name,
|
|
282
|
+
url: `${urlBase}/${zipName(version)}`,
|
|
283
|
+
shasums: `${urlBase}/SHASUMS256.txt`,
|
|
284
|
+
})
|
|
218
285
|
const candidates = [
|
|
219
|
-
|
|
220
|
-
|
|
286
|
+
base('github', `https://github.com/electron/electron/releases/download/v${version}`),
|
|
287
|
+
base('npmmirror', `https://npmmirror.com/mirrors/electron/${version}`),
|
|
221
288
|
]
|
|
222
289
|
const results = await Promise.all(
|
|
223
290
|
candidates.map(async (c) => {
|
|
224
291
|
const t0 = Date.now()
|
|
225
292
|
try {
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
const res = await fetch(c.url, { signal: ctrl.signal, method: 'HEAD' })
|
|
229
|
-
clearTimeout(timer)
|
|
293
|
+
// AbortSignal.timeout — the standard self-cleaning probe timeout.
|
|
294
|
+
const res = await fetch(c.url, { signal: AbortSignal.timeout(3000), method: 'HEAD' })
|
|
230
295
|
if (res.status < 500) return { ...c, ms: Date.now() - t0 }
|
|
231
296
|
} catch {
|
|
232
297
|
// unreachable — drop
|
|
@@ -237,26 +302,10 @@ async function runtimeUrls(version) {
|
|
|
237
302
|
const ok = results.filter(Boolean).sort((a, b) => a.ms - b.ms)
|
|
238
303
|
if (ok.length === 0) {
|
|
239
304
|
// Probes all failed (offline?) — still try both, mirror first (cheap).
|
|
240
|
-
return [candidates[1]
|
|
305
|
+
return [candidates[1], candidates[0]]
|
|
241
306
|
}
|
|
242
|
-
const rest = candidates.
|
|
243
|
-
return [ok[0]
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function fetchFile(url, dest) {
|
|
247
|
-
return new Promise((resolve) => {
|
|
248
|
-
// curl is available on Windows 10+; streams to disk, honors proxy env.
|
|
249
|
-
// --max-time keeps a stalled download from hanging forever (a proxy
|
|
250
|
-
// stall previously left a half-written .zip.tmp and blocked the shell
|
|
251
|
-
// launch); --retry 2 rides out transient failures.
|
|
252
|
-
const child = spawn(
|
|
253
|
-
'curl',
|
|
254
|
-
['-L', '--fail', '--silent', '--show-error', '--retry', '2', '--max-time', '600', '-o', dest, url],
|
|
255
|
-
{ windowsHide: true, stdio: 'ignore' },
|
|
256
|
-
)
|
|
257
|
-
child.on('error', () => resolve(false))
|
|
258
|
-
child.on('exit', (code) => resolve(code === 0))
|
|
259
|
-
})
|
|
307
|
+
const rest = candidates.filter((c) => c.url !== ok[0].url)
|
|
308
|
+
return [ok[0], ...rest]
|
|
260
309
|
}
|
|
261
310
|
|
|
262
311
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-clean-desktop-shell",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Clean desktop shell for DeepSeek Harness (DSH) as a DSH plugin — wraps your web profile in a native window, tray-managed backend, offline auto-reconnect, zero visual changes. DSH 插件形态的纯净桌面壳:复用现有 web profile,托盘管理后端,零视觉改造。",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"build": "node scripts/build.mjs",
|
|
36
|
-
"check": "node
|
|
36
|
+
"check": "node scripts/check-syntax.mjs && node scripts/selftest-runtime.mjs",
|
|
37
37
|
"dev": "electron electron/main.js",
|
|
38
38
|
"icons": "node scripts/gen-icons.mjs",
|
|
39
39
|
"pack": "electron-builder --win nsis",
|
|
@@ -118,7 +118,8 @@
|
|
|
118
118
|
"electron@33.4.11": true
|
|
119
119
|
},
|
|
120
120
|
"dependencies": {
|
|
121
|
-
"electron-updater": "^6.8.9"
|
|
121
|
+
"electron-updater": "^6.8.9",
|
|
122
|
+
"semver": "^7.7.2"
|
|
122
123
|
},
|
|
123
124
|
"desktopShell": {
|
|
124
125
|
"electronVersion": "33.4.11"
|