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 CHANGED
@@ -7,6 +7,12 @@ maintainer does not use daily.
7
7
 
8
8
  - **Icather** — Windows side, plugin architecture, backend lifecycle, CI setup.
9
9
 
10
+ ## Contributors
11
+
12
+ - **[@lispv](https://github.com/lispv)** — DSH 0.1.2 launch-token authentication on plugin cold start (the host half mints the process's launch URL and hands it to Electron), the desktop titlebar inset contract, preserving loaded pages during backend outages, and the macOS titlebar drag-strip hit area. His commit authorships are preserved in the merged history (#5, #6).
13
+ - **[@lzd-loostone](https://github.com/lzd-loostone)** — capturing the full launch-token URL instead of only host:port, so a shell-started backend stops landing on the 401 page (#4).
14
+ - **[@e16a](https://github.com/e16a)** — the issue that pinned the black-window root cause precisely (the dropped `?token=` and `probe()` scoring a 401 as healthy); the fix followed that diagnosis directly (#2).
15
+
10
16
  ## Looking for help
11
17
 
12
18
  - **macOS maintainer / co-developer**: validate the .dmg install flow,
package/README.en.md CHANGED
@@ -46,29 +46,32 @@ No terminal, no commands. **Double-click the desktop shortcut and the DSH window
46
46
 
47
47
  The tray **shows the backend state in real time** (running / starting / stopped / error) with one-click controls:
48
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
49
+ - **Responsiveness monitoring**: the probe timeout remains 1,500 ms. A failed response is not a process exit: transient and persistent failures retain the loaded page and show a connection notice.
50
+ - **In-page recovery**: HTTP and client-runtime state are combined, and the client's own connection service resumes synchronization without automatic reloads. Automatic retries use the existing 4-second watch; manual retries share its duplicate-request guards.
51
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
52
52
 
53
53
  ## Usage
54
54
 
55
55
  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.
56
56
  2. Everything from the original web UI works as-is.
57
- 3. Detailed settings live in the tray right-click menu. The main window adds no controls of its own, keeping the page clean.
57
+ 3. Detailed settings live in the tray right-click menu. The original page remains unchanged while connected; connection failures show a recovery notice.
58
58
 
59
- **All backend controls live in the tray** — the main window stays a pure shell:
59
+ **Backend lifecycle controls live in the tray**:
60
60
 
61
61
  - Start / restart / stop the backend (with progress dialogs; stopping really shuts down the service on 3080, including externally started instances)
62
62
  - Auto-detect backend · set the backend install folder (auto-detect default)
63
63
  - Reload window · create desktop shortcut · check for updates · repo homepage
64
64
 
65
- **Window reliability (Edge-style instant refresh):**
65
+ **Window reliability (non-destructive connection recovery):**
66
66
 
67
67
  - Shows immediately on launch, never waits for the backend
68
- - While the backend is down, a local "backend offline" screen is shown and re-probed; the real page loads automatically the moment it answers
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"
68
+ - When no usable page exists at startup, the local offline screen remains until backend startup is ready
69
+ - Only a confirmed managed-child exit or an explicit stop of an identified backend switches back to the offline screen; an unreachable external backend remains a responsiveness failure
70
+ - Persistent failures offer retry and explicit page reload; reloading can discard unsaved page state
70
71
  - The offline screen has self-service buttons: reload / start backend / auto-detect backend / set backend install folder
71
72
 
73
+ The shell does not recreate the loaded document after probe failures, but third-party plugins may still change their own views when handling disconnection. Confirmed exits and explicit reloads still leave the current page.
74
+
72
75
  ## macOS status (v0.1.7 important note)
73
76
 
74
77
  v0.1.7 fixes the plugin-mode bug where the shell could not locate `Electron.app`
@@ -124,6 +127,67 @@ and both can be avoided entirely by setting `DSH_SHELL_ELECTRON_DIR`.
124
127
  The unsigned-installer warnings (Windows SmartScreen, macOS Gatekeeper) come from
125
128
  the **absence of a code-signing certificate**, not from any of the above.
126
129
 
130
+ ## Compatibility & runtime bounds
131
+
132
+ The "high capability" rating comes from the four capabilities listed above (files, network,
133
+ commands, credentials). Compatibility ranges, dependencies, external services and failure
134
+ bounds are declared below for human review — **a declaration is not an acceptance run**, so
135
+ every row names its evidence.
136
+
137
+ ### Compatibility ranges
138
+
139
+ | Item | Declaration | Basis |
140
+ |:--|:--|:--|
141
+ | Node.js | `>=20.0.0` (`engines.node`) | The host half uses global `fetch` and `AbortSignal.timeout`; the Electron half runs on Electron 33's embedded Node 20.18. Nothing newer is required. |
142
+ | DSH | `>=0.1.1 <0.2.0` (`dsh.compatibility.dsh`) | Targets the 0.1.x plugin contract. Before 0.1.2 there is no BrowserAuth, and the host half has an explicit guard for that generation (it still launches the window, just without a bootstrap URL). |
143
+
144
+ `dsh.compatibility.dshReleases` records the tested status per release:
145
+
146
+ | DSH version | Status | Evidence |
147
+ |:--|:--|:--|
148
+ | `0.1.5-rc.1` | `compatible` | Real end-to-end: launch-token banner → plugin auto-launches the shell → the window renders the UI and stores a `dsh-auth` cookie; keep-page-on-outage and offline-screen-on-confirmed-exit both pass |
149
+ | `0.1.5-rc.2`, `0.1.5-alpha.2` | `unknown` | No runtime acceptance run |
150
+ | `0.1.1-rc.2` | `unknown` | The "pre-0.1.2" code path is asserted separately (real host module against a connection without `authenticatedUrl`), but no full acceptance run on that release |
151
+
152
+ ### Dependencies & lifecycle scripts
153
+
154
+ | Kind | Content |
155
+ |:--|:--|
156
+ | Runtime deps | `electron-updater` (tray "check for updates"), `semver` (version comparison). Both are used in the Electron half only; **the host half loads no third-party dependency**. |
157
+ | Peer dep | `@deepseek-ai/dsh` (optional) — the host is provided by DSH, never installed with this package. |
158
+ | Dev deps | `electron`, `electron-builder`, `sharp`, `png-to-ico` (build and icon generation only). |
159
+ | Lifecycle scripts | **None.** This package declares no `preinstall` / `install` / `postinstall` / `prepare`; `scripts` holds only manual entries (`build` / `check` / `dev` / `icons` / `pack`). |
160
+ | Install-script exception | `pnpm.allowScripts` allows `electron`'s own postinstall (it downloads the Electron binary). That is a **dependency's** script, not this package's, and only appears when dev dependencies are installed. |
161
+
162
+ ### External services
163
+
164
+ | Endpoint | When | Failure behaviour |
165
+ |:--|:--|:--|
166
+ | `github.com` / `npmmirror.com` | First-time Electron runtime provisioning; two sources race with a 3 s timeout (~100 MB) | Both fail → the window does not start, and `<DSH_HOME>/desktop-shell-launch.log` records the platform, the raw error and alternatives |
167
+ | `github.com` (rcedit) | First-time taskbar-icon patch on the runtime exe (~1.3 MB) | Capped at 20 s for the fetch and 25 s for the whole step; a timeout only costs the custom icon — **never the window** — and the next launch retries |
168
+ | `api.github.com` | Tray "check for updates" / auto-update | Fails silently; nothing else is affected |
169
+ | Anything else | None. No uploads, no conversation data, no telemetry. | — |
170
+
171
+ ### Failure bounds
172
+
173
+ - **Backend will not start**: the offline screen is shown and re-probed every 2.5 s; it loads the moment the backend answers.
174
+ - **Backend drops**: the loaded page is kept with an in-page notice (no reload, no lost drafts); only a confirmed process exit or an explicit stop swaps in the offline screen.
175
+ - **Not Windows**: the taskbar-icon patch is skipped outright (first line of `patchExeIcon` is an `isWin` check).
176
+ - **`dsh` CLI not found**: point the tray at the folder, or set `DSH_BACKEND_DIR`.
177
+
178
+ ### Disposable-profile acceptance record
179
+
180
+ A full install / start / uninstall cycle in a clean throwaway DSH home and profile
181
+ (**never the daily profile**), DSH `0.1.5-rc.1`, Windows 11:
182
+
183
+ | Step | Command | Result |
184
+ |:--|:--|:--|
185
+ | Install | `dsh plugin --profile web add file:<repo>` | exit 0 (pnpm 2.2 s); the plugin entry appears in `--dump-config` |
186
+ | Start | `dsh web --no-open` | launch-token banner printed; bare `/` → 401, with token → 303 + `Set-Cookie: dsh-auth-…`, with cookie → 200; the index manifest contains `dsh-clean-desktop-shell/client.js` (plugin mounted in the front end) |
187
+ | Plugin launches the window | same run | The host half completed every step: `inject(['connection'])` fired → `webServer` resolved → launch URL minted → `launchShell()` called (asserted point by point with a temporary probe). **Window visibility was not accepted on this machine**: the acceptance host is a GPU-less CI-style sandbox where Electron exits with `FATAL: GPU process isn't usable` — unrelated to this plugin (a blank Electron app with no GPU flags exits there too, and `--in-process-gpu` makes it work). Window rendering itself was verified separately with a GPU-flagged harness. |
188
+ | Uninstall | `dsh plugin --profile web remove dsh-clean-desktop-shell` | exit 0 (pnpm 1.4 s); plugin entries in `--dump-config` drop to zero |
189
+ | Rollback | Uninstall is the rollback: `dsh.profile.bundles` and `dependencies` are updated together, and backend/window behaviour returns to the uninstalled state | — |
190
+
127
191
  ## Install
128
192
 
129
193
  **Option 1: download the installer from Releases (for a standalone desktop app)**
@@ -229,8 +293,25 @@ npm run dev # launch the shell (dev mode)
229
293
  npm run pack # package NSIS (Win) / DMG (mac)
230
294
  ```
231
295
 
296
+ `npm run check` runs syntax and Node self-checks. Running `scripts/selftest-recovery.mjs` with Electron additionally checks retry after a real spawn failure, burst retries, and immediate-failure feedback; use `--user-data-dir` with a temporary directory.
297
+
298
+ The following sh/bash example expects `ELECTRON` to name an already installed Electron executable. It uses isolated user-data, does not launch the daily application, and does not send a model request:
299
+
300
+ ```sh
301
+ test -n "$ELECTRON" && "$ELECTRON" --user-data-dir="$(mktemp -d)" scripts/selftest-recovery.mjs
302
+ ```
303
+
304
+ This entry exercises recovery boundaries using a temporary non-executable file and a local page on an ephemeral port; it is not a substitute for real-session or performance acceptance.
305
+
232
306
  ## Changelog
233
307
 
308
+ ### 0.1.13
309
+ - Fixed a first launch that could stay windowless for minutes: the taskbar-icon patch (rcedit) reused the generic 600 s download budget, so a slow GitHub could hold the window back for ten minutes. It now has its own 20 s cap plus a 25 s deadline for the whole step, and runs in parallel with the Electron runtime download — a miss only costs the custom icon, never the window.
310
+ - Declared compatibility ranges explicitly (`engines.node: ">=20.0.0"` and `dsh.compatibility` with per-release status), plus dependencies, lifecycle scripts (none), external services, failure bounds and a disposable-profile install / start / uninstall record — meeting the DSH STORE listing contract.
311
+ - A slow backend response no longer navigates away to the offline screen: the loaded page is preserved and an in-page notice is shown, so drafts, scroll position and selection survive.
312
+ - Probe observations are separated from confirmed exits: only a real process exit or an explicit stop counts as the backend going down, and the probe keeps its 1,500 ms timeout.
313
+ - Real client connection state and the runtime's own reconnect action are bridged in (`shellAPI.connectionReport` / `onReconnectRequest`): a live HTTP port can no longer hide a dead page WebSocket, and manual plus automatic retries share one guard so an immediate failure cannot feed a reconnect loop.
314
+
234
315
  ### 0.1.12
235
316
  - **DSH 0.1.2 BrowserAuth compatibility, plugin form included**: on plugin cold start the host defers-injects the Connection service and, after the Loader tree settles, mints this process's launch URL for Electron — first entry completes the `?token=` → Cookie exchange automatically, no manual backend restart. Until now only a shell-started backend could obtain a token; the plugin form (`dsh web` auto-launching the shell) and "start `dsh web` yourself, then open the shell" both stayed on the unauthenticated page.
236
317
  - Electron-managed backend (start / restart) keeps the stdout launch-URL bootstrap: it matches the `dsh web:` banner strictly and keeps the whole `?token=` (loopback only — the token is a local-process secret), with a fallback parse for pre-0.1.2 bare URLs so those no longer degrade into a startup timeout; an externally running backend is never restarted or killed on its own.
package/README.md CHANGED
@@ -46,29 +46,32 @@
46
46
 
47
47
  托盘**实时显示后端状态**(运行中 / 启动中 / 未运行 / 错误),一键启停:
48
48
 
49
- - **活性监测**:窗口持续探测后端;后端一旦被杀、崩溃或手动关闭,窗口立刻切到离线页,绝不停在旧页面假装还活着
50
- - **自动重连**:后端恢复的一刻,窗口自动加载回真实页面,无需手动刷新
49
+ - **响应监测**:保留 1,500 ms 探测超时。响应失败不等于进程退出:短暂或持续失败都保留已加载页面,只显示连接提示。
50
+ - **原页重连**:结合 HTTP 与客户端连接状态,通过客户端自身的连接服务恢复同步,不自动重载。自动重试沿用 4 秒探测节奏;手动重试共用防重复请求检查。
51
51
  - **快捷启停**:托盘右键一键启动 / 重启 / 关闭后端(带进度弹窗);「关闭后端」真正停掉 3080 端口上的服务,含外部启动的实例
52
52
 
53
53
  ## 使用
54
54
 
55
55
  1. 若安装过插件,命令行启动 `dsh` 自动弹出桌面窗口;也可通过插件创建的桌面快捷方式双击,媲美原生桌面端的体验。
56
56
  2. 使用原网页端的一切功能。
57
- 3. 托盘右键可以进行详细设置。主窗口不添加任何控件,保持页面纯净。
57
+ 3. 托盘右键可以进行详细设置。正常连接时保持原网页;连接异常时显示恢复提示。
58
58
 
59
- **后端的一切操作都在托盘右键**,主窗口保持纯壳:
59
+ **后端启停等管理操作在托盘右键**:
60
60
 
61
61
  - 启动 / 重启 / 关闭后端(带进度弹窗;关闭会真正停掉 3080 上的服务,包括外部启动的实例)
62
62
  - 自动探测后端 · 设置后端安装文件夹(默认自动探测定位)
63
63
  - 刷新窗口 · 创建桌面快捷方式 · 检查更新 · 仓库主页
64
64
 
65
- **窗口的可靠性(Edge 式即时刷新)**:
65
+ **窗口的可靠性(保留页面的连接恢复)**:
66
66
 
67
67
  - 双击启动立即出窗,不等后端就绪
68
- - 后端没起来时显示「后端未连接」页,自动探测;后端一通立即加载
69
- - 后端关闭 / 被杀的一刻,窗口立刻切回离线页——不会停在旧页面假装还活着
68
+ - 启动时尚无可用页面,显示「后端未连接」页;后端启动就绪后加载
69
+ - 只有确认自管进程退出或明确停止已识别的后端,才切回离线页;外部后端失联仍按响应故障提示
70
+ - 持续故障提供「立即重试」与「重新加载页面」;后者是会丢失未保存页面状态的显式操作
70
71
  - 离线页内置快捷按钮:重新加载 / 启动后端 / 自动探测后端 / 设置后端安装文件夹
71
72
 
73
+ 壳不会因探测失败重建已加载文档,但第三方插件自身的断线处理仍可能改变其局部视图。确认退出与显式重载仍会离开当前页面。
74
+
72
75
  ## macOS 状态(v0.1.7 重要说明)
73
76
 
74
77
  v0.1.7 修复了插件形态在 macOS 上无法定位 `Electron.app` 路径的问题(该 bug 导致窗口在 Mac 上完全静默失败)。
@@ -105,6 +108,63 @@ v0.1.7 修复了插件形态在 macOS 上无法定位 `Electron.app` 路径的
105
108
 
106
109
  安装包的未签名警告(Windows SmartScreen、macOS Gatekeeper)来自**缺少代码签名证书**,与上述行为无关。
107
110
 
111
+ ## 兼容性与运行边界
112
+
113
+ 扫描器给出的「高能力」评级来自上一节那四类能力(文件 / 网络 / 命令 / 凭据)。下面把兼容范围、依赖、外部服务与失败边界摊开声明,供人工审阅——**声明不等于验收**,逐条注明证据。
114
+
115
+ ### 兼容范围
116
+
117
+ | 项 | 声明 | 依据 |
118
+ |:--|:--|:--|
119
+ | Node.js | `>=20.0.0`(`engines.node`) | host 半边用全局 `fetch` 与 `AbortSignal.timeout`;Electron 半边跑在 Electron 33 内嵌的 Node 20.18 上。没有更新 API 的依赖。 |
120
+ | DSH | `>=0.1.1 <0.2.0`(`dsh.compatibility.dsh`) | 面向 0.1.x 的插件契约;0.1.2 之前没有 BrowserAuth,host 半边对该代有显式守卫(拿不到 `authenticatedUrl()` 时照常拉起窗口,只是不带 bootstrap URL)。 |
121
+
122
+ `dsh.compatibility.dshReleases` 逐版本标注实测状态:
123
+
124
+ | DSH 版本 | 状态 | 证据 |
125
+ |:--|:--|:--|
126
+ | `0.1.5-rc.1` | `compatible` | 真机端到端:token 横幅 → 插件自动弹壳 → 窗口渲染 UI 并写入 `dsh-auth` cookie;掉线保留页面、确认退出切离线页均实测通过 |
127
+ | `0.1.5-rc.2`、`0.1.5-alpha.2` | `unknown` | 未做运行验收 |
128
+ | `0.1.1-rc.2` | `unknown` | 「0.1.2 之前」这条代码路径有独立断言(真实 host 模块 + 无 `authenticatedUrl` 的 connection),但没在真实该版本上跑完整验收 |
129
+
130
+ ### 依赖与生命周期脚本
131
+
132
+ | 类型 | 内容 |
133
+ |:--|:--|
134
+ | 运行时依赖 | `electron-updater`(托盘「检查更新」)、`semver`(版本比较)。两者都只在 Electron 半边使用;**host 半边不加载任何第三方依赖**。 |
135
+ | peer 依赖 | `@deepseek-ai/dsh`(可选)——宿主由 DSH 提供,不随本包安装。 |
136
+ | 开发依赖 | `electron`、`electron-builder`、`sharp`、`png-to-ico`(仅构建与图标生成)。 |
137
+ | 生命周期脚本 | **无。** 本包不声明 `preinstall` / `install` / `postinstall` / `prepare`;`scripts` 只有 `build` / `check` / `dev` / `icons` / `pack` 这些手动入口。 |
138
+ | 安装期脚本例外 | `pnpm.allowScripts` 放行了 `electron` 自身的 postinstall(它要下载 Electron 二进制)。这是**依赖的**脚本、不是本包的,且只在装开发依赖时出现。 |
139
+
140
+ ### 外部服务
141
+
142
+ | 端点 | 何时访问 | 失败后果 |
143
+ |:--|:--|:--|
144
+ | `github.com` / `npmmirror.com` | 首次准备 Electron 运行时,两源 3 秒竞速下载(约 100MB) | 两源都失败 → 窗口不启动,并写入 `<DSH_HOME>/desktop-shell-launch.log`(含平台、错误原文与替代方案) |
145
+ | `github.com`(rcedit) | 首次给 runtime exe 打任务栏图标(约 1.3MB) | 下载限时 20 秒、整步限时 25 秒,超时只损失自定义图标,**不影响出窗**;下次启动重试 |
146
+ | `api.github.com` | 托盘「检查更新」/ 自动更新 | 静默失败,不影响使用 |
147
+ | 其他 | 无。不上传数据、不读会话内容、无遥测。 | — |
148
+
149
+ ### 失败边界
150
+
151
+ - **后端起不来**:显示离线页并每 2.5 秒重探,后端一通立即加载。
152
+ - **后端掉线**:保留已加载页面 + 页内提示(不重载、不丢草稿);只有确认进程退出或用户主动停止才切离线页。
153
+ - **非 Windows**:任务栏图标补丁直接跳过(`patchExeIcon` 首行 `isWin` 判断)。
154
+ - **找不到 `dsh` CLI**:托盘「设置后端文件夹」手动指定,或设 `DSH_BACKEND_DIR`。
155
+
156
+ ### 一次性 Profile 验收记录
157
+
158
+ 在一台干净的一次性 DSH home + 一次性 profile 上跑完整安装 / 启动 / 卸载循环(**不触碰日常 profile**),DSH `0.1.5-rc.1`、Windows 11:
159
+
160
+ | 步骤 | 命令 | 结果 |
161
+ |:--|:--|:--|
162
+ | 安装 | `dsh plugin --profile web add file:<repo>` | 退出码 0(pnpm 2.2s);`--dump-config` 出现插件条目 |
163
+ | 启动 | `dsh web --no-open` | 打印 launch token 横幅;裸 `/` → 401、带 token → 303 + `Set-Cookie: dsh-auth-…`、带 cookie → 200;首页 manifest 含 `dsh-clean-desktop-shell/client.js`(插件在前端已挂载) |
164
+ | 插件拉起窗口 | 同上 | host 半边完整走通:`inject(['connection'])` 触发 → `webServer` 可解析 → 铸出 launch URL → `launchShell()` 被调用(用临时探针逐点断言)。**窗口是否可见未在本环境验收**:验收机是 CI 式无 GPU 沙箱,Electron 报 `FATAL: GPU process isn't usable` 后退出,与本插件无关(同一台机器上不带任何 GPU 参数的空白 Electron 应用同样退出,带 `--in-process-gpu` 则正常)。窗口渲染本身在此前用带 GPU 参数的 harness 单独验证过。 |
165
+ | 卸载 | `dsh plugin --profile web remove dsh-clean-desktop-shell` | 退出码 0(pnpm 1.4s);`--dump-config` 中插件条目归零 |
166
+ | 回滚 | 卸载即回滚:`dsh.profile.bundles` 与 `dependencies` 同步移除,后端与窗口行为回到未安装状态 | — |
167
+
108
168
  ## 安装
109
169
 
110
170
  **方式一:从 Release 下载安装包(想要独立桌面应用的用户)**
@@ -210,8 +270,25 @@ npm run dev # 启动壳(开发模式)
210
270
  npm run pack # 打包 NSIS (Win) / DMG (mac)
211
271
  ```
212
272
 
273
+ `npm run check` 运行语法和 Node 自检。用 Electron 运行 `scripts/selftest-recovery.mjs` 还会验证真实启动失败后的重试、连续手动重试及即时失败不会形成重连循环;建议通过 `--user-data-dir` 指定临时目录。
274
+
275
+ 以下为 sh/bash 示例;`ELECTRON` 指向预先安装的 Electron 可执行文件。用独立 user-data 目录运行,不启动日常应用、不发送模型请求:
276
+
277
+ ```sh
278
+ test -n "$ELECTRON" && "$ELECTRON" --user-data-dir="$(mktemp -d)" scripts/selftest-recovery.mjs
279
+ ```
280
+
281
+ 该入口使用临时无执行权限文件与随机端口的本地页面验证恢复边界;不是完整真实会话或性能验收的替代品。
282
+
213
283
  ## 更新历史
214
284
 
285
+ ### 0.1.13
286
+ - 修复首次启动可能长时间不出窗:任务栏图标补丁(rcedit)此前沿用通用下载预算(600 秒),GitHub 慢时能把窗口拖到十分钟才出现。现在它单独限时 20 秒、整步另有 25 秒硬上限,并与 Electron 运行时下载并行;超时只损失自定义图标,不再影响出窗。
287
+ - 显式声明兼容范围:`engines.node: ">=20.0.0"` 与 `dsh.compatibility`(含逐版本实测状态),并补齐依赖、生命周期脚本(无)、外部服务、失败边界与一次性 profile 的安装 / 启动 / 卸载验收记录——满足 DSH STORE 的上架契约。
288
+ - 后端响应超时不再直接跳离线页:改为页内提示并保留已加载页面,草稿/滚动/选中状态不受影响。
289
+ - 区分「探测结果」与「确认退出」:只有进程真的退出或用户主动停止才算后端下线,探测超时不再被当成退出;探测超时保留 1,500 ms。
290
+ - 接入真实客户端连接状态与重连入口(`shellAPI.connectionReport` / `onReconnectRequest`):HTTP 活着但页面 WebSocket 已断的情况不再被掩盖;手动与自动重试共用同一次检查,避免即时失败反馈成重连循环。
291
+
215
292
  ### 0.1.12
216
293
  - **适配 DSH 0.1.2 BrowserAuth,插件形态一并修复**:插件冷启动时由 host 延迟注入 Connection 服务、等 Loader settle 后铸造本进程 launch URL 交给 Electron,首次进入自动完成 `?token=` → Cookie 认证,无需手动重启后端。此前只有「壳自己启动后端」这条路能拿到 token,插件形态(`dsh web` 自动弹壳)与「先手动跑 `dsh web` 再开壳」都会停在未认证页。
217
294
  - Electron 自管后端(启动 / 重启)沿用 stdout launch URL bootstrap:严格匹配 `dsh web:` 横幅并保留完整 `?token=`(只接受 loopback,token 属本机进程机密),对 0.1.2 之前的裸 URL 保留回退解析以免退化成等待超时;外部已运行的 backend 不会被擅自重启或杀掉。
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Per-window backend-outage run (liveness-watch state).
3
+ *
4
+ * Pure module — no Electron imports — so the recovery self-test
5
+ * (scripts/selftest-recovery.mjs) can exercise the real transitions under
6
+ * plain node: consecutive-failure escalation, reset-on-recovery, and the
7
+ * navigation token that discards probe results which finished after the
8
+ * window navigated away or was disposed.
9
+ */
10
+
11
+ // An outage escalates from transient to persistent/unavailable copy after
12
+ // this many consecutive failed probes (~3 × 4s watch interval).
13
+ export const PERSISTENT_AFTER_FAILURES = 3
14
+
15
+ export class OutageRun {
16
+ constructor() {
17
+ this.failures = 0
18
+ // Navigation epoch. reset() bumps it; every async probe captures a
19
+ // checkpoint() before awaiting and apply() discards the result if the
20
+ // epoch moved meanwhile — a stale probe can never act on a newer
21
+ // navigation (or on a disposed window's fresh lookalike state).
22
+ this.epoch = 0
23
+ }
24
+
25
+ /** Capture the current epoch before starting an async probe. */
26
+ checkpoint() {
27
+ return this.epoch
28
+ }
29
+
30
+ /** True while the run is still on the navigation `token` was taken under. */
31
+ isCurrent(token) {
32
+ return token === this.epoch
33
+ }
34
+
35
+ /** Navigation/flip (or fresh page load): invalidate in-flight probes and start a fresh run. */
36
+ reset() {
37
+ this.epoch += 1
38
+ this.failures = 0
39
+ }
40
+
41
+ /**
42
+ * Apply a probe result taken under `token`. Returns null when the run was
43
+ * reset (the window navigated away or was disposed) while the probe was in
44
+ * flight — the result is stale and must not act. Otherwise advances the
45
+ * consecutive-failure counter and returns the exact renderer payload for
46
+ * the in-page connection notice (window.js sends it verbatim).
47
+ */
48
+ apply(token, up) {
49
+ if (!this.isCurrent(token)) return null
50
+ if (up) {
51
+ this.failures = 0
52
+ return { state: 'ok' }
53
+ }
54
+ this.failures += 1
55
+ return {
56
+ state: 'degraded',
57
+ persistent: this.failures >= PERSISTENT_AFTER_FAILURES,
58
+ }
59
+ }
60
+ }
@@ -12,9 +12,28 @@
12
12
  * use a pragmatic height and rely on the page's own top padding for the
13
13
  * DSH top bar area. Overlap is acceptable: the strip is click-through for
14
14
  * everything except dragging (app-region drag areas swallow mouse events).
15
+ *
16
+ * The in-page connection notice is the non-destructive counterpart of the
17
+ * offline screen: while a real page is loaded, backend liveness failures
18
+ * never navigate away — the main process pushes watch results here and we
19
+ * show/clear a small fixed banner instead. Draft, scroll and selection in
20
+ * the loaded page are untouched.
15
21
  */
16
22
  const { contextBridge, ipcRenderer } = require('electron')
17
23
 
24
+ // Page-side handler invoked when the main process asks the DSH client
25
+ // runtime (injected page module, src/client/client.js) to reconnect. One
26
+ // handler per page context — navigation resets it with the page.
27
+ let reconnectHandler = null
28
+
29
+ ipcRenderer.on('shell:client-reconnect', () => {
30
+ try {
31
+ if (typeof reconnectHandler === 'function') reconnectHandler()
32
+ } catch {
33
+ // page handler errors must not break the shell
34
+ }
35
+ })
36
+
18
37
  contextBridge.exposeInMainWorld('shellAPI', {
19
38
  // Ask the main process to (re)load the real target URL. Used by the
20
39
  // offline screen's retry button.
@@ -23,12 +42,153 @@ contextBridge.exposeInMainWorld('shellAPI', {
23
42
  startBackend: () => ipcRenderer.send('shell:start-backend'),
24
43
  detectBackend: () => ipcRenderer.send('shell:detect-backend'),
25
44
  chooseBackendFolder: () => ipcRenderer.send('shell:choose-backend-folder'),
45
+ // DSH client-runtime connection bridge (see src/client/client.js):
46
+ // - connectionReport(state) forwards the page connection lifecycle
47
+ // ('connected' | 'connecting' | 'disconnected') to the main process so
48
+ // HTTP health alone can never hide a terminal disconnect;
49
+ // - onReconnectRequest(cb) registers the page-side reconnect action (the
50
+ // runtime's own reconnect(), never a page reload); returns an
51
+ // unregister function.
52
+ connectionReport: (state) => ipcRenderer.send('shell:client-connection', state),
53
+ onReconnectRequest: (cb) => {
54
+ if (typeof cb !== 'function') return () => {}
55
+ reconnectHandler = cb
56
+ return () => {
57
+ if (reconnectHandler === cb) reconnectHandler = null
58
+ }
59
+ },
60
+ })
61
+
62
+ // ---------- in-page connection notice ----------
63
+
64
+ // Latest push from window.js refreshNotice: { state: 'ok' } or
65
+ // { state: 'degraded', persistent: boolean }, computed from merged HTTP
66
+ // probe + client-runtime connection state. Kept until the DOM is ready
67
+ // (messages can arrive before DOMContentLoaded on very fast loads).
68
+ let connState = null
69
+ let connUi = null
70
+
71
+ ipcRenderer.on('shell:connection-state', (_event, state) => {
72
+ connState = state
73
+ applyConnState()
26
74
  })
27
75
 
76
+ function applyConnState() {
77
+ if (!connState || !connUi) return
78
+ if (!connState.state || connState.state === 'ok') {
79
+ connUi.el.style.display = 'none'
80
+ return
81
+ }
82
+ const persistent = connState.persistent === true
83
+ connUi.title.textContent = persistent
84
+ ? '后端暂时无法访问'
85
+ : '与后端的连接暂时中断'
86
+ connUi.sub.textContent = persistent
87
+ ? '当前页面已保留,连接恢复后本页会自动继续,也可手动重试。'
88
+ : '正在自动重连,当前页面已保留。'
89
+ connUi.retryBtn.hidden = false
90
+ // Full reload is only offered once the outage is persistent: it is
91
+ // destructive to unsaved page state, so it stays an explicit user choice.
92
+ connUi.reloadBtn.hidden = !persistent
93
+ connUi.el.style.display = 'flex'
94
+ }
95
+
96
+ /**
97
+ * Build the notice banner below the drag strip (top-center, fixed). Uses
98
+ * inline styles like the drag strip — no <style> injection, so the loaded
99
+ * page's Content-Security-Policy cannot block it. The element itself is
100
+ * inert while hidden and never steals focus while visible.
101
+ */
102
+ function buildConnUi() {
103
+ const base = {
104
+ position: 'fixed',
105
+ top: '0',
106
+ left: '50%',
107
+ transform: 'translateX(-50%)',
108
+ display: 'none',
109
+ alignItems: 'center',
110
+ gap: '14px',
111
+ maxWidth: '76vw',
112
+ padding: '8px 14px',
113
+ background: 'rgba(16, 19, 26, 0.94)',
114
+ border: '1px solid #33405a',
115
+ borderRadius: '10px',
116
+ color: '#e8eaf0',
117
+ font: '13px/1.4 system-ui, "Segoe UI", "Microsoft YaHei", sans-serif',
118
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.45)',
119
+ zIndex: '2147483646',
120
+ pointerEvents: 'auto',
121
+ userSelect: 'none',
122
+ WebkitUserSelect: 'none',
123
+ }
124
+ const el = document.createElement('div')
125
+ el.id = 'dsh-clean-shell-conn'
126
+ el.setAttribute('role', 'status')
127
+ el.setAttribute('aria-live', 'polite')
128
+ Object.assign(el.style, base)
129
+
130
+ const copy = document.createElement('div')
131
+ copy.style.display = 'flex'
132
+ copy.style.flexDirection = 'column'
133
+ copy.style.gap = '2px'
134
+
135
+ const title = document.createElement('div')
136
+ title.id = 'dsh-clean-shell-conn-title'
137
+ title.style.fontWeight = '600'
138
+ title.style.color = '#e8eaf0'
139
+ const sub = document.createElement('div')
140
+ sub.id = 'dsh-clean-shell-conn-sub'
141
+ sub.style.fontSize = '12px'
142
+ sub.style.opacity = '0.75'
143
+ copy.append(title, sub)
144
+
145
+ const actions = document.createElement('div')
146
+ actions.style.display = 'flex'
147
+ actions.style.gap = '8px'
148
+
149
+ const btnStyle = {
150
+ padding: '5px 12px',
151
+ fontSize: '12px',
152
+ color: '#e8eaf0',
153
+ background: '#232d3d',
154
+ border: '1px solid #33405a',
155
+ borderRadius: '7px',
156
+ cursor: 'pointer',
157
+ }
158
+
159
+ const retryBtn = document.createElement('button')
160
+ retryBtn.id = 'dsh-clean-shell-conn-retry'
161
+ retryBtn.type = 'button'
162
+ retryBtn.textContent = '立即重试'
163
+ Object.assign(retryBtn.style, btnStyle)
164
+ retryBtn.addEventListener('mouseenter', () => { retryBtn.style.background = '#2b3750' })
165
+ retryBtn.addEventListener('mouseleave', () => { retryBtn.style.background = '#232d3d' })
166
+ retryBtn.addEventListener('click', () => ipcRenderer.send('shell:retry-connection'))
167
+
168
+ const reloadBtn = document.createElement('button')
169
+ reloadBtn.id = 'dsh-clean-shell-conn-reload'
170
+ reloadBtn.type = 'button'
171
+ reloadBtn.textContent = '重新加载页面'
172
+ Object.assign(reloadBtn.style, btnStyle)
173
+ reloadBtn.hidden = true
174
+ reloadBtn.addEventListener('mouseenter', () => { reloadBtn.style.background = '#2b3750' })
175
+ reloadBtn.addEventListener('mouseleave', () => { reloadBtn.style.background = '#232d3d' })
176
+ reloadBtn.addEventListener('click', () => ipcRenderer.send('shell:reload'))
177
+
178
+ actions.append(retryBtn, reloadBtn)
179
+ el.append(copy, actions)
180
+ return { el, title, sub, retryBtn, reloadBtn }
181
+ }
182
+
28
183
  window.addEventListener('DOMContentLoaded', () => {
29
184
  const platform = process.platform
30
185
  const isWin = platform === 'win32'
31
- const dragHeight = isWin ? 32 : 28
186
+ // macOS: 12px (was 28). The DSH web UI's own top toolbar starts ~16px
187
+ // from the window top and does NOT consume dsh-desktop-titlebar-inset,
188
+ // so a 28px strip swallowed the upper half of every toolbar control —
189
+ // clicks had to aim low. 12px stays above that padding (drag-only band)
190
+ // while better-sidebar keeps yielding 28px for the traffic lights.
191
+ const dragHeight = isWin ? 32 : 12
32
192
  // Width reserved for native window controls (Win caption buttons / mac
33
193
  // traffic lights live at the top-right / top-left).
34
194
  const rightReserve = isWin ? 138 : 80
@@ -46,4 +206,11 @@ window.addEventListener('DOMContentLoaded', () => {
46
206
  z-index: 2147483647;
47
207
  `
48
208
  document.body.appendChild(strip)
209
+
210
+ connUi = buildConnUi()
211
+ // Clear the drag band so the banner is clickable, then render any state
212
+ // pushed before the DOM was ready.
213
+ connUi.el.style.top = `${dragHeight + 10}px`
214
+ document.body.appendChild(connUi.el)
215
+ applyConnState()
49
216
  })