dsh-clean-desktop-shell 0.1.9 → 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 +43 -24
- 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
|
@@ -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
|
-
/**
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,17 +235,14 @@ 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:
|
|
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 =
|
|
245
|
+
latest = coerceVersion(tag)
|
|
247
246
|
if (data.html_url) url = data.html_url
|
|
248
247
|
}
|
|
249
248
|
} catch {
|
|
@@ -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
|
|
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 =
|
|
297
|
-
const latestV =
|
|
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.
|
|
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
|
|
393
|
+
if (!settled) treeKill(child)
|
|
375
394
|
}, timeoutMs)
|
|
376
|
-
child.stdout?.on('data', (d) => { out
|
|
377
|
-
child.stderr?.on('data', (d) => { err
|
|
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)
|
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"
|
|
@@ -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)
|
package/src/host/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/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
|
-
}
|