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/CONTRIBUTORS.md +6 -0
- package/README.en.md +88 -7
- package/README.md +84 -7
- package/electron/outage.js +60 -0
- package/electron/preload.js +168 -1
- package/electron/service.js +47 -9
- package/electron/window.js +246 -23
- package/lib/client.js +41 -6
- package/lib/icon.js +44 -10
- package/lib/index.js +33 -2
- package/package.json +140 -127
- package/scripts/selftest-recovery.mjs +140 -0
- package/src/client/client.js +41 -6
- package/src/host/icon.js +44 -10
- package/src/host/index.js +33 -2
- package/version.txt +1 -1
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)
|
|
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.
|
|
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
|
-
"
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"."
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
},
|
|
110
|
-
"
|
|
111
|
-
"
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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()
|
package/src/client/client.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-clean-desktop-shell — client half (web browser bundle).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
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
|
-
|
|
27
|
-
|
|
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)
|
|
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.
|
|
1
|
+
0.1.13
|