dsh-clean-desktop-shell 0.1.9 → 0.1.11

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/electron/tray.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  onStatusChange,
20
20
  } from './service.js'
21
21
  import { showProgress, setProgress, closeProgress } from './progress.js'
22
- import { checkForUpdate, checkForUpdatesAuto, isAutoUpdateSupported, openRepo, openUrl } from './update.js'
22
+ import { checkForUpdatesAuto, openRepo } from './update.js'
23
23
  import { shortcutSupported, createDesktopShortcut } from './shortcut.js'
24
24
 
25
25
  const trayIconPath = join(
@@ -142,37 +142,11 @@ export function refreshTrayMenu() {
142
142
  {
143
143
  label: '检查更新…',
144
144
  click: async () => {
145
- // Windows (packaged): auto-download + install on restart.
146
- // macOS / dev mode: manual page link (macOS needs a signature).
147
- if (isAutoUpdateSupported()) {
148
- await checkForUpdatesAuto()
149
- return
150
- }
151
- const r = await checkForUpdate()
152
- if (r.hasUpdate) {
153
- const choice = dialog.showMessageBoxSync({
154
- type: 'info',
155
- title: '发现新版本',
156
- message: `当前版本 ${r.current},最新版本 ${r.latest}。`,
157
- detail: '是否前往 GitHub Releases 页面下载?',
158
- buttons: ['前往下载', '取消'],
159
- defaultId: 0,
160
- cancelId: 1,
161
- })
162
- if (choice === 0) openUrl(r.url)
163
- } else if (r.latest) {
164
- dialog.showMessageBoxSync({
165
- type: 'info',
166
- title: '已是最新版本',
167
- message: `当前版本 ${r.current} 已是最新(${r.latest})。`,
168
- })
169
- } else {
170
- dialog.showMessageBoxSync({
171
- type: 'warning',
172
- title: '检查更新失败',
173
- message: '无法连接 GitHub 检查更新,请检查网络后重试。',
174
- })
175
- }
145
+ // checkForUpdatesAuto() handles all three modes internally:
146
+ // Windows packaged → electron-updater auto-download
147
+ // Plugin mode → npm registry check + one-click update
148
+ // macOS packaged → GitHub Releases manual link
149
+ await checkForUpdatesAuto()
176
150
  },
177
151
  },
178
152
  {
@@ -20,6 +20,7 @@ import { existsSync, readFileSync } from 'node:fs'
20
20
  import { spawn } from 'node:child_process'
21
21
  import { basename, dirname, join } from 'node:path'
22
22
  import { fileURLToPath } from 'node:url'
23
+ import semver from 'semver'
23
24
  import { showProgress, setProgress, closeProgress } from './progress.js'
24
25
  import { loadConfig } from './config.js'
25
26
 
@@ -197,11 +198,16 @@ export async function checkForUpdatesAuto() {
197
198
  }
198
199
  }
199
200
 
200
- /** Parse "v1.2.3" / "1.2.3" → [1,2,3]; null when malformed. */
201
- 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) {
202
209
  if (!v) return null
203
- const m = String(v).replace(/^v/i, '').trim().match(/^(\d+)\.(\d+)\.(\d+)/)
204
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
210
+ return semver.coerce(String(v))
205
211
  }
206
212
 
207
213
  /**
@@ -215,11 +221,7 @@ function currentVersion() {
215
221
 
216
222
  /** True when a is strictly newer than b. */
217
223
  function isNewer(a, b) {
218
- if (!a || !b) return false
219
- for (let i = 0; i < 3; i++) {
220
- if (a[i] !== b[i]) return a[i] > b[i]
221
- }
222
- return false
224
+ return !!(a && b && semver.gt(a, b))
223
225
  }
224
226
 
225
227
  /**
@@ -233,24 +235,21 @@ export async function checkForUpdate() {
233
235
  let url = REPO_URL
234
236
 
235
237
  try {
236
- const controller = new AbortController()
237
- const timer = setTimeout(() => controller.abort(), 8000)
238
238
  const res = await fetch(RELEASES_API, {
239
- signal: controller.signal,
239
+ signal: AbortSignal.timeout(8000),
240
240
  headers: { Accept: 'application/vnd.github+json' },
241
241
  })
242
- clearTimeout(timer)
243
242
  if (res.ok) {
244
243
  const data = await res.json()
245
244
  tag = data.tag_name || null
246
- latest = parseVersion(tag)
245
+ latest = coerceVersion(tag)
247
246
  if (data.html_url) url = data.html_url
248
247
  }
249
248
  } catch {
250
249
  // Network error — no update known.
251
250
  }
252
251
 
253
- const currentV = parseVersion(current)
252
+ const currentV = coerceVersion(current)
254
253
  return {
255
254
  hasUpdate: isNewer(latest, currentV),
256
255
  latest: tag,
@@ -281,10 +280,7 @@ export async function checkForUpdateNpm() {
281
280
  const url = 'https://www.npmjs.com/package/dsh-clean-desktop-shell'
282
281
 
283
282
  try {
284
- const controller = new AbortController()
285
- const timer = setTimeout(() => controller.abort(), 8000)
286
- const res = await fetch(NPM_REGISTRY_API, { signal: controller.signal })
287
- clearTimeout(timer)
283
+ const res = await fetch(NPM_REGISTRY_API, { signal: AbortSignal.timeout(8000) })
288
284
  if (res.ok) {
289
285
  const data = await res.json()
290
286
  latestStr = data['dist-tags']?.latest || null
@@ -293,8 +289,8 @@ export async function checkForUpdateNpm() {
293
289
  // Network error — no update known.
294
290
  }
295
291
 
296
- const currentV = parseVersion(current)
297
- const latestV = parseVersion(latestStr)
292
+ const currentV = coerceVersion(current)
293
+ const latestV = coerceVersion(latestStr)
298
294
  return {
299
295
  hasUpdate: isNewer(latestV, currentV),
300
296
  latest: latestStr ? `v${latestStr}` : null,
@@ -354,13 +350,36 @@ function updateCommandVariants(pm) {
354
350
  }
355
351
  }
356
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
+
357
373
  /**
358
374
  * Run a shell command, capturing tail output. Node's spawn with shell:true
359
375
  * always uses cmd.exe on Windows (regardless of the user's login shell being
360
376
  * PowerShell or cmd), which is also required for .cmd shims like pnpm — a
361
- * bare spawn would throw EINVAL. Resolves { ok, out, err }.
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 }.
362
379
  */
363
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)
364
383
  return new Promise((resolve) => {
365
384
  let out = ''
366
385
  let err = ''
@@ -371,10 +390,10 @@ function runShell(cmd, cwd, timeoutMs = 5 * 60 * 1000) {
371
390
  windowsHide: true, // no console flash from the GUI process
372
391
  })
373
392
  const timer = setTimeout(() => {
374
- if (!settled) child.kill()
393
+ if (!settled) treeKill(child)
375
394
  }, timeoutMs)
376
- child.stdout?.on('data', (d) => { out += d })
377
- child.stderr?.on('data', (d) => { err += d })
395
+ child.stdout?.on('data', (d) => { out = cap(out + d) })
396
+ child.stderr?.on('data', (d) => { err = cap(err + d) })
378
397
  child.on('error', () => {
379
398
  settled = true
380
399
  clearTimeout(timer)
@@ -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.9",
3
+ "version": "0.1.11",
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"
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Syntax gate: `node --check` every JS file that ships in the package
3
+ * (electron/ main-process modules + lib/ host/client bundle). Previously
4
+ * only lib/index.js was checked — a syntax slip in any other file shipped
5
+ * unnoticed.
6
+ *
7
+ * The two sandboxed preloads are CommonJS (require/module.exports) while
8
+ * package.json declares "type": "module", so plain --check on them fails
9
+ * spuriously; they are validated through a temporary .cjs copy instead.
10
+ */
11
+ import { spawnSync } from 'node:child_process'
12
+ import { readdirSync, readFileSync, statSync, rmSync, writeFileSync, mkdtempSync } from 'node:fs'
13
+ import { dirname, join } from 'node:path'
14
+ import { tmpdir } from 'node:os'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ const root = dirname(dirname(fileURLToPath(import.meta.url)))
18
+ // CJS files relative to electron/ — parsed as CommonJS despite the ESM package.
19
+ const CJS_FILES = new Set(['preload.js', 'progress-preload.js'])
20
+
21
+ function listJs(dir) {
22
+ return readdirSync(join(root, dir)).filter((f) => f.endsWith('.js'))
23
+ }
24
+
25
+ const scratch = mkdtempSync(join(tmpdir(), 'dsh-shell-check-'))
26
+ let bad = 0
27
+ let checked = 0
28
+
29
+ try {
30
+ for (const dir of ['electron', 'lib']) {
31
+ for (const f of listJs(dir)) {
32
+ const full = join(root, dir, f)
33
+ if (!statSync(full).isFile()) continue
34
+ const cjs = CJS_FILES.has(f)
35
+ const target = cjs ? join(scratch, `${dir}-${f}.cjs`) : full
36
+ if (cjs) writeFileSync(target, readFileSync(full))
37
+ const r = spawnSync(process.execPath, ['--check', target], { stdio: 'pipe' })
38
+ const ok = r.status === 0
39
+ checked++
40
+ if (!ok) {
41
+ bad++
42
+ console.log(` FAIL ${dir}/${f}${cjs ? ' (checked as CJS)' : ''}`)
43
+ if (r.stderr) console.error(r.stderr.toString().trim())
44
+ } else {
45
+ console.log(` ok ${dir}/${f}${cjs ? ' (checked as CJS)' : ''}`)
46
+ }
47
+ }
48
+ }
49
+ } finally {
50
+ rmSync(scratch, { recursive: true, force: true })
51
+ }
52
+
53
+ console.log(bad === 0 ? `PASS (${checked} files)` : `FAIL (${bad}/${checked} file(s))`)
54
+ process.exit(bad === 0 ? 0 : 1)
@@ -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/src/host/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
- }