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.
@@ -1,18 +1,26 @@
1
1
  /**
2
- * Update handling — Windows auto-update, macOS manual download.
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 / dev mode: falls back to a manual check that opens the GitHub
9
- * Releases page (macOS auto-update needs a Developer ID signature which
10
- * this project does not have yet).
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 { dirname, join } from 'node:path'
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 PKG_VERSION = (() => {
36
+ const PKG_INFO = (() => {
29
37
  try {
30
- return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version
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: isPlugin
140
- ? '插件形态请到 DSH 网页的「设置 → 插件市场 → 已安装」里点「更新」,完成后按提示重启即可生效。'
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) openUrl(isPlugin ? loadConfig().targetUrl : r.url)
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
- /** Parse "v1.2.3" / "1.2.3" → [1,2,3]; null when malformed. */
165
- function parseVersion(v) {
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
- const m = String(v).replace(/^v/i, '').trim().match(/^(\d+)\.(\d+)\.(\d+)/)
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
- if (!a || !b) return false
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: controller.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 = parseVersion(tag)
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 controller = new AbortController()
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 = parseVersion(current)
261
- const latestV = parseVersion(latestStr)
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
+ }
@@ -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 current = win.webContents.getURL()
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().includes('error.html')) {
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 urls = await runtimeUrls(version)
128
- const innerName = zipName(version).replace(/\.zip$/, '')
131
+ const candidates = await runtimeCandidates(version)
132
+ const zip = zipName(version)
133
+ const innerName = zip.replace(/\.zip$/, '')
129
134
 
130
- for (const url of urls) {
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 runtimeUrls(version) {
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
- { name: 'github', url: `https://github.com/electron/electron/releases/download/v${version}/${zipName(version)}` },
220
- { name: 'npmmirror', url: `https://npmmirror.com/mirrors/electron/${version}/${zipName(version)}` },
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
- const ctrl = new AbortController()
227
- const timer = setTimeout(() => ctrl.abort(), 3000)
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].url, candidates[0].url]
305
+ return [candidates[1], candidates[0]]
241
306
  }
242
- const rest = candidates.map((c) => c.url).filter((u) => u !== ok[0].url)
243
- return [ok[0].url, ...rest]
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.8",
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 --check lib/index.js && node scripts/selftest-runtime.mjs",
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"