dsh-clean-desktop-shell 0.1.12 → 0.1.13

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/lib/index.js CHANGED
@@ -12,7 +12,7 @@ import { spawn } from 'node:child_process'
12
12
  import { appendFileSync } from 'node:fs'
13
13
  import { PKG_ROOT, MAIN_JS, dshHome, runtimeRoot, launchLogPath } from './common.js'
14
14
  import { ensureRuntime } from './runtime.js'
15
- import { patchExeIcon } from './icon.js'
15
+ import { ensureRcedit, patchExeIcon } from './icon.js'
16
16
 
17
17
  // cordis registers the plugin by this name — bundles without an explicit
18
18
  // `name` export are silently skipped by the dsh loader.
@@ -21,8 +21,36 @@ export const name = 'dsh-clean-desktop-shell'
21
21
  // Disable auto-launch with DSH_SHELL_AUTO_LAUNCH=0.
22
22
  const AUTO_LAUNCH = process.env.DSH_SHELL_AUTO_LAUNCH !== '0'
23
23
 
24
+ /**
25
+ * Hard budget for the taskbar-icon step. It runs before the spawn (Windows
26
+ * locks a running image), so it is on the launch path — but the icon is
27
+ * cosmetic and must never decide when the window appears. Without a budget a
28
+ * stalled rcedit fetch held the first launch for up to the fetchFile default
29
+ * of 600 s; with it, the worst case is 25 s and the usual case is ~0 (the
30
+ * fetch has already finished behind the Electron runtime download).
31
+ */
32
+ const ICON_PATCH_DEADLINE_MS = 25000
33
+
24
34
  let launched = false
25
35
 
36
+ /**
37
+ * Let a best-effort step run, but never wait longer than `ms` for it. Whatever
38
+ * it was doing keeps running in the background.
39
+ */
40
+ function withDeadline(ctx, promise, ms) {
41
+ let timer
42
+ const deadline = new Promise((resolve) => {
43
+ timer = setTimeout(() => {
44
+ ctx.logger.warn(`[clean-desktop-shell] icon step exceeded ${ms} ms — launching without it`)
45
+ resolve()
46
+ }, ms)
47
+ // Do not keep the host process alive just to fire a warning.
48
+ timer.unref?.()
49
+ })
50
+ const settled = promise.then(() => {}, () => {})
51
+ return Promise.race([settled, deadline]).finally(() => clearTimeout(timer))
52
+ }
53
+
26
54
  export function apply(ctx) {
27
55
  ctx.logger.info('[clean-desktop-shell] mounted (host half)')
28
56
  if (!AUTO_LAUNCH) return
@@ -30,10 +58,13 @@ export function apply(ctx) {
30
58
  // Runtime preparation is independent of authentication — start it now, but
31
59
  // defer the Electron spawn until the launch URL is minted below.
32
60
  let runtimeExe = null
61
+ // Kick the (tiny) rcedit fetch off alongside the (large) Electron runtime so
62
+ // the two never serialise; patchExeIcon reuses this same memoised promise.
63
+ void ensureRcedit(ctx)
33
64
  const runtimeReady = ensureRuntime(ctx)
34
65
  .then(async (exe) => {
35
66
  runtimeExe = exe
36
- await patchExeIcon(ctx, exe).catch(() => {})
67
+ await withDeadline(ctx, patchExeIcon(ctx, exe), ICON_PATCH_DEADLINE_MS)
37
68
  })
38
69
  .catch((err) => {
39
70
  reportLaunchFailure(ctx, err)
package/package.json CHANGED
@@ -1,127 +1,140 @@
1
- {
2
- "name": "dsh-clean-desktop-shell",
3
- "version": "0.1.12",
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
- "repository": {
6
- "type": "git",
7
- "url": "git+https://github.com/Icather/dsh-clean-desktop-shell.git"
8
- },
9
- "homepage": "https://github.com/Icather/dsh-clean-desktop-shell#readme",
10
- "bugs": {
11
- "url": "https://github.com/Icather/dsh-clean-desktop-shell/issues"
12
- },
13
- "type": "module",
14
- "main": "lib/index.js",
15
- "files": [
16
- "lib/",
17
- "electron/",
18
- "src/",
19
- "scripts/",
20
- "build/icon.png",
21
- "build/icon.ico",
22
- "cordis.patch.yml",
23
- "README.md",
24
- "README.en.md",
25
- "CONTRIBUTORS.md",
26
- "LICENSE",
27
- "version.txt"
28
- ],
29
- "exports": {
30
- ".": "./lib/index.js",
31
- "./client": "./lib/client.js",
32
- "./package.json": "./package.json"
33
- },
34
- "scripts": {
35
- "build": "node scripts/build.mjs",
36
- "check": "node scripts/check-syntax.mjs && node scripts/selftest-runtime.mjs",
37
- "dev": "electron electron/main.js",
38
- "icons": "node scripts/gen-icons.mjs",
39
- "pack": "electron-builder --win nsis",
40
- "pack:mac": "electron-builder --mac dmg"
41
- },
42
- "dsh": {
43
- "bundle": {
44
- "patch": "./cordis.patch.yml"
45
- },
46
- "client": {
47
- "inject": [
48
- "@deepseek-ai/dsh-client-runtime",
49
- "@deepseek-ai/dsh-client-locale",
50
- "@deepseek-ai/dsh-client-ui-settings",
51
- "@deepseek-ai/dsh-client-ui-theme"
52
- ],
53
- "platform": "web"
54
- }
55
- },
56
- "peerDependencies": {
57
- "@deepseek-ai/dsh": "*"
58
- },
59
- "peerDependenciesMeta": {
60
- "@deepseek-ai/dsh": {
61
- "optional": true
62
- }
63
- },
64
- "devDependencies": {
65
- "electron": "^33.0.0",
66
- "electron-builder": "^25.0.0",
67
- "png-to-ico": "^3.0.2",
68
- "sharp": "^0.35.3"
69
- },
70
- "build": {
71
- "appId": "com.icather.dsh-clean-desktop-shell",
72
- "productName": "DSH Clean Desktop Shell",
73
- "asar": true,
74
- "files": [
75
- "electron/**/*",
76
- "lib/**/*",
77
- "src/**/*",
78
- "build/icon.png",
79
- "package.json",
80
- "cordis.patch.yml"
81
- ],
82
- "extraMetadata": {
83
- "main": "electron/main.js"
84
- },
85
- "win": {
86
- "target": [
87
- {
88
- "target": "nsis",
89
- "arch": [
90
- "x64"
91
- ]
92
- }
93
- ],
94
- "icon": "build/icon.ico",
95
- "signAndEditExecutable": false
96
- },
97
- "mac": {
98
- "target": [
99
- "dmg"
100
- ],
101
- "category": "public.app-category.developer-tools",
102
- "icon": "build/icon.png"
103
- },
104
- "nsis": {
105
- "oneClick": true,
106
- "perMachine": false,
107
- "createDesktopShortcut": true,
108
- "createStartMenuShortcut": true
109
- },
110
- "publish": {
111
- "provider": "github",
112
- "owner": "Icather",
113
- "repo": "dsh-clean-desktop-shell"
114
- }
115
- },
116
- "license": "MIT",
117
- "allowScripts": {
118
- "electron@33.4.11": true
119
- },
120
- "dependencies": {
121
- "electron-updater": "^6.8.9",
122
- "semver": "^7.7.2"
123
- },
124
- "desktopShell": {
125
- "electronVersion": "33.4.11"
126
- }
127
- }
1
+ {
2
+ "name": "dsh-clean-desktop-shell",
3
+ "version": "0.1.13",
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
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Icather/dsh-clean-desktop-shell.git"
8
+ },
9
+ "homepage": "https://github.com/Icather/dsh-clean-desktop-shell#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/Icather/dsh-clean-desktop-shell/issues"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "engines": {
16
+ "node": ">=20.0.0"
17
+ },
18
+ "files": [
19
+ "lib/",
20
+ "electron/",
21
+ "src/",
22
+ "scripts/",
23
+ "build/icon.png",
24
+ "build/icon.ico",
25
+ "cordis.patch.yml",
26
+ "README.md",
27
+ "README.en.md",
28
+ "CONTRIBUTORS.md",
29
+ "LICENSE",
30
+ "version.txt"
31
+ ],
32
+ "exports": {
33
+ ".": "./lib/index.js",
34
+ "./client": "./lib/client.js",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "scripts": {
38
+ "build": "node scripts/build.mjs",
39
+ "check": "node scripts/check-syntax.mjs && node scripts/selftest-runtime.mjs && node scripts/selftest-recovery.mjs",
40
+ "dev": "electron electron/main.js",
41
+ "icons": "node scripts/gen-icons.mjs",
42
+ "pack": "electron-builder --win nsis",
43
+ "pack:mac": "electron-builder --mac dmg"
44
+ },
45
+ "dsh": {
46
+ "bundle": {
47
+ "patch": "./cordis.patch.yml"
48
+ },
49
+ "compatibility": {
50
+ "dsh": ">=0.1.1 <0.2.0",
51
+ "dshReleases": {
52
+ "0.1.1-rc.2": "unknown",
53
+ "0.1.5-rc.1": "compatible",
54
+ "0.1.5-rc.2": "unknown",
55
+ "0.1.5-alpha.2": "unknown"
56
+ }
57
+ },
58
+ "client": {
59
+ "inject": [
60
+ "@deepseek-ai/dsh-client-connection",
61
+ "@deepseek-ai/dsh-client-runtime",
62
+ "@deepseek-ai/dsh-client-locale",
63
+ "@deepseek-ai/dsh-client-ui-settings",
64
+ "@deepseek-ai/dsh-client-ui-theme"
65
+ ],
66
+ "platform": "web"
67
+ }
68
+ },
69
+ "peerDependencies": {
70
+ "@deepseek-ai/dsh": "*"
71
+ },
72
+ "peerDependenciesMeta": {
73
+ "@deepseek-ai/dsh": {
74
+ "optional": true
75
+ }
76
+ },
77
+ "devDependencies": {
78
+ "electron": "^33.0.0",
79
+ "electron-builder": "^25.0.0",
80
+ "png-to-ico": "^3.0.2",
81
+ "sharp": "^0.35.3"
82
+ },
83
+ "build": {
84
+ "appId": "com.icather.dsh-clean-desktop-shell",
85
+ "productName": "DSH Clean Desktop Shell",
86
+ "asar": true,
87
+ "files": [
88
+ "electron/**/*",
89
+ "lib/**/*",
90
+ "src/**/*",
91
+ "build/icon.png",
92
+ "package.json",
93
+ "cordis.patch.yml"
94
+ ],
95
+ "extraMetadata": {
96
+ "main": "electron/main.js"
97
+ },
98
+ "win": {
99
+ "target": [
100
+ {
101
+ "target": "nsis",
102
+ "arch": [
103
+ "x64"
104
+ ]
105
+ }
106
+ ],
107
+ "icon": "build/icon.ico",
108
+ "signAndEditExecutable": false
109
+ },
110
+ "mac": {
111
+ "target": [
112
+ "dmg"
113
+ ],
114
+ "category": "public.app-category.developer-tools",
115
+ "icon": "build/icon.png"
116
+ },
117
+ "nsis": {
118
+ "oneClick": true,
119
+ "perMachine": false,
120
+ "createDesktopShortcut": true,
121
+ "createStartMenuShortcut": true
122
+ },
123
+ "publish": {
124
+ "provider": "github",
125
+ "owner": "Icather",
126
+ "repo": "dsh-clean-desktop-shell"
127
+ }
128
+ },
129
+ "license": "MIT",
130
+ "allowScripts": {
131
+ "electron@33.4.11": true
132
+ },
133
+ "dependencies": {
134
+ "electron-updater": "^6.8.9",
135
+ "semver": "^7.7.2"
136
+ },
137
+ "desktopShell": {
138
+ "electronVersion": "33.4.11"
139
+ }
140
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Self-check for the backend-outage run (electron/outage.js).
3
+ *
4
+ * Defends the phase-1 recovery transitions, not threshold constants alone:
5
+ * - consecutive probe failures escalate the in-page notice from transient
6
+ * to persistent/unavailable only after the agreed boundary, and a single
7
+ * success resets the run (a one-off slow response can never escalate a
8
+ * loaded session);
9
+ * - the navigation token discards probe results that finish after the
10
+ * window navigated or was disposed — a stale result (success or failure)
11
+ * must never mutate the run nor produce a notice payload.
12
+ *
13
+ * Run: node scripts/selftest-recovery.mjs
14
+ * Electron additionally checks that a real failed spawn remains retryable.
15
+ */
16
+ import { OutageRun, PERSISTENT_AFTER_FAILURES } from '../electron/outage.js'
17
+
18
+ let failures = 0
19
+
20
+ function check(label, cond) {
21
+ console.log(` ${cond ? 'ok ' : 'FAIL'} ${label}`)
22
+ if (!cond) failures++
23
+ }
24
+
25
+ function same(a, b) {
26
+ return JSON.stringify(a) === JSON.stringify(b)
27
+ }
28
+
29
+ console.log('[escalation: transient → persistent → recovery]')
30
+ const run = new OutageRun()
31
+ check('a healthy probe yields the ok payload', same(run.apply(run.checkpoint(), true), { state: 'ok' }))
32
+ for (let i = 1; i < PERSISTENT_AFTER_FAILURES; i++) {
33
+ const out = run.apply(run.checkpoint(), false)
34
+ check(
35
+ `failure ${i} stays transient (failures=${run.failures})`,
36
+ same(out, { state: 'degraded', persistent: false }) && run.failures === i,
37
+ )
38
+ }
39
+ const escalated = run.apply(run.checkpoint(), false)
40
+ check(
41
+ `failure ${PERSISTENT_AFTER_FAILURES} escalates to persistent`,
42
+ same(escalated, { state: 'degraded', persistent: true }) &&
43
+ run.failures === PERSISTENT_AFTER_FAILURES,
44
+ )
45
+ check(
46
+ 'further failures stay persistent',
47
+ same(run.apply(run.checkpoint(), false), { state: 'degraded', persistent: true }),
48
+ )
49
+ const recovered = run.apply(run.checkpoint(), true)
50
+ check('a success recovers and resets the run', same(recovered, { state: 'ok' }) && run.failures === 0)
51
+ check(
52
+ 'a failure after recovery is transient again',
53
+ same(run.apply(run.checkpoint(), false), { state: 'degraded', persistent: false }) &&
54
+ run.failures === 1,
55
+ )
56
+
57
+ console.log('[stale results after navigation are discarded]')
58
+ const run2 = new OutageRun()
59
+ const t1 = run2.checkpoint()
60
+ const t2 = run2.checkpoint()
61
+ check('checkpoints stay valid while no navigation happens', run2.isCurrent(t1) && run2.isCurrent(t2))
62
+ run2.apply(t1, false)
63
+ run2.reset() // e.g. showOffline / reload / fresh page load
64
+ check('reset invalidates outstanding checkpoints', !run2.isCurrent(t1) && !run2.isCurrent(t2))
65
+ check('a stale failure result is discarded (no payload)', run2.apply(t1, false) === null)
66
+ check('a stale success result is discarded too', run2.apply(t2, true) === null)
67
+ check('discards did not mutate the fresh run', run2.failures === 0)
68
+ check(
69
+ 'a fresh probe after the reset starts over at failure 1',
70
+ same(run2.apply(run2.checkpoint(), false), { state: 'degraded', persistent: false }) &&
71
+ run2.failures === 1,
72
+ )
73
+
74
+ if (process.versions.electron) {
75
+ const { default: assert } = await import('node:assert/strict')
76
+ const { mkdtempSync, writeFileSync, unlinkSync, rmdirSync } = await import('node:fs')
77
+ const { tmpdir } = await import('node:os')
78
+ const { join } = await import('node:path')
79
+ const service = await import('../electron/service.js')
80
+ const folder = mkdtempSync(join(tmpdir(), 'dsh-failed-spawn-'))
81
+ const command = join(folder, 'dsh')
82
+ const fetch = globalThis.fetch
83
+ // Keep the probe isolated from any real backend; process spawning stays real.
84
+ globalThis.fetch = async () => { throw new Error('isolated unreachable backend') }
85
+ try {
86
+ writeFileSync(command, 'not an executable', { mode: 0o600 })
87
+ await assert.rejects(service.start({ backendPath: folder }))
88
+ await assert.rejects(service.start({ backendPath: folder }))
89
+ check('a failed spawn rejects again instead of reporting a live backend', true)
90
+ } catch (error) {
91
+ console.error(error)
92
+ check('a failed spawn rejects again instead of reporting a live backend', false)
93
+ } finally {
94
+ globalThis.fetch = fetch
95
+ unlinkSync(command)
96
+ rmdirSync(folder)
97
+ }
98
+
99
+ const { app, ipcMain } = await import('electron')
100
+ const { createServer } = await import('node:http')
101
+ const { once } = await import('node:events')
102
+ const { createMainWindow } = await import('../electron/window.js')
103
+ const server = createServer((_request, response) => response.end('<!doctype html><title>Recovery check</title>'))
104
+ app.whenReady().then(async () => {
105
+ let window
106
+ try {
107
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
108
+ window = createMainWindow({ target: `http://127.0.0.1:${server.address().port}/?dsh-desktop-mode=advanced` })
109
+ window.hide()
110
+ await once(window.webContents, 'did-finish-load')
111
+ await window.webContents.executeJavaScript(`
112
+ window.attempts = 0
113
+ window.shellAPI.onReconnectRequest(() => {
114
+ if (++window.attempts > 5) return
115
+ window.shellAPI.connectionReport('connecting')
116
+ window.shellAPI.connectionReport('disconnected')
117
+ })
118
+ window.shellAPI.connectionReport('disconnected')
119
+ `)
120
+ for (let i = 0; i < 5; i++) ipcMain.emit('shell:retry-connection', { sender: window.webContents })
121
+ await new Promise(resolve => setTimeout(resolve, 500))
122
+ assert.equal(await window.webContents.executeJavaScript('window.attempts'), 1)
123
+ check('burst retries share one attempt and immediate failure does not feed back', true)
124
+ } catch (error) {
125
+ console.error(error)
126
+ check('burst retries share one attempt and immediate failure does not feed back', false)
127
+ } finally {
128
+ window?.destroy()
129
+ server.close()
130
+ }
131
+ finish()
132
+ })
133
+ }
134
+
135
+ function finish() {
136
+ console.log(`\n${failures === 0 ? 'PASS' : `FAIL (${failures} check(s))`}`)
137
+ process.exit(failures === 0 ? 0 : 1)
138
+ }
139
+
140
+ if (!process.versions.electron) finish()
@@ -1,11 +1,21 @@
1
1
  /**
2
2
  * dsh-clean-desktop-shell — client half (web browser bundle).
3
3
  *
4
- * The shell is a standalone Electron window; it injects nothing into the
5
- * dsh web UI. This client module exists only to satisfy the client-modules
6
- * loader contract a bundle's client entry must register itself via
7
- * `window.__ModuleLoader__.load({ id, factory })`, otherwise dsh reports
8
- * "loaded without registering … via __ModuleLoader__.load".
4
+ * Bridges the page's DSH client-runtime connection lifecycle into the
5
+ * Electron shell so HTTP health alone can never hide a terminal disconnect
6
+ * (e.g. the backend restarted under the page: HTTP answers again while the
7
+ * page's WebSocket generation is dead).
8
+ *
9
+ * - ctx.connection.state ('connected' | 'connecting' | 'disconnected') is
10
+ * forwarded to the main process (shell:client-connection → preload
11
+ * shellAPI.connectionReport);
12
+ * - main-process reconnect requests (shell:client-reconnect →
13
+ * shellAPI.onReconnectRequest) call ctx.connection.reconnect() —
14
+ * recovery through the app's own reconnect loop, never a reload.
15
+ *
16
+ * The supported baseline provides ctx.connection.state / reconnect();
17
+ * missing APIs fail activation loudly instead of degrading. A plain browser
18
+ * (no shellAPI — no Electron shell) is the only silent case.
9
19
  */
10
20
  window.__ModuleLoader__.load({
11
21
  id: 'dsh-clean-desktop-shell',
@@ -13,7 +23,32 @@ window.__ModuleLoader__.load({
13
23
  var module = { exports: {} };
14
24
  var exports = module.exports;
15
25
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
16
- exports.apply = () => {};
26
+ exports.inject = ['connection'];
27
+ exports.apply = function (ctx) {
28
+ var api = window.shellAPI;
29
+ if (!api) return;
30
+ var connection = ctx.connection;
31
+ var latest = null;
32
+ var notify = function () {
33
+ var state = connection.state.getSnapshot();
34
+ if (state === latest) return;
35
+ latest = state;
36
+ if (state === 'connected' || state === 'connecting' || state === 'disconnected') {
37
+ api.connectionReport(state);
38
+ }
39
+ };
40
+ var unsubscribe = connection.state.subscribe(notify);
41
+ notify(); // report whatever the runtime already is — a lost initial disconnect must not be hidden
42
+ var unregisterRequest = api.onReconnectRequest(function () {
43
+ connection.reconnect();
44
+ });
45
+ ctx.effect(function () {
46
+ return function () {
47
+ unsubscribe();
48
+ unregisterRequest();
49
+ };
50
+ });
51
+ };
17
52
  return module.exports;
18
53
  },
19
54
  });
package/src/host/icon.js CHANGED
@@ -16,6 +16,48 @@ import { existsSync, writeFileSync } from 'node:fs'
16
16
  import { join } from 'node:path'
17
17
  import { PKG_ROOT, isWin, runtimeRoot, fetchFile } from './common.js'
18
18
 
19
+ const RCEDIT_NAME = 'rcedit-x64.exe'
20
+ const RCEDIT_URL = 'https://github.com/electron/rcedit/releases/download/v2.0.0/rcedit-x64.exe'
21
+
22
+ /**
23
+ * rcedit is a ~1.3 MB self-contained exe, and its download sits on the launch
24
+ * path (Windows locks a running image, so the patch has to happen before the
25
+ * exe is spawned). fetchFile's default budget — 600 s — is sized for the
26
+ * ~100 MB Electron runtime, not for this: behind a stalled proxy it could keep
27
+ * the window off screen for ten minutes. Cap it well below that; a miss only
28
+ * costs the default icon.
29
+ */
30
+ const RCEDIT_TIMEOUT_SEC = 20
31
+
32
+ let rceditPromise = null
33
+
34
+ /**
35
+ * Fetch (once) the cached rcedit binary next to the runtimes. Memoised so the
36
+ * host half can start this alongside the Electron runtime download and have
37
+ * the icon step reuse the same fetch instead of serialising behind it.
38
+ *
39
+ * Never rejects: every failure mode just means "no rcedit, default icon".
40
+ */
41
+ export function ensureRcedit(ctx) {
42
+ if (!rceditPromise) {
43
+ rceditPromise = (async () => {
44
+ try {
45
+ const rcedit = join(runtimeRoot(), RCEDIT_NAME)
46
+ if (existsSync(rcedit)) return rcedit
47
+ ctx.logger.info('[clean-desktop-shell] downloading rcedit for icon patching')
48
+ if (await fetchFile(RCEDIT_URL, rcedit, RCEDIT_TIMEOUT_SEC)) return rcedit
49
+ ctx.logger.warn('[clean-desktop-shell] rcedit download failed — taskbar icon stays default')
50
+ } catch (err) {
51
+ ctx.logger.warn(`[clean-desktop-shell] rcedit unavailable (${err?.message ?? err}) — taskbar icon stays default`)
52
+ }
53
+ // Not memoised as a failure: a later launch deserves a fresh attempt.
54
+ rceditPromise = null
55
+ return null
56
+ })()
57
+ }
58
+ return rceditPromise
59
+ }
60
+
19
61
  export async function patchExeIcon(ctx, exe) {
20
62
  if (!isWin) return
21
63
  const ico = join(PKG_ROOT, 'build', 'icon.ico')
@@ -23,16 +65,8 @@ export async function patchExeIcon(ctx, exe) {
23
65
  const marker = `${exe}.whale-icon`
24
66
  if (existsSync(marker)) return
25
67
 
26
- // rcedit is a single self-contained exe, cached next to the runtimes.
27
- const rcedit = join(runtimeRoot(), 'rcedit-x64.exe')
28
- if (!existsSync(rcedit)) {
29
- const url = 'https://github.com/electron/rcedit/releases/download/v2.0.0/rcedit-x64.exe'
30
- ctx.logger.info('[clean-desktop-shell] downloading rcedit for icon patching')
31
- if (!(await fetchFile(url, rcedit))) {
32
- ctx.logger.warn('[clean-desktop-shell] rcedit download failed — taskbar icon stays default')
33
- return
34
- }
35
- }
68
+ const rcedit = await ensureRcedit(ctx)
69
+ if (!rcedit) return
36
70
 
37
71
  const child = spawn(rcedit, [exe, '--set-icon', ico], {
38
72
  windowsHide: true,
package/src/host/index.js CHANGED
@@ -12,7 +12,7 @@ import { spawn } from 'node:child_process'
12
12
  import { appendFileSync } from 'node:fs'
13
13
  import { PKG_ROOT, MAIN_JS, dshHome, runtimeRoot, launchLogPath } from './common.js'
14
14
  import { ensureRuntime } from './runtime.js'
15
- import { patchExeIcon } from './icon.js'
15
+ import { ensureRcedit, patchExeIcon } from './icon.js'
16
16
 
17
17
  // cordis registers the plugin by this name — bundles without an explicit
18
18
  // `name` export are silently skipped by the dsh loader.
@@ -21,8 +21,36 @@ export const name = 'dsh-clean-desktop-shell'
21
21
  // Disable auto-launch with DSH_SHELL_AUTO_LAUNCH=0.
22
22
  const AUTO_LAUNCH = process.env.DSH_SHELL_AUTO_LAUNCH !== '0'
23
23
 
24
+ /**
25
+ * Hard budget for the taskbar-icon step. It runs before the spawn (Windows
26
+ * locks a running image), so it is on the launch path — but the icon is
27
+ * cosmetic and must never decide when the window appears. Without a budget a
28
+ * stalled rcedit fetch held the first launch for up to the fetchFile default
29
+ * of 600 s; with it, the worst case is 25 s and the usual case is ~0 (the
30
+ * fetch has already finished behind the Electron runtime download).
31
+ */
32
+ const ICON_PATCH_DEADLINE_MS = 25000
33
+
24
34
  let launched = false
25
35
 
36
+ /**
37
+ * Let a best-effort step run, but never wait longer than `ms` for it. Whatever
38
+ * it was doing keeps running in the background.
39
+ */
40
+ function withDeadline(ctx, promise, ms) {
41
+ let timer
42
+ const deadline = new Promise((resolve) => {
43
+ timer = setTimeout(() => {
44
+ ctx.logger.warn(`[clean-desktop-shell] icon step exceeded ${ms} ms — launching without it`)
45
+ resolve()
46
+ }, ms)
47
+ // Do not keep the host process alive just to fire a warning.
48
+ timer.unref?.()
49
+ })
50
+ const settled = promise.then(() => {}, () => {})
51
+ return Promise.race([settled, deadline]).finally(() => clearTimeout(timer))
52
+ }
53
+
26
54
  export function apply(ctx) {
27
55
  ctx.logger.info('[clean-desktop-shell] mounted (host half)')
28
56
  if (!AUTO_LAUNCH) return
@@ -30,10 +58,13 @@ export function apply(ctx) {
30
58
  // Runtime preparation is independent of authentication — start it now, but
31
59
  // defer the Electron spawn until the launch URL is minted below.
32
60
  let runtimeExe = null
61
+ // Kick the (tiny) rcedit fetch off alongside the (large) Electron runtime so
62
+ // the two never serialise; patchExeIcon reuses this same memoised promise.
63
+ void ensureRcedit(ctx)
33
64
  const runtimeReady = ensureRuntime(ctx)
34
65
  .then(async (exe) => {
35
66
  runtimeExe = exe
36
- await patchExeIcon(ctx, exe).catch(() => {})
67
+ await withDeadline(ctx, patchExeIcon(ctx, exe), ICON_PATCH_DEADLINE_MS)
37
68
  })
38
69
  .catch((err) => {
39
70
  reportLaunchFailure(ctx, err)
package/version.txt CHANGED
@@ -1 +1 @@
1
- 0.1.12
1
+ 0.1.13