dsh-clean-desktop-shell 0.1.4 → 0.1.6

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 CHANGED
@@ -29,6 +29,25 @@ Key differences from other desktop clients in the ecosystem:
29
29
  | **Visual changes** | Custom title bar / frosted glass etc. | **None** — pure window shell |
30
30
  | **Upstream** | Pinned version | **Tracks rc.7** |
31
31
 
32
+ ## Usage
33
+
34
+ 1. If the plugin is installed, launching `dsh` from the command line pops up the desktop window automatically; you can also double-click the desktop shortcut created by the plugin — on par with a native desktop app.
35
+ 2. Everything from the original web UI works as-is.
36
+ 3. Detailed settings live in the tray right-click menu. The main window adds no controls of its own, keeping the page clean.
37
+
38
+ **All backend controls live in the tray** — the main window stays a pure shell:
39
+
40
+ - Start / restart / stop the backend (with progress dialogs; stopping really shuts down the service on 3080, including externally started instances)
41
+ - Auto-detect backend · set the backend install folder (auto-detect default)
42
+ - Reload window · create desktop shortcut · check for updates · repo homepage
43
+
44
+ **Window reliability (Edge-style instant refresh):**
45
+
46
+ - Shows immediately on launch, never waits for the backend
47
+ - While the backend is down, a local "backend offline" screen is shown and re-probed; the real page loads automatically the moment it answers
48
+ - The instant the backend stops (tray stop, kill or crash) the window flips back to the offline screen — a stale page never fakes "still alive"
49
+ - The offline screen has self-service buttons: reload / start backend / auto-detect backend / set backend install folder
50
+
32
51
  ## Install
33
52
 
34
53
  **Option 1: download the installer from Releases (for a standalone desktop app)**
@@ -87,22 +106,30 @@ Unblock-File -Path "$env:USERPROFILE\Downloads\DSH-Clean-Desktop-Shell-Setup-*.e
87
106
  ## Architecture
88
107
 
89
108
  ```
90
- ┌─────────────────── Core (dsh web / headless service) ───────────────────┐
91
- Sessions · Agent · Plugins · Memory live here, decoupled from UI
92
- └─────────────────────────────────────────────────────────────────────────┘
93
-
94
- ┌───────────────┴───────────────┐
95
- │ dsh-clean-desktop-shell │
96
- │ Electron shell (client) │
97
- tray · single-instance ·
98
-
99
- └────────────────────────────────┘
109
+ ┌────────────── Core (dsh web / headless service) ──────────────┐
110
+ Sessions · Agent · Plugins · Memory live here,
111
+ │ decoupled from the UI │
112
+ └───────────────────────────┬───────────────────────────────────┘
113
+ │ http://127.0.0.1:3080 (or remote)
114
+
115
+ ┌───────────────────────────────────────────────────────────┐
116
+ dsh-clean-desktop-shell (Electron shell)
117
+ tray · single-instance · offline auto-reconnect
118
+ │ · desktop shortcut │
119
+ │ │
120
+ │ One shell codebase, two distribution forms: │
121
+ │ ├─ Installer: standalone exe, auto-update │
122
+ │ └─ Plugin: auto-pops on dsh web start │
123
+ │ (self-managed Electron runtime) │
124
+ └───────────────────────────────────────────────────────────┘
100
125
  ```
101
126
 
127
+ - **Shell / core separation**: the shell handles only the window, tray and backend management; sessions, agents, plugins and memory all live in the core, decoupled from the UI.
102
128
  - **Default**: loads the local `127.0.0.1:3080` (your configured web profile, zero migration).
103
129
  - **Remote-capable**: configure any remote DSH address; the shell is just a window. Phones / Linux / other devices can reach the core via browser or PWA — the shell is never bound to a local service.
130
+ - **One shell codebase, two distribution forms**: the installer (standalone exe) and the plugin (launches with `dsh web`) share the same `electron/` code — only the runtime source and launch differ (see Install).
104
131
 
105
- ### Platform matrix
132
+ ## Platform matrix
106
133
 
107
134
  | Platform | Shell | Status |
108
135
  |:--|:--|:--|
@@ -111,29 +138,6 @@ Unblock-File -Path "$env:USERPROFILE\Downloads\DSH-Clean-Desktop-Shell-Setup-*.e
111
138
  | Linux | — (browser / PWA to the core) | Not planned |
112
139
  | Termux / phone / tablet | — (headless / PWA to the core) | Covered by remote core access |
113
140
 
114
- ## Usage
115
-
116
- 1. Start `dsh web` (or configure a remote service address).
117
- 2. Launch the shell: it auto-detects local 3080 — loads the page if the backend is up, otherwise shows the "backend offline" screen where you can start it in one click.
118
- 3. Drag the window by its top area (right side reserved for native buttons); close minimizes to tray by default.
119
-
120
- **All backend controls live in the tray** — the main window stays a pure shell:
121
-
122
- - Start / restart / stop the backend (with progress dialogs; stopping really
123
- shuts down the service on 3080, including externally started instances)
124
- - Auto-detect backend · set the backend install folder (auto-detect default)
125
- - Reload window · check for updates · repo homepage
126
-
127
- **Window reliability (Edge-style instant refresh):**
128
-
129
- - Shows immediately on launch, never waits for the backend
130
- - While the backend is down, a local "backend offline" screen is shown and
131
- re-probed; the real page loads automatically the moment it answers
132
- - The instant the backend stops (tray stop, kill or crash) the window flips
133
- back to the offline screen — a stale page never fakes "still alive"
134
- - The offline screen has self-service buttons: reload / start backend /
135
- auto-detect backend / set backend install folder
136
-
137
141
  ## Development
138
142
 
139
143
  ```sh
package/README.md CHANGED
@@ -29,6 +29,25 @@
29
29
  | **视觉改造** | 自绘标题栏 / 毛玻璃等 | **零改造**,纯净窗口壳 |
30
30
  | **跟随上游** | 固定版本 | **跟随 rc.7** |
31
31
 
32
+ ## 使用
33
+
34
+ 1. 若安装过插件,命令行启动 `dsh` 自动弹出桌面窗口;也可通过插件创建的桌面快捷方式双击,媲美原生桌面端的体验。
35
+ 2. 使用原网页端的一切功能。
36
+ 3. 托盘右键可以进行详细设置。主窗口不添加任何控件,保持页面纯净。
37
+
38
+ **后端的一切操作都在托盘右键**,主窗口保持纯壳:
39
+
40
+ - 启动 / 重启 / 关闭后端(带进度弹窗;关闭会真正停掉 3080 上的服务,包括外部启动的实例)
41
+ - 自动探测后端 · 设置后端安装文件夹(默认自动探测定位)
42
+ - 刷新窗口 · 创建桌面快捷方式 · 检查更新 · 仓库主页
43
+
44
+ **窗口的可靠性(Edge 式即时刷新)**:
45
+
46
+ - 双击启动立即出窗,不等后端就绪
47
+ - 后端没起来时显示「后端未连接」页,自动探测;后端一通立即加载
48
+ - 后端关闭 / 被杀的一刻,窗口立刻切回离线页——不会停在旧页面假装还活着
49
+ - 离线页内置快捷按钮:重新加载 / 启动后端 / 自动探测后端 / 设置后端安装文件夹
50
+
32
51
  ## 安装
33
52
 
34
53
  **方式一:从 Release 下载安装包(想要独立桌面应用的用户)**
@@ -91,21 +110,27 @@ Unblock-File -Path "$env:USERPROFILE\Downloads\DSH-Clean-Desktop-Shell-Setup-*.e
91
110
  ## 架构
92
111
 
93
112
  ```
94
- ┌──────────────────── 内核(dsh web / headless 服务) ────────────────────┐
95
- 会话 · Agent · 插件 · 记忆 都在这层,与界面解耦
96
- └─────────────────────────────────────────────────────────────────────────┘
97
-
98
- ┌───────────────┴───────────────┐
99
- │ dsh-clean-desktop-shell │
100
- Electron 壳(客户端)
101
- 托盘 · 单实例
102
- └────────────────────────────────┘
113
+ ┌────────────── 内核(dsh web / headless 服务)──────────────┐
114
+ 会话 · Agent · 插件 · 记忆 都在这层,与界面解耦
115
+ └───────────────────────────┬──────────────────────────────┘
116
+ │ http://127.0.0.1:3080(或远程地址)
117
+
118
+ ┌───────────────────────────────────────────────────────────┐
119
+ dsh-clean-desktop-shell(Electron 壳)
120
+ 托盘 · 单实例 · 离线自动重连 · 桌面快捷方式
121
+ │ │
122
+ │ 同一份壳代码,两种分发形态: │
123
+ │ ├─ 安装包版:独立 exe,双击即用,自动更新 │
124
+ │ └─ 插件版:随 dsh web 启动自动弹窗(自管 Electron 运行时)│
125
+ └───────────────────────────────────────────────────────────┘
103
126
  ```
104
127
 
128
+ - **壳 / 内核分离**:壳只负责窗口、托盘、后端管理;会话、Agent、插件、记忆都在内核层,与界面解耦。
105
129
  - **默认**:加载本地 `127.0.0.1:3080`(已配置好的 web profile,零迁移)。
106
130
  - **可配远程**:在设置里填入任意远程 DSH 地址,壳只当窗口——手机 / Linux / 其他设备通过浏览器或 PWA 也能接入内核,壳本身不绑定本地服务。
131
+ - **同一套壳代码,两种分发形态**:安装包版(独立 exe)与插件版(随 `dsh web` 启动)共用 `electron/` 壳代码,仅运行时来源与启动方式不同(见「安装」)。
107
132
 
108
- ### 平台矩阵
133
+ ## 平台矩阵
109
134
 
110
135
  | 平台 | 壳 | 状态 |
111
136
  |:--|:--|:--|
@@ -114,25 +139,6 @@ Unblock-File -Path "$env:USERPROFILE\Downloads\DSH-Clean-Desktop-Shell-Setup-*.e
114
139
  | Linux | —(浏览器 / PWA 直连内核) | 不做 |
115
140
  | Termux / 手机 / 平板 | —(headless / PWA 直连内核) | 由内核远程访问支持 |
116
141
 
117
- ## 使用
118
-
119
- 1. 启动 `dsh web`(或配置远程服务地址)。
120
- 2. 启动桌面壳:自动检测本地 3080——后端在跑则直接加载,未运行则显示「后端未连接」页,可在离线页一键启动。
121
- 3. 窗口顶部(右侧留系统按钮)可拖动窗口;关闭默认缩到托盘。
122
-
123
- **后端的一切操作都在托盘右键**,主窗口保持纯壳:
124
-
125
- - 启动 / 重启 / 关闭后端(带进度弹窗;关闭会真正停掉 3080 上的服务,包括外部启动的实例)
126
- - 自动探测后端 · 设置后端安装文件夹(默认自动探测定位)
127
- - 刷新窗口 · 检查更新 · 仓库主页
128
-
129
- **窗口的可靠性(Edge 式即时刷新)**:
130
-
131
- - 双击启动立即出窗,不等后端就绪
132
- - 后端没起来时显示「后端未连接」页,自动探测;后端一通立即加载
133
- - 后端关闭 / 被杀的一刻,窗口立刻切回离线页——不会停在旧页面假装还活着
134
- - 离线页内置快捷按钮:重新加载 / 启动后端 / 自动探测后端 / 设置后端安装文件夹
135
-
136
142
  ## 开发
137
143
 
138
144
  ```sh
@@ -10,11 +10,27 @@
10
10
  * this project does not have yet).
11
11
  */
12
12
  import { app, shell, dialog } from 'electron'
13
+ import { readFileSync } from 'node:fs'
14
+ import { dirname, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
13
16
  import { showProgress, setProgress, closeProgress } from './progress.js'
17
+ import { loadConfig } from './config.js'
14
18
 
15
19
  const REPO_URL = 'https://github.com/Icather/dsh-clean-desktop-shell'
16
20
  const RELEASES_API = 'https://api.github.com/repos/Icather/dsh-clean-desktop-shell/releases/latest'
17
21
 
22
+ // Plugin (bare-runtime) mode has no app bundle, so app.getVersion() returns
23
+ // the Electron runtime version (e.g. 33.4.11). Read the plugin's own version
24
+ // from its package.json instead.
25
+ const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
26
+ const PKG_VERSION = (() => {
27
+ try {
28
+ return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version
29
+ } catch {
30
+ return null
31
+ }
32
+ })()
33
+
18
34
  let autoUpdater = null
19
35
  let updaterPromise = null
20
36
 
@@ -111,17 +127,20 @@ export async function checkForUpdatesAuto() {
111
127
 
112
128
  // Manual path (macOS / dev mode): compare versions, offer GitHub page.
113
129
  const r = await checkForUpdate()
130
+ const isPlugin = !app.isPackaged
114
131
  if (r.hasUpdate) {
115
132
  const choice = dialog.showMessageBoxSync({
116
133
  type: 'info',
117
134
  title: '发现新版本',
118
135
  message: `当前版本 ${r.current},最新版本 ${r.latest}。`,
119
- detail: 'macOS 自动更新需要代码签名,当前请前往 GitHub Releases 手动下载。',
120
- buttons: ['前往下载', '取消'],
136
+ detail: isPlugin
137
+ ? '插件形态请到 DSH 网页的「设置 → 插件市场 → 已安装」里点「更新」,完成后按提示重启即可生效。'
138
+ : 'macOS 自动更新需要代码签名,当前请前往 GitHub Releases 手动下载。',
139
+ buttons: isPlugin ? ['打开 DSH 网页', '稍后'] : ['前往下载', '取消'],
121
140
  defaultId: 0,
122
141
  cancelId: 1,
123
142
  })
124
- if (choice === 0) openUrl(r.url)
143
+ if (choice === 0) openUrl(isPlugin ? loadConfig().targetUrl : r.url)
125
144
  } else if (r.latest) {
126
145
  dialog.showMessageBoxSync({
127
146
  type: 'info',
@@ -144,6 +163,15 @@ function parseVersion(v) {
144
163
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
145
164
  }
146
165
 
166
+ /**
167
+ * Current app version. Packaged builds carry it in the exe; a bare runtime
168
+ * (plugin mode) must read the plugin package.json — app.getVersion() would
169
+ * report the Electron runtime version there.
170
+ */
171
+ function currentVersion() {
172
+ return app.isPackaged ? app.getVersion() : PKG_VERSION || app.getVersion()
173
+ }
174
+
147
175
  /** True when a is strictly newer than b. */
148
176
  function isNewer(a, b) {
149
177
  if (!a || !b) return false
@@ -158,7 +186,7 @@ function isNewer(a, b) {
158
186
  * @returns {{ hasUpdate: boolean, latest?: string, current: string, url: string }}
159
187
  */
160
188
  export async function checkForUpdate() {
161
- const current = app.getVersion()
189
+ const current = currentVersion()
162
190
  let latest = null
163
191
  let tag = null
164
192
  let url = REPO_URL
package/lib/common.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shared constants and helpers for the host half modules.
3
+ *
4
+ * These compiled files sit at lib/<name>.js, so two dirname hops reach the
5
+ * package root — the same layout as src/host/ before build, and the same
6
+ * location electron/ and build/ live in the published package.
7
+ */
8
+ import { homedir } from 'node:os'
9
+ import { dirname, join } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ export const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
13
+ export const MAIN_JS = join(PKG_ROOT, 'electron', 'main.js')
14
+ export const isWin = process.platform === 'win32'
15
+ export const EXE_NAME = isWin ? 'electron.exe' : 'electron'
16
+ export const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
17
+ export const PLATFORM = isWin ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'
18
+
19
+ /** Where the self-provisioned runtimes live (shared with icon.js). */
20
+ export function runtimeRoot() {
21
+ return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'desktop-shell-runtime')
22
+ }
package/lib/icon.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Patch the runtime electron.exe's icon resource so the Windows taskbar
3
+ * shows our whale icon.
4
+ *
5
+ * A bare runtime exe ships Electron's default icon and — as documented —
6
+ * no runtime API (BrowserWindow icon, setAppDetails, AUMID shortcuts) can
7
+ * change the taskbar button: it reads the exe's icon resource. rcedit
8
+ * (electron team's official tool) rewrites it in place.
9
+ *
10
+ * Best-effort: icon patching must never block the shell from launching.
11
+ * Idempotent: a marker file next to the exe records success; a re-provisioned
12
+ * (new version) exe has no marker and gets patched again.
13
+ */
14
+ import { spawn } from 'node:child_process'
15
+ import { existsSync, writeFileSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+ import { PKG_ROOT, isWin, runtimeRoot } from './common.js'
18
+
19
+ export async function patchExeIcon(ctx, exe) {
20
+ if (!isWin) return
21
+ const ico = join(PKG_ROOT, 'build', 'icon.ico')
22
+ if (!existsSync(ico)) return
23
+ const marker = `${exe}.whale-icon`
24
+ if (existsSync(marker)) return
25
+
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
+ }
36
+
37
+ const child = spawn(rcedit, [exe, '--set-icon', ico], {
38
+ windowsHide: true,
39
+ stdio: 'ignore',
40
+ })
41
+ const ok = await new Promise((resolve) => {
42
+ child.on('error', () => resolve(false))
43
+ child.on('exit', (code) => resolve(code === 0))
44
+ })
45
+ if (ok) {
46
+ writeFileSync(marker, String(Date.now()), 'utf8')
47
+ ctx.logger.info('[clean-desktop-shell] taskbar icon patched (rcedit)')
48
+ } else {
49
+ ctx.logger.warn('[clean-desktop-shell] rcedit patch failed — taskbar icon stays default')
50
+ }
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/index.js CHANGED
@@ -4,44 +4,14 @@
4
4
  * Branch 2 (plugin-market distribution): when installed through the DSH
5
5
  * plugin market, this host half brings up the Electron shell itself.
6
6
  *
7
- * The electron runtime is NOT an npm dependency (electron-builder forbids
8
- * electron in "dependencies", and pnpm's allowBuilds would block its
9
- * postinstall anyway). Instead the host half manages the runtime on its
10
- * own, under $DSH_HOME/desktop-shell-runtime/:
11
- *
12
- * 1. resolve the version to run (package.json → desktopShell.electronVersion)
13
- * 2. if that version dir exists → reuse it
14
- * 3. otherwise download the electron zip from the best source for the
15
- * network (official GitHub releases vs npmmirror mirror), extract it,
16
- * and drop any older version dirs (no unbounded disk growth)
17
- * 4. spawn the shell (runtime electron + electron/main.js) — the same
18
- * code branch 1 (installer) ships
19
- *
20
- * Window, tray, backend management etc. are identical to branch 1; only
21
- * the runtime provisioning differs.
7
+ * The shell code is shared with branch 1 (installer) — only the runtime
8
+ * provisioning and launch differ. See runtime.js (provisioning) and
9
+ * icon.js (Windows taskbar icon).
22
10
  */
23
11
  import { spawn } from 'node:child_process'
24
- import {
25
- cpSync,
26
- existsSync,
27
- mkdirSync,
28
- readFileSync,
29
- readdirSync,
30
- renameSync,
31
- rmSync,
32
- symlinkSync,
33
- writeFileSync,
34
- } from 'node:fs'
35
- import { homedir } from 'node:os'
36
- import { dirname, join } from 'node:path'
37
- import { fileURLToPath } from 'node:url'
38
-
39
- const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
40
- const MAIN_JS = join(PKG_ROOT, 'electron', 'main.js')
41
- const isWin = process.platform === 'win32'
42
- const EXE_NAME = isWin ? 'electron.exe' : 'electron'
43
- const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
44
- const PLATFORM = isWin ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'
12
+ import { PKG_ROOT, MAIN_JS } from './common.js'
13
+ import { ensureRuntime } from './runtime.js'
14
+ import { patchExeIcon } from './icon.js'
45
15
 
46
16
  // cordis registers the plugin by this name — bundles without an explicit
47
17
  // `name` export are silently skipped by the dsh loader.
@@ -63,6 +33,7 @@ export function apply(ctx) {
63
33
  ;(async () => {
64
34
  try {
65
35
  const exe = await ensureRuntime(ctx)
36
+ await patchExeIcon(ctx, exe).catch(() => {})
66
37
  launchShell(exe, ctx)
67
38
  } catch (err) {
68
39
  ctx.logger.warn(`[clean-desktop-shell] shell launch failed: ${err?.message ?? err}`)
@@ -70,246 +41,6 @@ export function apply(ctx) {
70
41
  })()
71
42
  }
72
43
 
73
- // ---------- electron runtime provisioning ----------
74
-
75
- function electronVersion() {
76
- try {
77
- const meta = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
78
- return meta.desktopShell?.electronVersion || null
79
- } catch {
80
- return null
81
- }
82
- }
83
-
84
- function runtimeRoot() {
85
- return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'desktop-shell-runtime')
86
- }
87
-
88
- function versionDir(root, version) {
89
- return join(root, `electron-v${version}`)
90
- }
91
-
92
- function zipName(version) {
93
- return `electron-v${version}-${PLATFORM}-${ARCH}.zip`
94
- }
95
-
96
- async function ensureRuntime(ctx) {
97
- const version = electronVersion()
98
- if (!version) throw new Error('desktopShell.electronVersion missing in package.json')
99
- const root = runtimeRoot()
100
- const dir = versionDir(root, version)
101
- const exe = join(dir, EXE_NAME)
102
-
103
- // 1) Already provisioned for this version?
104
- if (existsSync(exe)) {
105
- cleanupOldVersions(root, dir)
106
- await patchExeIcon(ctx, exe, root).catch(() => {})
107
- return exe
108
- }
109
-
110
- // 2) Local reuse: DSH_SHELL_ELECTRON_DIR → link/copy its dist/ (fast).
111
- const localSrc = process.env.DSH_SHELL_ELECTRON_DIR
112
- if (localSrc) {
113
- const srcExe = join(localSrc, 'dist', EXE_NAME)
114
- if (existsSync(srcExe) && provisionLocalDist(localSrc, dir)) {
115
- ctx.logger.info(`[clean-desktop-shell] reused electron runtime from ${localSrc}`)
116
- }
117
- }
118
-
119
- // 3) Download + extract the official zip from a network-appropriate source.
120
- if (!existsSync(exe)) {
121
- mkdirSync(root, { recursive: true })
122
- await downloadRuntime(ctx, version, root, dir)
123
- }
124
-
125
- if (!existsSync(exe)) {
126
- throw new Error(
127
- 'electron runtime provisioning failed — check network, or point DSH_SHELL_ELECTRON_DIR at an electron package',
128
- )
129
- }
130
- cleanupOldVersions(root, dir)
131
- ctx.logger.info(`[clean-desktop-shell] electron runtime ${version} ready at ${dir}`)
132
- await patchExeIcon(ctx, exe, root).catch(() => {})
133
- return exe
134
- }
135
-
136
- /**
137
- * Patch the runtime electron.exe's icon resource so the Windows taskbar
138
- * shows our whale icon. A bare runtime exe ships Electron's default icon
139
- * and — as documented — no runtime API (BrowserWindow icon, setAppDetails,
140
- * AUMID shortcuts) can change the taskbar button: it reads the exe's icon
141
- * resource. rcedit (electron team's official tool) rewrites it in place.
142
- *
143
- * Best-effort: icon patching must never block the shell from launching.
144
- * Idempotent: a marker file next to the exe records success; a re-provisioned
145
- * (new version) exe has no marker and gets patched again.
146
- */
147
- async function patchExeIcon(ctx, exe, root) {
148
- if (!isWin) return
149
- const ico = join(PKG_ROOT, 'build', 'icon.ico')
150
- if (!existsSync(ico)) return
151
- const marker = `${exe}.whale-icon`
152
- if (existsSync(marker)) return
153
-
154
- // rcedit is a single self-contained exe, cached next to the runtimes.
155
- const rcedit = join(root, 'rcedit-x64.exe')
156
- if (!existsSync(rcedit)) {
157
- const url = 'https://github.com/electron/rcedit/releases/download/v2.0.0/rcedit-x64.exe'
158
- ctx.logger.info('[clean-desktop-shell] downloading rcedit for icon patching')
159
- if (!(await fetchFile(url, rcedit))) {
160
- ctx.logger.warn('[clean-desktop-shell] rcedit download failed — taskbar icon stays default')
161
- return
162
- }
163
- }
164
-
165
- const child = spawn(rcedit, [exe, '--set-icon', ico], {
166
- windowsHide: true,
167
- stdio: 'ignore',
168
- })
169
- const ok = await new Promise((resolve) => {
170
- child.on('error', () => resolve(false))
171
- child.on('exit', (code) => resolve(code === 0))
172
- })
173
- if (ok) {
174
- writeFileSync(marker, String(Date.now()), 'utf8')
175
- ctx.logger.info('[clean-desktop-shell] taskbar icon patched (rcedit)')
176
- } else {
177
- ctx.logger.warn('[clean-desktop-shell] rcedit patch failed — taskbar icon stays default')
178
- }
179
- }
180
-
181
- async function downloadRuntime(ctx, version, root, dir) {
182
- const tmpZip = join(root, `.electron-${version}.zip.tmp`)
183
- rmSync(tmpZip, { force: true })
184
- const urls = await runtimeUrls(version)
185
- for (const url of urls) {
186
- ctx.logger.info(`[clean-desktop-shell] downloading electron ${version} from ${url}`)
187
- if (await fetchFile(url, tmpZip)) {
188
- // Zip extracts to an inner dir named like the zip basename.
189
- const inner = join(root, zipName(version).replace(/\.zip$/, ''))
190
- try {
191
- await extractZip(tmpZip, root)
192
- if (existsSync(join(inner, EXE_NAME)) && inner !== dir) {
193
- rmSync(dir, { recursive: true, force: true })
194
- renameSync(inner, dir)
195
- }
196
- rmSync(tmpZip, { force: true })
197
- return
198
- } catch (err) {
199
- ctx.logger.warn(`[clean-desktop-shell] extract failed: ${err?.message ?? err}`)
200
- rmSync(inner, { recursive: true, force: true })
201
- }
202
- }
203
- // Failed download — drop the partial file so a later run starts clean.
204
- rmSync(tmpZip, { force: true })
205
- }
206
- throw new Error('electron download failed from all sources')
207
- }
208
-
209
- /**
210
- * Pick the download source by racing a HEAD probe against each candidate
211
- * (direct connection, 3s each). The fastest reachable source goes first —
212
- * this naturally prefers the domestic npmmirror mirror on CN networks,
213
- * the official GitHub source on international/well-proxied networks, and
214
- * never wastes a full download on a dead source.
215
- */
216
- async function runtimeUrls(version) {
217
- const candidates = [
218
- { name: 'github', url: `https://github.com/electron/electron/releases/download/v${version}/${zipName(version)}` },
219
- { name: 'npmmirror', url: `https://npmmirror.com/mirrors/electron/${version}/${zipName(version)}` },
220
- ]
221
- const results = await Promise.all(
222
- candidates.map(async (c) => {
223
- const t0 = Date.now()
224
- try {
225
- const ctrl = new AbortController()
226
- const timer = setTimeout(() => ctrl.abort(), 3000)
227
- const res = await fetch(c.url, { signal: ctrl.signal, method: 'HEAD' })
228
- clearTimeout(timer)
229
- if (res.status < 500) return { ...c, ms: Date.now() - t0 }
230
- } catch {
231
- // unreachable — drop
232
- }
233
- return null
234
- }),
235
- )
236
- const ok = results.filter(Boolean).sort((a, b) => a.ms - b.ms)
237
- if (ok.length === 0) {
238
- // Probes all failed (offline?) — still try both, mirror first (cheap).
239
- return [candidates[1].url, candidates[0].url]
240
- }
241
- const rest = candidates.map((c) => c.url).filter((u) => u !== ok[0].url)
242
- return [ok[0].url, ...rest]
243
- }
244
-
245
- function fetchFile(url, dest) {
246
- return new Promise((resolve) => {
247
- // curl is available on Windows 10+; streams to disk, honors proxy env.
248
- // --max-time keeps a stalled download from hanging forever (a proxy
249
- // stall previously left a half-written .zip.tmp and blocked the shell
250
- // launch); --retry 2 rides out transient failures.
251
- const child = spawn(
252
- 'curl',
253
- ['-L', '--fail', '--silent', '--show-error', '--retry', '2', '--max-time', '600', '-o', dest, url],
254
- { windowsHide: true, stdio: 'ignore' },
255
- )
256
- child.on('error', () => resolve(false))
257
- child.on('exit', (code) => resolve(code === 0))
258
- })
259
- }
260
-
261
- function extractZip(zipPath, dest) {
262
- // Windows ships bsdtar (tar.exe) which reads zip; fall back to
263
- // PowerShell Expand-Archive if needed.
264
- const child = spawn('tar', ['-xf', zipPath, '-C', dest], { windowsHide: true, stdio: 'ignore' })
265
- return new Promise((resolve, reject) => {
266
- child.on('error', reject)
267
- child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`tar exit ${code}`))))
268
- })
269
- }
270
-
271
- /** Remove version dirs older than the current one (dead weight). */
272
- function cleanupOldVersions(root, currentDir) {
273
- try {
274
- for (const entry of readdirSync(root)) {
275
- if (!entry.startsWith('electron-v')) continue
276
- const full = join(root, entry)
277
- if (full === currentDir) continue
278
- rmSync(full, { recursive: true, force: true })
279
- }
280
- } catch {
281
- // best-effort
282
- }
283
- }
284
-
285
- /**
286
- * Provision a local electron package's dist/ as the version dir itself,
287
- * so the layout matches a downloaded runtime: <dir>/electron(.exe) at the
288
- * version-dir root. Windows: junction (zero-copy, instant) — a 269MB
289
- * recursive cpSync can be killed by sandbox/AV on large trees, so only
290
- * fall back to a copy.
291
- */
292
- function provisionLocalDist(srcPkg, destDir) {
293
- if (isWin) {
294
- try {
295
- rmSync(destDir, { recursive: true, force: true })
296
- symlinkSync(join(srcPkg, 'dist'), destDir, 'junction')
297
- return true
298
- } catch {
299
- // fall through to a real copy
300
- }
301
- }
302
- try {
303
- rmSync(destDir, { recursive: true, force: true })
304
- cpSync(join(srcPkg, 'dist'), destDir, { recursive: true })
305
- return true
306
- } catch {
307
- return false
308
- }
309
- }
310
-
311
- // ---------- shell launch ----------
312
-
313
44
  function launchShell(exe, ctx) {
314
45
  if (launched) return
315
46
  const child = spawn(exe, [MAIN_JS], {