dsh-clean-desktop-shell 0.1.5 → 0.1.7

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.
@@ -0,0 +1,19 @@
1
+ # Contributors
2
+
3
+ This project welcomes contributions — especially around platforms that the
4
+ maintainer does not use daily.
5
+
6
+ ## Author
7
+
8
+ - **Icather** — Windows side, plugin architecture, backend lifecycle, CI setup.
9
+
10
+ ## Looking for help
11
+
12
+ - **macOS maintainer / co-developer**: validate the .dmg install flow,
13
+ test Apple Silicon + Intel builds, set up code signing + notarization,
14
+ and ideally add a launch-at-login menu item in the tray.
15
+ - **Linux maintainer**: validate the runtime provisioning and tray behavior
16
+ on major distributions.
17
+
18
+ If you open a PR or verified issue that moves macOS support forward, add your
19
+ name here.
package/README.en.md CHANGED
@@ -11,8 +11,11 @@ Does exactly one thing: wraps your already-configured DSH Web in a clean native
11
11
  [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS-0078D6?logo=windows&logoColor=white)](https://github.com/Icather/dsh-clean-desktop-shell)
12
12
  [![License](https://img.shields.io/badge/License-MIT-22c55e)](LICENSE)
13
13
  [![Release](https://img.shields.io/github/v/release/Icather/dsh-clean-desktop-shell?color=blue)](https://github.com/Icather/dsh-clean-desktop-shell/releases/latest)
14
- [![DSH](https://img.shields.io/badge/DeepSeek_Harness-rc.7-4D6BFE)](https://github.com/deepseek-ai/deepseek-harness)
14
+ [![DSH](https://img.shields.io/badge/DeepSeek_Harness-0.1.1--rc.2-4D6BFE)](https://github.com/deepseek-ai/deepseek-harness)
15
15
  [![Contributors](https://img.shields.io/github/contributors/Icather/dsh-clean-desktop-shell?color=blueviolet)](https://github.com/Icather/dsh-clean-desktop-shell/graphs/contributors)
16
+ [![npm downloads](https://img.shields.io/npm/dt/dsh-clean-desktop-shell?logo=npm&color=cb3837&label=npm%20downloads)](https://www.npmjs.com/package/dsh-clean-desktop-shell)
17
+ [![Installs](https://img.shields.io/github/downloads/Icather/dsh-clean-desktop-shell/total?logo=github&color=2ea043&label=installs)](https://github.com/Icather/dsh-clean-desktop-shell/releases)
18
+ [![Clones](https://img.shields.io/badge/clones-139%20%2F%2014d-8957E5?logo=github&label=clones)](https://github.com/Icather/dsh-clean-desktop-shell)
16
19
 
17
20
  </div>
18
21
 
@@ -27,7 +30,25 @@ Key differences from other desktop clients in the ecosystem:
27
30
  | **Form** | Standalone Electron app with its own profile | **DSH plugin** mounted into your existing profile |
28
31
  | **Profile** | New `desktop` profile, plugins/config must be reinstalled | **Reuses your web profile**, zero migration |
29
32
  | **Visual changes** | Custom title bar / frosted glass etc. | **None** — pure window shell |
30
- | **Upstream** | Pinned version | **Tracks rc.7** |
33
+ | **Upstream** | Pinned version | **Tracks 0.1.1-rc.2** |
34
+
35
+ ## Highlights
36
+
37
+ **① One-click launch — like double-clicking a normal desktop app**
38
+
39
+ No terminal, no commands. **Double-click the desktop shortcut and the DSH window opens instantly**, just like launching any normal app:
40
+
41
+ - The installer creates the desktop shortcut automatically; the plugin form asks on first run, plus a one-click "create desktop shortcut" in the tray
42
+ - Shows immediately on double-click — never waits for the backend
43
+ - Single instance: a second double-click just focuses the existing window
44
+
45
+ **② Live backend monitoring · quick manual start/stop**
46
+
47
+ The tray **shows the backend state in real time** (running / starting / stopped / error) with one-click controls:
48
+
49
+ - **Live monitoring**: the window keeps probing the backend; the moment it is killed, crashes or is stopped, the window flips to the offline screen — a stale page never fakes "still alive"
50
+ - **Auto-reconnect**: the instant the backend recovers, the window reloads the real page by itself
51
+ - **Quick start/stop**: one-click start / restart / stop from the tray (with progress dialogs); "stop backend" really shuts the service down on 3080, including externally started instances
31
52
 
32
53
  ## Usage
33
54
 
@@ -48,6 +69,29 @@ Key differences from other desktop clients in the ecosystem:
48
69
  - 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
70
  - The offline screen has self-service buttons: reload / start backend / auto-detect backend / set backend install folder
50
71
 
72
+ ## macOS status (v0.1.7 important note)
73
+
74
+ v0.1.7 fixes the plugin-mode bug where the shell could not locate `Electron.app`
75
+ on macOS, which caused the window to fail silently on Mac.
76
+
77
+ However, **the maintainer does not currently have a Mac** to verify the
78
+ following in person:
79
+
80
+ - **The .dmg is unsigned and un-notarized**: Apple requires a yearly Developer
81
+ Program membership ($99/yr) for code signing + notarization. The first time you
82
+ open the app from the .dmg, Gatekeeper will likely say the app is "damaged"
83
+ or "cannot be verified".
84
+ - Workaround: run `xattr -cr "/Applications/DSH Clean Desktop Shell.app"`,
85
+ then right-click the app and choose Open.
86
+ - Long-term fix: a Mac co-maintainer with an Apple Developer account can help
87
+ set up signed + notarized builds.
88
+ - Post-extract executable bits and quarantine extended attributes can only be
89
+ confirmed on real hardware.
90
+
91
+ If you have a Mac and want to co-maintain macOS support (test the .dmg, set up
92
+ signing, or add a launch-at-login tray item), PRs and verified issues are very
93
+ welcome. You will be added to [CONTRIBUTORS.md](./CONTRIBUTORS.md).
94
+
51
95
  ## Install
52
96
 
53
97
  **Option 1: download the installer from Releases (for a standalone desktop app)**
@@ -55,7 +99,13 @@ Key differences from other desktop clients in the ecosystem:
55
99
  - Windows: `DSH-Clean-Desktop-Shell-Setup-<version>.exe`
56
100
  - macOS: `DSH-Clean-Desktop-Shell-<version>.dmg` (Intel) or `-arm64.dmg` (Apple Silicon)
57
101
 
58
- The installer **creates a desktop shortcut automatically** and provides the full desktop experience (tray). The first time you run the Windows installer you may see a SmartScreen warning — **this is normal for unsigned programs, not a virus**, see "Windows SmartScreen warning" below.
102
+ The installer **creates a desktop shortcut automatically** and provides the full desktop experience (tray).
103
+
104
+ - **Windows**: the first time you run the installer you may see a SmartScreen
105
+ warning — **this is normal for unsigned programs, not a virus**, see
106
+ "Windows SmartScreen warning" below.
107
+ - **macOS**: the .dmg is unsigned / un-notarized and may trigger Gatekeeper.
108
+ See "macOS status" above.
59
109
 
60
110
  **Option 2: install as a DSH plugin (DSH ecosystem users)**
61
111
 
@@ -149,6 +199,14 @@ npm run pack # package NSIS (Win) / DMG (mac)
149
199
 
150
200
  ## Changelog
151
201
 
202
+ ### 0.1.6
203
+ - Fixed "check for updates" reporting the Electron runtime version in plugin mode.
204
+
205
+ ### 0.1.5
206
+ - Refactored the plugin host half into focused modules.
207
+ - Completed package.json metadata (repository / homepage / bugs); dropped a stale auto-launch field.
208
+ - README: usage before install, architecture reflects the two forms, platform matrix top-level.
209
+
152
210
  ### 0.1.4
153
211
  - Branch 2 (plugin-market distribution) is now live: `dsh plugin add` →
154
212
  restart `dsh web` → the desktop shell opens automatically. The Electron
package/README.md CHANGED
@@ -11,8 +11,11 @@
11
11
  [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS-0078D6?logo=windows&logoColor=white)](https://github.com/Icather/dsh-clean-desktop-shell)
12
12
  [![License](https://img.shields.io/badge/License-MIT-22c55e)](LICENSE)
13
13
  [![Release](https://img.shields.io/github/v/release/Icather/dsh-clean-desktop-shell?color=blue)](https://github.com/Icather/dsh-clean-desktop-shell/releases/latest)
14
- [![DSH](https://img.shields.io/badge/DeepSeek_Harness-rc.7-4D6BFE)](https://github.com/deepseek-ai/deepseek-harness)
14
+ [![DSH](https://img.shields.io/badge/DeepSeek_Harness-0.1.1--rc.2-4D6BFE)](https://github.com/deepseek-ai/deepseek-harness)
15
15
  [![Contributors](https://img.shields.io/github/contributors/Icather/dsh-clean-desktop-shell?color=blueviolet)](https://github.com/Icather/dsh-clean-desktop-shell/graphs/contributors)
16
+ [![npm downloads](https://img.shields.io/npm/dt/dsh-clean-desktop-shell?logo=npm&color=cb3837&label=npm%20downloads)](https://www.npmjs.com/package/dsh-clean-desktop-shell)
17
+ [![Installs](https://img.shields.io/github/downloads/Icather/dsh-clean-desktop-shell/total?logo=github&color=2ea043&label=installs)](https://github.com/Icather/dsh-clean-desktop-shell/releases)
18
+ [![Clones](https://img.shields.io/badge/clones-139%20%2F%2014d-8957E5?logo=github&label=clones)](https://github.com/Icather/dsh-clean-desktop-shell)
16
19
 
17
20
  </div>
18
21
 
@@ -27,7 +30,25 @@
27
30
  | **形态** | 独立 Electron 应用,自带独立 profile | **DSH 插件**,挂载进现有 profile |
28
31
  | **Profile** | 新建 desktop profile,插件/配置要重装 | **复用现有 web profile**,零迁移 |
29
32
  | **视觉改造** | 自绘标题栏 / 毛玻璃等 | **零改造**,纯净窗口壳 |
30
- | **跟随上游** | 固定版本 | **跟随 rc.7** |
33
+ | **跟随上游** | 固定版本 | **跟随 0.1.1-rc.2** |
34
+
35
+ ## 核心亮点
36
+
37
+ **① 像双击桌面应用一样,一键启动 DSH**
38
+
39
+ 不用开终端、不用记命令。**双击桌面快捷方式,DSH 窗口立刻弹出**,和启动任何一个普通软件一样自然:
40
+
41
+ - 安装包自动创建桌面快捷方式;插件形态首次运行询问 + 托盘「创建桌面快捷方式」一键补建
42
+ - 双击即出窗——窗口不等后端、不做启动等待
43
+ - 单实例:重复双击只聚焦已有窗口,绝不重复开壳
44
+
45
+ **② 后端活性实时监测 · 快捷手动自主启停**
46
+
47
+ 托盘**实时显示后端状态**(运行中 / 启动中 / 未运行 / 错误),一键启停:
48
+
49
+ - **活性监测**:窗口持续探测后端;后端一旦被杀、崩溃或手动关闭,窗口立刻切到离线页,绝不停在旧页面假装还活着
50
+ - **自动重连**:后端恢复的一刻,窗口自动加载回真实页面,无需手动刷新
51
+ - **快捷启停**:托盘右键一键启动 / 重启 / 关闭后端(带进度弹窗);「关闭后端」真正停掉 3080 端口上的服务,含外部启动的实例
31
52
 
32
53
  ## 使用
33
54
 
@@ -48,6 +69,19 @@
48
69
  - 后端关闭 / 被杀的一刻,窗口立刻切回离线页——不会停在旧页面假装还活着
49
70
  - 离线页内置快捷按钮:重新加载 / 启动后端 / 自动探测后端 / 设置后端安装文件夹
50
71
 
72
+ ## macOS 状态(v0.1.7 重要说明)
73
+
74
+ v0.1.7 修复了插件形态在 macOS 上无法定位 `Electron.app` 路径的问题(该 bug 导致窗口在 Mac 上完全静默失败)。
75
+
76
+ 但**当前开发者没有 Mac 实机**,以下事项仍然依赖 Mac 用户验证/贡献:
77
+
78
+ - **.dmg 安装包未签名、未公证**:Apple 要求年度开发者计划($99/年)才能给安装包签名+公证。首次打开 .dmg 里的应用,很可能提示「已损坏,无法打开」或「无法验证开发者」。这不是应用本身损坏,是 Gatekeeper 拦截了未签名应用。
79
+ - 临时解决:`xattr -cr "/Applications/DSH Clean Desktop Shell.app"`,然后右键 → 打开。
80
+ - 长期解决:需要一位有 Apple Developer 账号的 Mac 合作者协助签名/公证,或长期把 .dmg 安装体验写为「需要右键打开 / 执行 xattr」。
81
+ - **Electron.app 解压后的可执行位、quarantine 扩展属性等**只有真机能确认行为是否完全正确。
82
+
83
+ 诚挚邀请有 Mac 环境、愿意一起打磨的同学参与:能帮忙验证安装流程、补充签名配置、或者把开机自启/登录项做进 Electron 托盘,欢迎直接提 PR 或在 Issue 里 @ 我,我会把你加入 [CONTRIBUTORS.md](./CONTRIBUTORS.md)。
84
+
51
85
  ## 安装
52
86
 
53
87
  **方式一:从 Release 下载安装包(想要独立桌面应用的用户)**
@@ -55,7 +89,12 @@
55
89
  - Windows:下载 `DSH-Clean-Desktop-Shell-Setup-<版本>.exe`
56
90
  - macOS:下载 `DSH-Clean-Desktop-Shell-<版本>.dmg`(Intel)或 `-arm64.dmg`(Apple Silicon)
57
91
 
58
- 安装包会**自动创建桌面快捷方式**,并提供系统托盘等完整桌面体验。首次运行 Windows 安装包可能触发 SmartScreen 警告——**这是未签名程序的正常现象,不是病毒**,见下方「Windows SmartScreen 警告说明」。
92
+ 安装包会**自动创建桌面快捷方式**,并提供系统托盘等完整桌面体验。
93
+
94
+ - **Windows**:首次运行安装包可能触发 SmartScreen 警告——**这是未签名程序的正常现象,不是病毒**,见下方「Windows SmartScreen 警告说明」。
95
+ - **macOS**:.dmg 未签名/未公证,首次打开可能触发 Gatekeeper。见上方「macOS 状态」。
96
+
97
+
59
98
 
60
99
  **方式二:作为 DSH 插件安装(DSH 生态用户)**
61
100
 
@@ -150,6 +189,14 @@ npm run pack # 打包 NSIS (Win) / DMG (mac)
150
189
 
151
190
  ## 更新历史
152
191
 
192
+ ### 0.1.6
193
+ - 修复插件形态下「检查更新」误报 Electron 运行时版本号的问题。
194
+
195
+ ### 0.1.5
196
+ - 插件 host 侧重构为聚焦模块。
197
+ - 补全 package.json 元数据(repository / homepage / bugs),清理遗留的开机自启字段。
198
+ - README 调整:使用前置、架构反映两种形态、平台矩阵置顶。
199
+
153
200
  ### 0.1.4
154
201
  - 分支二(插件市场分发)正式可用:`dsh plugin add` 装插件 → 重启 `dsh web` → 桌面壳自动弹出;Electron 运行时由插件自管理(本地复用 / 按网络环境自动选源下载)。
155
202
  - 桌面快捷方式:首次启动询问创建 + 托盘「创建桌面快捷方式」一键添加(安装包与插件两种形态均支持)。
Binary file
package/electron/tray.js CHANGED
@@ -25,8 +25,12 @@ import { shortcutSupported, createDesktopShortcut } from './shortcut.js'
25
25
  const trayIconPath = join(
26
26
  fileURLToPath(new URL('.', import.meta.url)),
27
27
  'assets',
28
- process.platform === 'win32' ? 'tray-16.png' : 'tray-32.png',
28
+ process.platform === 'win32' ? 'tray-16.png' : 'trayTemplate.png',
29
29
  )
30
+ // macOS: the filename must end in "Template" (e.g. trayTemplate.png) and
31
+ // Electron will automatically pick trayTemplate@2x.png on Retina.
32
+ // The image itself must be a black silhouette + alpha channel so it
33
+ // inverts correctly in both light and dark menu bars.
30
34
 
31
35
  let trayInstance = null
32
36
  let handlers = null
@@ -61,9 +65,11 @@ export function refreshTrayMenu() {
61
65
  label: '刷新窗口',
62
66
  click: onReload,
63
67
  },
68
+ // Windows-only: desktop .lnk shortcut. Hidden on other platforms instead of
69
+ // disabled so the menu stays relevant to the OS it is running on.
64
70
  {
65
71
  label: '创建桌面快捷方式',
66
- enabled: shortcutSupported(),
72
+ visible: shortcutSupported(),
67
73
  click: async () => {
68
74
  const ok = await createDesktopShortcut()
69
75
  if (ok) {
@@ -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 CHANGED
@@ -12,11 +12,42 @@ import { fileURLToPath } from 'node:url'
12
12
  export const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
13
13
  export const MAIN_JS = join(PKG_ROOT, 'electron', 'main.js')
14
14
  export const isWin = process.platform === 'win32'
15
- export const EXE_NAME = isWin ? 'electron.exe' : 'electron'
15
+ export const isMac = process.platform === 'darwin'
16
16
  export const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
17
- export const PLATFORM = isWin ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'
17
+ export const PLATFORM = isWin ? 'win32' : isMac ? 'darwin' : 'linux'
18
+
19
+ /**
20
+ * Electron binary path *relative to the extracted runtime dir*.
21
+ *
22
+ * The three upstream archives do not share a layout — only darwin ships an
23
+ * app bundle, and it is the one case with no top-level executable:
24
+ * win32 → electron.exe
25
+ * linux → electron
26
+ * darwin → Electron.app/Contents/MacOS/Electron
27
+ *
28
+ * Authoritative source: the `electron` package's own install.js, which writes
29
+ * exactly this relative path into path.txt for `require('electron')`.
30
+ */
31
+ export const EXE_RELPATH = isWin
32
+ ? 'electron.exe'
33
+ : isMac
34
+ ? join('Electron.app', 'Contents', 'MacOS', 'Electron')
35
+ : 'electron'
36
+
37
+ /** First path segment of EXE_RELPATH — what a successful extract must leave behind. */
38
+ export const EXE_TOP = isWin ? 'electron.exe' : isMac ? 'Electron.app' : 'electron'
39
+
40
+ /** DSH home, honouring DSH_HOME the same way dsh-home-paths does. */
41
+ export function dshHome() {
42
+ return process.env.DSH_HOME || join(homedir(), '.dsh')
43
+ }
18
44
 
19
45
  /** Where the self-provisioned runtimes live (shared with icon.js). */
20
46
  export function runtimeRoot() {
21
- return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'desktop-shell-runtime')
47
+ return join(dshHome(), 'desktop-shell-runtime')
48
+ }
49
+
50
+ /** Launch diagnostics land here — the only thing a headless user can send us. */
51
+ export function launchLogPath() {
52
+ return join(dshHome(), 'desktop-shell-launch.log')
22
53
  }
package/lib/index.js CHANGED
@@ -9,7 +9,8 @@
9
9
  * icon.js (Windows taskbar icon).
10
10
  */
11
11
  import { spawn } from 'node:child_process'
12
- import { PKG_ROOT, MAIN_JS } from './common.js'
12
+ import { appendFileSync } from 'node:fs'
13
+ import { PKG_ROOT, MAIN_JS, dshHome, runtimeRoot, launchLogPath } from './common.js'
13
14
  import { ensureRuntime } from './runtime.js'
14
15
  import { patchExeIcon } from './icon.js'
15
16
 
@@ -36,11 +37,50 @@ export function apply(ctx) {
36
37
  await patchExeIcon(ctx, exe).catch(() => {})
37
38
  launchShell(exe, ctx)
38
39
  } catch (err) {
39
- ctx.logger.warn(`[clean-desktop-shell] shell launch failed: ${err?.message ?? err}`)
40
+ reportLaunchFailure(ctx, err)
40
41
  }
41
42
  })()
42
43
  }
43
44
 
45
+ /**
46
+ * A provisioning failure used to vanish into ctx.logger — invisible to
47
+ * anyone who is not already tailing the DSH log. That is precisely how the
48
+ * macOS launch bug survived several releases: there was no window, no error
49
+ * dialog, and nothing on disk to send back.
50
+ *
51
+ * Write a diagnostics file next to the runtime and name it in the warning,
52
+ * so a user on an untested platform can hand us something actionable.
53
+ */
54
+ function reportLaunchFailure(ctx, err) {
55
+ const message = err?.message ?? String(err)
56
+ ctx.logger.warn(`[clean-desktop-shell] shell launch failed: ${message}`)
57
+
58
+ const logPath = launchLogPath()
59
+ const body = [
60
+ `time: ${new Date().toISOString()}`,
61
+ `platform: ${process.platform} (${process.arch})`,
62
+ `node: ${process.version}`,
63
+ `dsh home: ${dshHome()}`,
64
+ `runtime: ${runtimeRoot()}`,
65
+ `entry: ${MAIN_JS}`,
66
+ `error: ${message}`,
67
+ '',
68
+ 'Things worth checking:',
69
+ ' - first launch downloads the Electron runtime; a blocked network fails here',
70
+ ' - set DSH_SHELL_ELECTRON_DIR to an electron package to skip the download',
71
+ ' - macOS binary: <runtime>/electron-v<ver>/Electron.app/Contents/MacOS/Electron',
72
+ ' - macOS: unsandboxed extractors may drop the executable bit (chmod +x)',
73
+ '',
74
+ ].join('\n')
75
+
76
+ try {
77
+ appendFileSync(logPath, body + '\n')
78
+ ctx.logger.warn(`[clean-desktop-shell] diagnostics written to ${logPath}`)
79
+ } catch {
80
+ // Nothing else a headless host process can do.
81
+ }
82
+ }
83
+
44
84
  function launchShell(exe, ctx) {
45
85
  if (launched) return
46
86
  const child = spawn(exe, [MAIN_JS], {
package/lib/runtime.js CHANGED
@@ -23,8 +23,17 @@ import {
23
23
  rmSync,
24
24
  symlinkSync,
25
25
  } from 'node:fs'
26
- import { join } from 'node:path'
27
- import { PKG_ROOT, EXE_NAME, ARCH, PLATFORM, runtimeRoot } from './common.js'
26
+ import { basename, join } from 'node:path'
27
+ import {
28
+ PKG_ROOT,
29
+ EXE_RELPATH,
30
+ EXE_TOP,
31
+ ARCH,
32
+ PLATFORM,
33
+ isWin,
34
+ isMac,
35
+ runtimeRoot,
36
+ } from './common.js'
28
37
 
29
38
  function electronVersion() {
30
39
  try {
@@ -49,18 +58,19 @@ export async function ensureRuntime(ctx) {
49
58
  if (!version) throw new Error('desktopShell.electronVersion missing in package.json')
50
59
  const root = runtimeRoot()
51
60
  const dir = versionDir(root, version)
52
- const exe = join(dir, EXE_NAME)
61
+ const exe = join(dir, EXE_RELPATH)
53
62
 
54
63
  // 1) Already provisioned for this version?
55
64
  if (existsSync(exe)) {
56
65
  cleanupOldVersions(root, dir)
66
+ if (!isWin) await ensureExecutable(ctx, exe)
57
67
  return exe
58
68
  }
59
69
 
60
70
  // 2) Local reuse: DSH_SHELL_ELECTRON_DIR → link/copy its dist/ (fast).
61
71
  const localSrc = process.env.DSH_SHELL_ELECTRON_DIR
62
72
  if (localSrc) {
63
- const srcExe = join(localSrc, 'dist', EXE_NAME)
73
+ const srcExe = join(localSrc, 'dist', EXE_RELPATH)
64
74
  if (existsSync(srcExe) && provisionLocalDist(localSrc, dir)) {
65
75
  ctx.logger.info(`[clean-desktop-shell] reused electron runtime from ${localSrc}`)
66
76
  }
@@ -73,43 +83,130 @@ export async function ensureRuntime(ctx) {
73
83
  }
74
84
 
75
85
  if (!existsSync(exe)) {
86
+ // Name the exact path we expected. Without it a user on an untested
87
+ // platform has nothing to report back (this failure used to be silent).
76
88
  throw new Error(
77
- 'electron runtime provisioning failed — check network, or point DSH_SHELL_ELECTRON_DIR at an electron package',
89
+ `electron runtime provisioning failed — expected binary at ${exe} ` +
90
+ `(platform=${PLATFORM} arch=${ARCH}, layout differs per platform); ` +
91
+ 'check network, or point DSH_SHELL_ELECTRON_DIR at an electron package',
78
92
  )
79
93
  }
80
94
  cleanupOldVersions(root, dir)
95
+ if (!isWin) await ensureExecutable(ctx, exe)
81
96
  ctx.logger.info(`[clean-desktop-shell] electron runtime ${version} ready at ${dir}`)
82
97
  return exe
83
98
  }
84
99
 
100
+ /**
101
+ * Zip extraction does not reliably restore the executable bit — bsdtar
102
+ * (Windows tar.exe, macOS /usr/bin/tar) in particular drops it — and a
103
+ * non-executable binary fails later with a bare EACCES on spawn. Guarantee
104
+ * it instead of trusting the extractor.
105
+ */
106
+ function ensureExecutable(ctx, target) {
107
+ return new Promise((resolve) => {
108
+ let child
109
+ try {
110
+ child = spawn('chmod', ['+x', target], { stdio: 'ignore' })
111
+ } catch {
112
+ return resolve(false)
113
+ }
114
+ child.on('error', () => resolve(false))
115
+ child.on('exit', (code) => {
116
+ if (code !== 0) {
117
+ ctx.logger.warn(`[clean-desktop-shell] chmod +x failed (exit ${code}) on ${target}`)
118
+ }
119
+ resolve(code === 0)
120
+ })
121
+ })
122
+ }
123
+
85
124
  async function downloadRuntime(ctx, version, root, dir) {
86
125
  const tmpZip = join(root, `.electron-${version}.zip.tmp`)
87
126
  rmSync(tmpZip, { force: true })
88
127
  const urls = await runtimeUrls(version)
128
+ const innerName = zipName(version).replace(/\.zip$/, '')
129
+
89
130
  for (const url of urls) {
90
131
  ctx.logger.info(`[clean-desktop-shell] downloading electron ${version} from ${url}`)
91
- if (await fetchFile(url, tmpZip)) {
92
- // Zip extracts to an inner dir named like the zip basename.
93
- const inner = join(root, zipName(version).replace(/\.zip$/, ''))
94
- try {
95
- await extractZip(tmpZip, root)
96
- if (existsSync(join(inner, EXE_NAME)) && inner !== dir) {
97
- rmSync(dir, { recursive: true, force: true })
98
- renameSync(inner, dir)
99
- }
100
- rmSync(tmpZip, { force: true })
101
- return
102
- } catch (err) {
103
- ctx.logger.warn(`[clean-desktop-shell] extract failed: ${err?.message ?? err}`)
104
- rmSync(inner, { recursive: true, force: true })
105
- }
132
+ if (!(await fetchFile(url, tmpZip))) {
133
+ // Failed download drop the partial file so a later run starts clean.
134
+ rmSync(tmpZip, { force: true })
135
+ continue
106
136
  }
107
- // Failed download — drop the partial file so a later run starts clean.
137
+
138
+ // Extract into a scratch dir, never into the shared runtime root: a
139
+ // half-extracted zip there would be indistinguishable from a real
140
+ // runtime, and the sibling electron-v* dirs must not be disturbed.
141
+ const scratch = join(root, `.extract-${version}-${Date.now()}`)
142
+ const ok = await extractZip(ctx, tmpZip, scratch)
108
143
  rmSync(tmpZip, { force: true })
144
+ if (!ok) {
145
+ ctx.logger.warn(`[clean-desktop-shell] no extractor succeeded for ${url}`)
146
+ rmSync(scratch, { recursive: true, force: true })
147
+ continue
148
+ }
149
+
150
+ // Both archive layouts exist in the wild: some Electron zips wrap
151
+ // everything in a dir named after the zip, others (notably darwin, whose
152
+ // payload is Electron.app/) unpack flat into the destination. Detect
153
+ // rather than assume — the old code assumed the wrapper and silently
154
+ // produced nothing on macOS.
155
+ const payload = findPayload(scratch, join(scratch, innerName))
156
+ if (!payload) {
157
+ ctx.logger.warn(
158
+ `[clean-desktop-shell] unexpected archive layout: no ${EXE_TOP} under ${scratch}`,
159
+ )
160
+ rmSync(scratch, { recursive: true, force: true })
161
+ continue
162
+ }
163
+
164
+ movePayloadInto(dir, payload)
165
+ rmSync(scratch, { recursive: true, force: true })
166
+ if (isMac) await clearQuarantine(ctx, join(dir, EXE_TOP))
167
+ return
109
168
  }
110
169
  throw new Error('electron download failed from all sources')
111
170
  }
112
171
 
172
+ /** The dir that directly holds the electron payload right after extraction. */
173
+ function findPayload(scratch, wrapper) {
174
+ if (existsSync(join(wrapper, EXE_TOP))) return wrapper
175
+ if (existsSync(join(scratch, EXE_TOP))) return scratch
176
+ return null
177
+ }
178
+
179
+ function movePayloadInto(dir, payload) {
180
+ rmSync(dir, { recursive: true, force: true })
181
+ renameSync(payload, dir)
182
+ }
183
+
184
+ /**
185
+ * macOS: a bundle carrying com.apple.quarantine is refused by Gatekeeper with
186
+ * the infamous "已损坏,无法打开" dialog. We download with curl (which does
187
+ * not set the attribute), but archive members themselves can carry it, so
188
+ * clear it once right after extraction rather than debugging it per user.
189
+ */
190
+ function clearQuarantine(ctx, target) {
191
+ return new Promise((resolve) => {
192
+ let child
193
+ try {
194
+ child = spawn('xattr', ['-cr', target], { windowsHide: true, stdio: 'ignore' })
195
+ } catch {
196
+ return resolve(false)
197
+ }
198
+ child.on('error', () => resolve(false))
199
+ child.on('exit', (code) => {
200
+ ctx.logger.info(
201
+ code === 0
202
+ ? `[clean-desktop-shell] cleared extended attributes on ${target}`
203
+ : `[clean-desktop-shell] xattr cleanup skipped (exit ${code})`,
204
+ )
205
+ resolve(code === 0)
206
+ })
207
+ })
208
+ }
209
+
113
210
  /**
114
211
  * Pick the download source by racing a HEAD probe against each candidate
115
212
  * (direct connection, 3s each). The fastest reachable source goes first —
@@ -162,21 +259,81 @@ function fetchFile(url, dest) {
162
259
  })
163
260
  }
164
261
 
165
- function extractZip(zipPath, dest) {
166
- // Windows ships bsdtar (tar.exe) which reads zip; fall back to
167
- // PowerShell Expand-Archive if needed.
168
- const child = spawn('tar', ['-xf', zipPath, '-C', dest], { windowsHide: true, stdio: 'ignore' })
169
- return new Promise((resolve, reject) => {
170
- child.on('error', reject)
171
- child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`tar exit ${code}`))))
262
+ /**
263
+ * Extract a zip using whatever the platform actually provides.
264
+ *
265
+ * The archives are ordinary zips, but the readers differ:
266
+ * - Windows: bsdtar ships as tar.exe and reads zip; Expand-Archive is the
267
+ * fallback for hardened images where tar is unavailable.
268
+ * - macOS: ditto -xk is Apple's own extractor and preserves the symlinks
269
+ * inside Electron.app; unzip next; bsdtar last.
270
+ * - Linux: unzip, then tar.
271
+ *
272
+ * Success is decided by the caller (findPayload), not by the exit code — a
273
+ * zip can unpack "successfully" into a layout nobody expects.
274
+ */
275
+ function extractZip(ctx, zipPath, dest) {
276
+ const strategies = isWin
277
+ ? [
278
+ { cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
279
+ {
280
+ cmd: 'powershell',
281
+ args: [
282
+ '-NoProfile',
283
+ '-NonInteractive',
284
+ '-Command',
285
+ `Expand-Archive -LiteralPath '${zipPath.replace(/'/g, "''")}' -DestinationPath '${dest.replace(/'/g, "''")}' -Force`,
286
+ ],
287
+ },
288
+ ]
289
+ : isMac
290
+ ? [
291
+ { cmd: 'ditto', args: ['-xk', zipPath, dest] },
292
+ { cmd: 'unzip', args: ['-q', '-o', zipPath, '-d', dest] },
293
+ { cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
294
+ ]
295
+ : [
296
+ { cmd: 'unzip', args: ['-q', '-o', zipPath, '-d', dest] },
297
+ { cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
298
+ ]
299
+
300
+ return (async () => {
301
+ mkdirSync(dest, { recursive: true })
302
+ for (const s of strategies) {
303
+ const ok = await runExtractor(s.cmd, s.args)
304
+ if (ok) {
305
+ ctx.logger.info(`[clean-desktop-shell] extracted with ${s.cmd}`)
306
+ return true
307
+ }
308
+ ctx.logger.warn(`[clean-desktop-shell] extractor unavailable or failed: ${s.cmd}`)
309
+ }
310
+ return false
311
+ })()
312
+ }
313
+
314
+ function runExtractor(cmd, args) {
315
+ return new Promise((resolve) => {
316
+ let child
317
+ try {
318
+ child = spawn(cmd, args, { windowsHide: true, stdio: 'ignore' })
319
+ } catch {
320
+ return resolve(false)
321
+ }
322
+ child.on('error', () => resolve(false))
323
+ child.on('exit', (code) => resolve(code === 0))
172
324
  })
173
325
  }
174
326
 
175
- /** Remove version dirs older than the current one (dead weight). */
327
+ /**
328
+ * Drop version dirs older than the current one (dead weight), plus any
329
+ * scratch dir a crashed extraction left behind.
330
+ */
176
331
  function cleanupOldVersions(root, currentDir) {
177
332
  try {
178
333
  for (const entry of readdirSync(root)) {
179
- if (!entry.startsWith('electron-v')) continue
334
+ const isStaleRuntime = entry.startsWith('electron-v')
335
+ const isScratch = entry.startsWith('.extract-') || entry.startsWith('.electron-')
336
+ if (!isStaleRuntime && !isScratch) continue
180
337
  const full = join(root, entry)
181
338
  if (full === currentDir) continue
182
339
  rmSync(full, { recursive: true, force: true })
@@ -187,11 +344,15 @@ function cleanupOldVersions(root, currentDir) {
187
344
  }
188
345
 
189
346
  /**
190
- * Provision a local electron package's dist/ as the version dir itself,
191
- * so the layout matches a downloaded runtime: <dir>/electron(.exe) at the
192
- * version-dir root. Windows: junction (zero-copy, instant) a 269MB
193
- * recursive cpSync can be killed by sandbox/AV on large trees, so only
194
- * fall back to a copy.
347
+ * Provision a local electron package's dist/ as the version dir itself, so
348
+ * the layout matches a downloaded runtime: <dir>/<EXE_RELPATH> at the
349
+ * version-dir root (on macOS that is <dir>/Electron.app/Contents/MacOS/Electron).
350
+ *
351
+ * Windows: junction (zero-copy, instant) — a 269MB recursive cpSync can be
352
+ * killed by sandbox/AV on large trees, so only fall back to a copy.
353
+ * macOS/Linux: a symlink to dist/ would work too, but a bare .app reached
354
+ * through a symlink is a common source of Gatekeeper/entitlement surprises,
355
+ * so always copy there.
195
356
  */
196
357
  function provisionLocalDist(srcPkg, destDir) {
197
358
  if (process.platform === 'win32') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-clean-desktop-shell",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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",
@@ -22,6 +22,7 @@
22
22
  "cordis.patch.yml",
23
23
  "README.md",
24
24
  "README.en.md",
25
+ "CONTRIBUTORS.md",
25
26
  "LICENSE",
26
27
  "version.txt"
27
28
  ],
@@ -32,7 +33,7 @@
32
33
  },
33
34
  "scripts": {
34
35
  "build": "node scripts/build.mjs",
35
- "check": "node --check lib/index.js",
36
+ "check": "node --check lib/index.js && node scripts/selftest-runtime.mjs",
36
37
  "dev": "electron electron/main.js",
37
38
  "icons": "node scripts/gen-icons.mjs",
38
39
  "pack": "electron-builder --win nsis",
@@ -97,7 +98,8 @@
97
98
  "target": [
98
99
  "dmg"
99
100
  ],
100
- "category": "public.app-category.developer-tools"
101
+ "category": "public.app-category.developer-tools",
102
+ "icon": "build/icon.png"
101
103
  },
102
104
  "nsis": {
103
105
  "oneClick": true,
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Self-check for the Electron runtime path logic.
3
+ *
4
+ * Guards the macOS launch bug: Electron's three archives have three
5
+ * different layouts, and only darwin ships an app bundle with no top-level
6
+ * binary. This fakes each platform and each archive layout, then asserts
7
+ * join(versionDir, EXE_RELPATH) resolves to a real file — the exact
8
+ * condition ensureRuntime() checks before spawning the shell.
9
+ *
10
+ * Run: node scripts/selftest-runtime.mjs
11
+ */
12
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
13
+ import { dirname, join, sep } from 'node:path'
14
+ import { tmpdir } from 'node:os'
15
+ import { pathToFileURL } from 'node:url'
16
+
17
+ // Layouts as Electron actually ships them (authoritative mapping lives in
18
+ // the `electron` package's own install.js, which writes path.txt):
19
+ // win32 → <dir>/electron.exe
20
+ // linux → <dir>/electron
21
+ // darwin → <dir>/Electron.app/Contents/MacOS/Electron
22
+ const LAYOUTS = {
23
+ win32: ['electron.exe', 'resources/placeholder'],
24
+ linux: ['electron', 'resources/placeholder'],
25
+ darwin: [
26
+ 'Electron.app/Contents/MacOS/Electron',
27
+ 'Electron.app/Contents/Info.plist',
28
+ ],
29
+ }
30
+
31
+ let failures = 0
32
+
33
+ function check(label, cond) {
34
+ console.log(` ${cond ? 'ok ' : 'FAIL'} ${label}`)
35
+ if (!cond) failures++
36
+ }
37
+
38
+ /** Materialise an extracted-archive tree under dir. */
39
+ function buildLayout(dir, entries) {
40
+ for (const rel of entries) {
41
+ const full = join(dir, ...rel.split('/').map((s) => s.split('\\').join(sep)))
42
+ mkdirSync(dirname(full), { recursive: true })
43
+ writeFileSync(full, 'binary')
44
+ }
45
+ }
46
+
47
+ async function forPlatform(platform) {
48
+ Object.defineProperty(process, 'platform', { value: platform, configurable: true })
49
+ // Cache-bust so the module re-evaluates its platform-derived constants.
50
+ const { EXE_RELPATH, EXE_TOP } = await import(
51
+ `${pathToFileURL(join(process.cwd(), 'lib', 'common.js')).href}?p=${platform}`
52
+ )
53
+
54
+ const root = mkdtempSync(join(tmpdir(), `dsh-shell-${platform}-`))
55
+ const dir = join(root, 'electron-v33.4.11')
56
+ console.log(`\n[${platform}]`)
57
+ try {
58
+ buildLayout(dir, LAYOUTS[platform])
59
+ const exe = join(dir, EXE_RELPATH)
60
+
61
+ check(`EXE_TOP = ${EXE_TOP}`, typeof EXE_TOP === 'string' && EXE_TOP.length > 0)
62
+ check(`binary resolves: ${EXE_RELPATH}`, existsSync(exe))
63
+ check('resolved path lives inside the version dir', exe.startsWith(dir))
64
+ } finally {
65
+ rmSync(root, { recursive: true, force: true })
66
+ }
67
+ }
68
+
69
+ // Regression guard: prove the pre-fix constant was broken on darwin, so this
70
+ // test would actually have caught the bug rather than just passing forever.
71
+ async function regression() {
72
+ const root = mkdtempSync(join(tmpdir(), 'dsh-shell-legacy-'))
73
+ const dir = join(root, 'electron-v33.4.11')
74
+ console.log('\n[regression: pre-fix darwin behaviour]')
75
+ try {
76
+ buildLayout(dir, LAYOUTS.darwin)
77
+ const legacyExe = 'electron' // what EXE_NAME used to be on every non-Windows platform
78
+ check(`legacy join(dir, 'electron') is absent — the bug`, !existsSync(join(dir, legacyExe)))
79
+ } finally {
80
+ rmSync(root, { recursive: true, force: true })
81
+ }
82
+ }
83
+
84
+ await regression()
85
+ for (const p of ['win32', 'linux', 'darwin']) {
86
+ await forPlatform(p)
87
+ }
88
+
89
+ console.log(`\n${failures === 0 ? 'PASS' : `FAIL (${failures} check(s))`}`)
90
+ process.exit(failures === 0 ? 0 : 1)
@@ -12,11 +12,42 @@ import { fileURLToPath } from 'node:url'
12
12
  export const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
13
13
  export const MAIN_JS = join(PKG_ROOT, 'electron', 'main.js')
14
14
  export const isWin = process.platform === 'win32'
15
- export const EXE_NAME = isWin ? 'electron.exe' : 'electron'
15
+ export const isMac = process.platform === 'darwin'
16
16
  export const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
17
- export const PLATFORM = isWin ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'
17
+ export const PLATFORM = isWin ? 'win32' : isMac ? 'darwin' : 'linux'
18
+
19
+ /**
20
+ * Electron binary path *relative to the extracted runtime dir*.
21
+ *
22
+ * The three upstream archives do not share a layout — only darwin ships an
23
+ * app bundle, and it is the one case with no top-level executable:
24
+ * win32 → electron.exe
25
+ * linux → electron
26
+ * darwin → Electron.app/Contents/MacOS/Electron
27
+ *
28
+ * Authoritative source: the `electron` package's own install.js, which writes
29
+ * exactly this relative path into path.txt for `require('electron')`.
30
+ */
31
+ export const EXE_RELPATH = isWin
32
+ ? 'electron.exe'
33
+ : isMac
34
+ ? join('Electron.app', 'Contents', 'MacOS', 'Electron')
35
+ : 'electron'
36
+
37
+ /** First path segment of EXE_RELPATH — what a successful extract must leave behind. */
38
+ export const EXE_TOP = isWin ? 'electron.exe' : isMac ? 'Electron.app' : 'electron'
39
+
40
+ /** DSH home, honouring DSH_HOME the same way dsh-home-paths does. */
41
+ export function dshHome() {
42
+ return process.env.DSH_HOME || join(homedir(), '.dsh')
43
+ }
18
44
 
19
45
  /** Where the self-provisioned runtimes live (shared with icon.js). */
20
46
  export function runtimeRoot() {
21
- return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'desktop-shell-runtime')
47
+ return join(dshHome(), 'desktop-shell-runtime')
48
+ }
49
+
50
+ /** Launch diagnostics land here — the only thing a headless user can send us. */
51
+ export function launchLogPath() {
52
+ return join(dshHome(), 'desktop-shell-launch.log')
22
53
  }
package/src/host/index.js CHANGED
@@ -9,7 +9,8 @@
9
9
  * icon.js (Windows taskbar icon).
10
10
  */
11
11
  import { spawn } from 'node:child_process'
12
- import { PKG_ROOT, MAIN_JS } from './common.js'
12
+ import { appendFileSync } from 'node:fs'
13
+ import { PKG_ROOT, MAIN_JS, dshHome, runtimeRoot, launchLogPath } from './common.js'
13
14
  import { ensureRuntime } from './runtime.js'
14
15
  import { patchExeIcon } from './icon.js'
15
16
 
@@ -36,11 +37,50 @@ export function apply(ctx) {
36
37
  await patchExeIcon(ctx, exe).catch(() => {})
37
38
  launchShell(exe, ctx)
38
39
  } catch (err) {
39
- ctx.logger.warn(`[clean-desktop-shell] shell launch failed: ${err?.message ?? err}`)
40
+ reportLaunchFailure(ctx, err)
40
41
  }
41
42
  })()
42
43
  }
43
44
 
45
+ /**
46
+ * A provisioning failure used to vanish into ctx.logger — invisible to
47
+ * anyone who is not already tailing the DSH log. That is precisely how the
48
+ * macOS launch bug survived several releases: there was no window, no error
49
+ * dialog, and nothing on disk to send back.
50
+ *
51
+ * Write a diagnostics file next to the runtime and name it in the warning,
52
+ * so a user on an untested platform can hand us something actionable.
53
+ */
54
+ function reportLaunchFailure(ctx, err) {
55
+ const message = err?.message ?? String(err)
56
+ ctx.logger.warn(`[clean-desktop-shell] shell launch failed: ${message}`)
57
+
58
+ const logPath = launchLogPath()
59
+ const body = [
60
+ `time: ${new Date().toISOString()}`,
61
+ `platform: ${process.platform} (${process.arch})`,
62
+ `node: ${process.version}`,
63
+ `dsh home: ${dshHome()}`,
64
+ `runtime: ${runtimeRoot()}`,
65
+ `entry: ${MAIN_JS}`,
66
+ `error: ${message}`,
67
+ '',
68
+ 'Things worth checking:',
69
+ ' - first launch downloads the Electron runtime; a blocked network fails here',
70
+ ' - set DSH_SHELL_ELECTRON_DIR to an electron package to skip the download',
71
+ ' - macOS binary: <runtime>/electron-v<ver>/Electron.app/Contents/MacOS/Electron',
72
+ ' - macOS: unsandboxed extractors may drop the executable bit (chmod +x)',
73
+ '',
74
+ ].join('\n')
75
+
76
+ try {
77
+ appendFileSync(logPath, body + '\n')
78
+ ctx.logger.warn(`[clean-desktop-shell] diagnostics written to ${logPath}`)
79
+ } catch {
80
+ // Nothing else a headless host process can do.
81
+ }
82
+ }
83
+
44
84
  function launchShell(exe, ctx) {
45
85
  if (launched) return
46
86
  const child = spawn(exe, [MAIN_JS], {
@@ -23,8 +23,17 @@ import {
23
23
  rmSync,
24
24
  symlinkSync,
25
25
  } from 'node:fs'
26
- import { join } from 'node:path'
27
- import { PKG_ROOT, EXE_NAME, ARCH, PLATFORM, runtimeRoot } from './common.js'
26
+ import { basename, join } from 'node:path'
27
+ import {
28
+ PKG_ROOT,
29
+ EXE_RELPATH,
30
+ EXE_TOP,
31
+ ARCH,
32
+ PLATFORM,
33
+ isWin,
34
+ isMac,
35
+ runtimeRoot,
36
+ } from './common.js'
28
37
 
29
38
  function electronVersion() {
30
39
  try {
@@ -49,18 +58,19 @@ export async function ensureRuntime(ctx) {
49
58
  if (!version) throw new Error('desktopShell.electronVersion missing in package.json')
50
59
  const root = runtimeRoot()
51
60
  const dir = versionDir(root, version)
52
- const exe = join(dir, EXE_NAME)
61
+ const exe = join(dir, EXE_RELPATH)
53
62
 
54
63
  // 1) Already provisioned for this version?
55
64
  if (existsSync(exe)) {
56
65
  cleanupOldVersions(root, dir)
66
+ if (!isWin) await ensureExecutable(ctx, exe)
57
67
  return exe
58
68
  }
59
69
 
60
70
  // 2) Local reuse: DSH_SHELL_ELECTRON_DIR → link/copy its dist/ (fast).
61
71
  const localSrc = process.env.DSH_SHELL_ELECTRON_DIR
62
72
  if (localSrc) {
63
- const srcExe = join(localSrc, 'dist', EXE_NAME)
73
+ const srcExe = join(localSrc, 'dist', EXE_RELPATH)
64
74
  if (existsSync(srcExe) && provisionLocalDist(localSrc, dir)) {
65
75
  ctx.logger.info(`[clean-desktop-shell] reused electron runtime from ${localSrc}`)
66
76
  }
@@ -73,43 +83,130 @@ export async function ensureRuntime(ctx) {
73
83
  }
74
84
 
75
85
  if (!existsSync(exe)) {
86
+ // Name the exact path we expected. Without it a user on an untested
87
+ // platform has nothing to report back (this failure used to be silent).
76
88
  throw new Error(
77
- 'electron runtime provisioning failed — check network, or point DSH_SHELL_ELECTRON_DIR at an electron package',
89
+ `electron runtime provisioning failed — expected binary at ${exe} ` +
90
+ `(platform=${PLATFORM} arch=${ARCH}, layout differs per platform); ` +
91
+ 'check network, or point DSH_SHELL_ELECTRON_DIR at an electron package',
78
92
  )
79
93
  }
80
94
  cleanupOldVersions(root, dir)
95
+ if (!isWin) await ensureExecutable(ctx, exe)
81
96
  ctx.logger.info(`[clean-desktop-shell] electron runtime ${version} ready at ${dir}`)
82
97
  return exe
83
98
  }
84
99
 
100
+ /**
101
+ * Zip extraction does not reliably restore the executable bit — bsdtar
102
+ * (Windows tar.exe, macOS /usr/bin/tar) in particular drops it — and a
103
+ * non-executable binary fails later with a bare EACCES on spawn. Guarantee
104
+ * it instead of trusting the extractor.
105
+ */
106
+ function ensureExecutable(ctx, target) {
107
+ return new Promise((resolve) => {
108
+ let child
109
+ try {
110
+ child = spawn('chmod', ['+x', target], { stdio: 'ignore' })
111
+ } catch {
112
+ return resolve(false)
113
+ }
114
+ child.on('error', () => resolve(false))
115
+ child.on('exit', (code) => {
116
+ if (code !== 0) {
117
+ ctx.logger.warn(`[clean-desktop-shell] chmod +x failed (exit ${code}) on ${target}`)
118
+ }
119
+ resolve(code === 0)
120
+ })
121
+ })
122
+ }
123
+
85
124
  async function downloadRuntime(ctx, version, root, dir) {
86
125
  const tmpZip = join(root, `.electron-${version}.zip.tmp`)
87
126
  rmSync(tmpZip, { force: true })
88
127
  const urls = await runtimeUrls(version)
128
+ const innerName = zipName(version).replace(/\.zip$/, '')
129
+
89
130
  for (const url of urls) {
90
131
  ctx.logger.info(`[clean-desktop-shell] downloading electron ${version} from ${url}`)
91
- if (await fetchFile(url, tmpZip)) {
92
- // Zip extracts to an inner dir named like the zip basename.
93
- const inner = join(root, zipName(version).replace(/\.zip$/, ''))
94
- try {
95
- await extractZip(tmpZip, root)
96
- if (existsSync(join(inner, EXE_NAME)) && inner !== dir) {
97
- rmSync(dir, { recursive: true, force: true })
98
- renameSync(inner, dir)
99
- }
100
- rmSync(tmpZip, { force: true })
101
- return
102
- } catch (err) {
103
- ctx.logger.warn(`[clean-desktop-shell] extract failed: ${err?.message ?? err}`)
104
- rmSync(inner, { recursive: true, force: true })
105
- }
132
+ if (!(await fetchFile(url, tmpZip))) {
133
+ // Failed download drop the partial file so a later run starts clean.
134
+ rmSync(tmpZip, { force: true })
135
+ continue
106
136
  }
107
- // Failed download — drop the partial file so a later run starts clean.
137
+
138
+ // Extract into a scratch dir, never into the shared runtime root: a
139
+ // half-extracted zip there would be indistinguishable from a real
140
+ // runtime, and the sibling electron-v* dirs must not be disturbed.
141
+ const scratch = join(root, `.extract-${version}-${Date.now()}`)
142
+ const ok = await extractZip(ctx, tmpZip, scratch)
108
143
  rmSync(tmpZip, { force: true })
144
+ if (!ok) {
145
+ ctx.logger.warn(`[clean-desktop-shell] no extractor succeeded for ${url}`)
146
+ rmSync(scratch, { recursive: true, force: true })
147
+ continue
148
+ }
149
+
150
+ // Both archive layouts exist in the wild: some Electron zips wrap
151
+ // everything in a dir named after the zip, others (notably darwin, whose
152
+ // payload is Electron.app/) unpack flat into the destination. Detect
153
+ // rather than assume — the old code assumed the wrapper and silently
154
+ // produced nothing on macOS.
155
+ const payload = findPayload(scratch, join(scratch, innerName))
156
+ if (!payload) {
157
+ ctx.logger.warn(
158
+ `[clean-desktop-shell] unexpected archive layout: no ${EXE_TOP} under ${scratch}`,
159
+ )
160
+ rmSync(scratch, { recursive: true, force: true })
161
+ continue
162
+ }
163
+
164
+ movePayloadInto(dir, payload)
165
+ rmSync(scratch, { recursive: true, force: true })
166
+ if (isMac) await clearQuarantine(ctx, join(dir, EXE_TOP))
167
+ return
109
168
  }
110
169
  throw new Error('electron download failed from all sources')
111
170
  }
112
171
 
172
+ /** The dir that directly holds the electron payload right after extraction. */
173
+ function findPayload(scratch, wrapper) {
174
+ if (existsSync(join(wrapper, EXE_TOP))) return wrapper
175
+ if (existsSync(join(scratch, EXE_TOP))) return scratch
176
+ return null
177
+ }
178
+
179
+ function movePayloadInto(dir, payload) {
180
+ rmSync(dir, { recursive: true, force: true })
181
+ renameSync(payload, dir)
182
+ }
183
+
184
+ /**
185
+ * macOS: a bundle carrying com.apple.quarantine is refused by Gatekeeper with
186
+ * the infamous "已损坏,无法打开" dialog. We download with curl (which does
187
+ * not set the attribute), but archive members themselves can carry it, so
188
+ * clear it once right after extraction rather than debugging it per user.
189
+ */
190
+ function clearQuarantine(ctx, target) {
191
+ return new Promise((resolve) => {
192
+ let child
193
+ try {
194
+ child = spawn('xattr', ['-cr', target], { windowsHide: true, stdio: 'ignore' })
195
+ } catch {
196
+ return resolve(false)
197
+ }
198
+ child.on('error', () => resolve(false))
199
+ child.on('exit', (code) => {
200
+ ctx.logger.info(
201
+ code === 0
202
+ ? `[clean-desktop-shell] cleared extended attributes on ${target}`
203
+ : `[clean-desktop-shell] xattr cleanup skipped (exit ${code})`,
204
+ )
205
+ resolve(code === 0)
206
+ })
207
+ })
208
+ }
209
+
113
210
  /**
114
211
  * Pick the download source by racing a HEAD probe against each candidate
115
212
  * (direct connection, 3s each). The fastest reachable source goes first —
@@ -162,21 +259,81 @@ function fetchFile(url, dest) {
162
259
  })
163
260
  }
164
261
 
165
- function extractZip(zipPath, dest) {
166
- // Windows ships bsdtar (tar.exe) which reads zip; fall back to
167
- // PowerShell Expand-Archive if needed.
168
- const child = spawn('tar', ['-xf', zipPath, '-C', dest], { windowsHide: true, stdio: 'ignore' })
169
- return new Promise((resolve, reject) => {
170
- child.on('error', reject)
171
- child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`tar exit ${code}`))))
262
+ /**
263
+ * Extract a zip using whatever the platform actually provides.
264
+ *
265
+ * The archives are ordinary zips, but the readers differ:
266
+ * - Windows: bsdtar ships as tar.exe and reads zip; Expand-Archive is the
267
+ * fallback for hardened images where tar is unavailable.
268
+ * - macOS: ditto -xk is Apple's own extractor and preserves the symlinks
269
+ * inside Electron.app; unzip next; bsdtar last.
270
+ * - Linux: unzip, then tar.
271
+ *
272
+ * Success is decided by the caller (findPayload), not by the exit code — a
273
+ * zip can unpack "successfully" into a layout nobody expects.
274
+ */
275
+ function extractZip(ctx, zipPath, dest) {
276
+ const strategies = isWin
277
+ ? [
278
+ { cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
279
+ {
280
+ cmd: 'powershell',
281
+ args: [
282
+ '-NoProfile',
283
+ '-NonInteractive',
284
+ '-Command',
285
+ `Expand-Archive -LiteralPath '${zipPath.replace(/'/g, "''")}' -DestinationPath '${dest.replace(/'/g, "''")}' -Force`,
286
+ ],
287
+ },
288
+ ]
289
+ : isMac
290
+ ? [
291
+ { cmd: 'ditto', args: ['-xk', zipPath, dest] },
292
+ { cmd: 'unzip', args: ['-q', '-o', zipPath, '-d', dest] },
293
+ { cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
294
+ ]
295
+ : [
296
+ { cmd: 'unzip', args: ['-q', '-o', zipPath, '-d', dest] },
297
+ { cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
298
+ ]
299
+
300
+ return (async () => {
301
+ mkdirSync(dest, { recursive: true })
302
+ for (const s of strategies) {
303
+ const ok = await runExtractor(s.cmd, s.args)
304
+ if (ok) {
305
+ ctx.logger.info(`[clean-desktop-shell] extracted with ${s.cmd}`)
306
+ return true
307
+ }
308
+ ctx.logger.warn(`[clean-desktop-shell] extractor unavailable or failed: ${s.cmd}`)
309
+ }
310
+ return false
311
+ })()
312
+ }
313
+
314
+ function runExtractor(cmd, args) {
315
+ return new Promise((resolve) => {
316
+ let child
317
+ try {
318
+ child = spawn(cmd, args, { windowsHide: true, stdio: 'ignore' })
319
+ } catch {
320
+ return resolve(false)
321
+ }
322
+ child.on('error', () => resolve(false))
323
+ child.on('exit', (code) => resolve(code === 0))
172
324
  })
173
325
  }
174
326
 
175
- /** Remove version dirs older than the current one (dead weight). */
327
+ /**
328
+ * Drop version dirs older than the current one (dead weight), plus any
329
+ * scratch dir a crashed extraction left behind.
330
+ */
176
331
  function cleanupOldVersions(root, currentDir) {
177
332
  try {
178
333
  for (const entry of readdirSync(root)) {
179
- if (!entry.startsWith('electron-v')) continue
334
+ const isStaleRuntime = entry.startsWith('electron-v')
335
+ const isScratch = entry.startsWith('.extract-') || entry.startsWith('.electron-')
336
+ if (!isStaleRuntime && !isScratch) continue
180
337
  const full = join(root, entry)
181
338
  if (full === currentDir) continue
182
339
  rmSync(full, { recursive: true, force: true })
@@ -187,11 +344,15 @@ function cleanupOldVersions(root, currentDir) {
187
344
  }
188
345
 
189
346
  /**
190
- * Provision a local electron package's dist/ as the version dir itself,
191
- * so the layout matches a downloaded runtime: <dir>/electron(.exe) at the
192
- * version-dir root. Windows: junction (zero-copy, instant) a 269MB
193
- * recursive cpSync can be killed by sandbox/AV on large trees, so only
194
- * fall back to a copy.
347
+ * Provision a local electron package's dist/ as the version dir itself, so
348
+ * the layout matches a downloaded runtime: <dir>/<EXE_RELPATH> at the
349
+ * version-dir root (on macOS that is <dir>/Electron.app/Contents/MacOS/Electron).
350
+ *
351
+ * Windows: junction (zero-copy, instant) — a 269MB recursive cpSync can be
352
+ * killed by sandbox/AV on large trees, so only fall back to a copy.
353
+ * macOS/Linux: a symlink to dist/ would work too, but a bare .app reached
354
+ * through a symlink is a common source of Gatekeeper/entitlement surprises,
355
+ * so always copy there.
195
356
  */
196
357
  function provisionLocalDist(srcPkg, destDir) {
197
358
  if (process.platform === 'win32') {
package/version.txt CHANGED
@@ -1 +1 @@
1
- 0.1.5
1
+ 0.1.7