feat: add npm update checks and manual installation

Add profile-scoped npm updates for Desktop and Web, protect source installations, and keep restarts manual. Cover stale status recovery, request races, and dialog focus with regression tests.

Verified with npm run check (1626 tests) and isolated Desktop/Web npm installation flows.

Refs #61
This commit is contained in:
xmanrui 2026-08-28 06:37:40 +08:00
parent 5888641297
commit f3cefd8a84
23 changed files with 4058 additions and 271 deletions

View file

@ -8,6 +8,9 @@ This file records the notable changes in each dsh-im release. Its format follows
### Added / 新增
- 「设置 → IM机器人」新增 npm 更新检查和确认安装按钮,复用当前 Desktop / Harness 的包管理机制;保护源码链接、校验精确版本与目标 profile,不拉取 GitHub,安装后提示手动重启。手动重启后可「刷新状态」核验生效,无需重载原页面。更新功能不会主动重启或刷新;宿主自带的界面刷新不代表后台版本已生效。
Added npm update checking and confirmed installation to Settings → IM Bot using the current Desktop / Harness package-management mechanism. It protects source links, verifies the exact version and target profile, uses no GitHub downloads, and requires a manual restart. Refresh status verifies the restarted Host without reloading the existing page. The updater does not request a restart or refresh; the host's own interface refresh does not mean the new backend version is running.
- 九个聊天渠道统一新增私聊命令 `/history [数量]`,只读预览当前绑定会话的最近对话:默认 3 条、最多 5 条,超出上限自动按 5 条处理;过滤工具、推理和未完成回复,长正文截断,并复用各渠道现有文字回复机制。命令不创建会话或调用模型,绑定成功提示和中英文帮助同步提供入口。
Added `/history [count]` to direct chats on all nine channels to preview the bound Session's recent conversation without creating a Session or prompting the model. It defaults to 3 messages and caps larger counts at 5, omits tools, reasoning, and unfinished replies, truncates long text, and reuses each channel's existing text-reply mechanism. Binding confirmations and bilingual help now point to the command.

View file

@ -133,6 +133,24 @@ Each Telegram bot has its own access-mode control on its bot card. Existing and
Each WhatsApp bot also has its own access mode. Existing bots migrate to **Only me**, which is also the default for newly linked bots and accepts only self-chat messages from the linked account. **Selected contacts** additionally accepts direct messages from allowlisted phone numbers and ignores groups. Enter one number with its country or region code per line; a leading `+` is optional. **Open responses** accepts all direct messages, group messages sent by the linked account, and mentions of or replies to that account from other group members; this also lets an owner-only group act as a separate conversation. Switching modes retains the allowlist. An empty Selected contacts allowlist behaves like Only me, and rejected messages are ignored silently.
## Checking and installing updates
In **Settings → IM Bot**, click **Check for updates** immediately to the left of GitHub. The official npm registry is contacted only on request; confirm the target version and current profile before installing. Only `@xmanrui/dsh-im` is updated, without fetching GitHub or updating Harness / Desktop itself.
After installation, the backend still requires a manual restart, and the panel reports **Installed; restart manually** based on the Host's status. The updater does not request a restart, hot reload, or page refresh. The host's existing module watcher may refresh the plugin interface, but an interface change does not mean the new backend version is running; the Host-reported running version is authoritative. Update when bots are idle, then restart the current Harness / Desktop yourself. Closing the settings page does not cancel a submitted installation.
If the existing page still shows a restart notice after you restart manually, click **Refresh status** in the dialog or reopen **Restart needed**. This reads the current Host status without checking npm or refreshing the page.
The button reuses Desktop's package-management service or the current Harness CLI for an exact-version install equivalent to the following (replace the example profile and version with the confirmed values):
```sh
dsh plugin --profile web add -w --save-exact @xmanrui/dsh-im@3.0.8 --registry=https://registry.npmjs.org/
```
Source `link:`, `file:`, Git, and unrecognized installations can check versions but are never replaced automatically. Confirm the intended profile before manually migrating to npm. Conflicting scoped registries, incompatible Node versions, and unavailable Host executors disable installation with an explanation. Standard Windows CLI installations currently require a manual update; Desktop uses its existing executor.
Do not modify the same profile through a terminal or plugin market during installation. A failed command may leave partial dependency changes; it is not an automatic rollback. Inspect the installation, reinstall the previous exact version if needed, and restart manually. The updater keeps only the profile's latest job and manifest backup under the current `DSH_HOME/updates/dsh-im`, without copying bot credentials. Resolve uncertain remaining installers or locks before retrying; do not blindly delete a lock.
## Bot commands
| Command | Description |

View file

@ -136,6 +136,24 @@ dsh web
每个 WhatsApp 机器人也有独立的访问模式。旧机器人升级后和新接入机器人都默认使用**仅自己模式**,只响应已绑定账号的自聊消息。**指定联系人模式**额外接受白名单电话号码的私聊并忽略群聊;号码需包含国家或地区代码,每行一个,可带开头的 `+`。**开放响应模式**响应所有私聊、已绑定账号自己发出的群聊消息,以及其他群成员对该账号的提及或回复;因此也可以把“仅自己”的群当作独立会话使用。切换模式会保留白名单;指定联系人模式的空白名单等同于仅自己模式。未授权消息会被静默忽略。
## 检查与安装更新
在「设置 → IM机器人」右上角点击 GitHub 左侧的「检查更新」。只有点击后才查询 npm 官方源;发现新版后,确认目标版本和当前 profile 再安装。更新只涉及 `@xmanrui/dsh-im`,不拉取 GitHub,不更新 Harness 或 Desktop 本体。
安装完成后,后台仍需手动重启,页面根据 Host 状态显示「已安装,待手动重启」。更新功能不会主动重启、执行热更新或刷新页面。宿主自带的模块监视机制可能自行刷新插件界面,但界面变化不代表后台版本已生效,运行版本以 Host 报告为准。请在机器人任务空闲时更新,并自行重启当前 Harness / Desktop。关闭设置页不会取消已提交的安装任务。
手动重启后,如果原页面仍显示待重启,点击窗口中的「刷新状态」,或重新打开「待手动重启」窗口。该操作只重新读取当前 Host 状态,不查询 npm,也不刷新页面。
按钮复用 Desktop 内置包管理服务或标准 Harness 的 CLI,执行相当于以下命令的精确版本安装(将示例 profile、版本替换为确认值):
```sh
dsh plugin --profile web add -w --save-exact @xmanrui/dsh-im@3.0.8 --registry=https://registry.npmjs.org/
```
源码 `link:`、`file:`、Git 来源或无法确认归属的安装只提供版本检查,不会替换开发链接;如需迁移为 npm 安装,请自行确认对应 profile。作用域 registry 冲突、Node 版本不满足要求或缺少当前 Host 的执行器时,按钮会说明原因。标准 Windows CLI 暂需手动更新;Desktop 使用其原有执行器。
安装期间不要同时在终端或插件市场修改该 profile。失败可能已经改变部分依赖,不能视为自动回滚;先检查安装状态,必要时按上述命令重装原精确版本,再手动重启。更新器只在当前 `DSH_HOME/updates/dsh-im` 下保存该 profile 最近一次任务与清单备份,不复制机器人凭据;残留安装进程或锁状态不明确时,请先人工确认,不要盲目重试或删除锁。
## 机器人命令
| 命令 | 作用 |

View file

@ -18,6 +18,26 @@ This project is an independent DeepSeek Harness integration. It does not bundle
The WeChat, QQ, Telegram, Discord, and WhatsApp marks use path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu, DingTalk, and WeCom marks are inline vectors used for channel identification. Product names and logos remain trademarks of their respective owners.
The Host bundle also includes [semver](https://www.npmjs.com/package/semver) 7.8.5 for npm release comparison, licensed under the ISC License reproduced below.
## semver license
The ISC License
Copyright (c) Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
## Lark Node SDK license
MIT License

View file

@ -0,0 +1,458 @@
# Issue #61:npm 更新检查与手动更新方案
日期:2026-08-28。实现基线:v3.0.8 / `8c6c31a`。状态:已实施,自动化检查、本机 Desktop 与 npm 全局安装版 Web 验收通过。
需求来源:[Issue #61](https://github.com/xmanrui/dsh-im/issues/61)。本文保留评审时的设计和验收要求;第 13、14 节记录实际实现、实测结果和未验收边界,不能把计划中的用例当作已经通过。
## 1. 本期范围
| 项目 | 决定 |
| --- | --- |
| 更新对象 | 仅 `@xmanrui/dsh-im` 插件,不更新 Harness、Desktop 本体或其他插件 |
| 更新来源 | npm;不查询 GitHub Release、不拉 GitHub 源码、不提供 GitHub 回退源 |
| 更新版本 | npm `latest` 指向的稳定版本;安装用户确认的具体版本号 |
| 检查方式 | 用户点击“检查更新”;首期不做启动时或后台定时检查 |
| 安装方式 | 用户确认后,由当前 Host 在当前 profile 中异步执行安装 |
| 更新生效 | 安装后显示“已安装,待手动重启”;用户重启后核验运行版本 |
| 自动重启、热更新 | 不做,不调用 Desktop 重启接口,不主动刷新页面或重载插件 |
| 按钮位置 | “设置 → IM机器人”页右上角,现有 GitHub 入口左侧 |
| 版本显示 | 继续保留左上角版本号,并处理前端、Host、磁盘版本不同的情况 |
| 本地源码安装 | 允许检查 npm 版本,但不自动替换 `link:`、`file:` 或源码目录 |
| GitHub 等其他安装来源 | 只提供手动迁移说明,不自动改成 npm 来源 |
| 主要真机验收环境 | 本机 DSH Desktop 2.0.3 / macOS;同时验证标准 `dsh web` |
现有 GitHub 帮助链接可以保留,只有用户主动点击才打开网页。更新流程不调用它。本期也不从 GitHub 拉更新日志;确认窗口只展示版本、来源、目标 profile 和重启提示。以后如需内嵌更新日志,应从 npm 发布包提供的数据获取,另行设计。
## 2. 实施前的基础与调研事实
- 版本号从 v3.0.2 起已经常驻在品牌标题旁,入口为 `plugin-src/client/index.js`;当前构建从 `package.json` 注入版本。
- Host 已有管理 RPC,默认使用 `loopback` 权限;可新增插件级通道,不需要给九个渠道各做一套更新接口。
- 标准安装由 `dsh plugin --profile <name> ...` 转交给该 profile 中的 pnpm,完成后协调插件 bundle 列表。[Harness CLI 实现](https://github.com/deepseek-ai/deepseek-harness/blob/master/apps/cli/src/plugin.ts)
- 现有 `bin/dsh-im.mjs` 安装器默认拉 GitHub,且使用 `spawnSync`。新更新功能不能直接复用其默认安装流程,也不能把同步子进程调用搬进 Host。
- 本机默认 Harness home 中,`web` 与 `desktop` 的 dsh-im 都通过 `link:<项目绝对路径>` 指向源码。它们是开发环境,不是应该被按钮自动替换的 npm 安装。
- 已确认本机应用为 `/Applications/DSH Desktop.app`,版本 2.0.3;已用其内置 `desktop-cli.js --help` 做只读探测,退出码为 0。没有执行包安装、启动 Desktop 窗口或重启。
**Desktop 的平台差异必须保留在方案中:**这版应用给 Host 注入内置 pnpm,但 `dsh` 的 Host PATH shim 仅在 Windows 安装;macOS 内置终端拥有自己的 `dsh` shim,不能据此推断 Host 直接 `spawn('dsh')` 也可用。Desktop 终端、profile 与手动重启行为另见[用户指南](https://github.com/anywhere-labs/dsh-desktop/blob/master/docs/user-guide.md#打开终端)。
## 3. 界面与交互
### 3.1 入口
```text
DSH-IM v3.0.8 [检查更新] [GitHub ↗]
让 DeepSeek Harness 触手可及
```
这是整个插件的统一入口,不放进微信、飞书等渠道页面。不改变现有渠道导航、机器人卡片和设置入口。
### 3.2 状态文案
| 状态 | 按钮或提示 | 可执行操作 |
| --- | --- | --- |
| 尚未检查 | 检查更新 | 请求 npm 版本信息 |
| 正在检查 | 检查中… | 禁止重复发起同一请求 |
| 已是最新版 | 已是最新版本,并显示检查时间 | 可再次检查 |
| 当前版本高于 npm 稳定版 | 当前版本高于 npm 稳定版 | 不降级 |
| 发现新版且允许安装 | 更新至 vX.X.X | 打开确认窗口 |
| 发现新版但不能自动安装 | 有新版本;显示源码模式、权限或环境限制原因 | 查看手动更新说明 |
| 安装中 | 正在安装 vX.X.X… | 不再启动安装;关闭面板不取消任务 |
| 安装后校验中 | 正在校验安装结果… | 等待真实结果 |
| 安装完成,Host 未重启 | 已安装,待手动重启 | 查看手动重启说明;重启后可手动刷新状态 |
| 手动重启后核验一致 | 已更新至 vX.X.X | 恢复正常检查入口 |
| 检查或安装失败 | 简短原因和可执行建议 | 按失败类型重试或手动恢复 |
网络失败不能显示“已是最新版”。下载或安装没有可靠百分比时,只展示阶段和耗时,不伪造进度。
### 3.3 确认窗口
窗口展示当前运行版本、目标版本、npm 来源和目标 profile 名称,提供“取消”“确认安装”。提示内容至少包含:
> 安装后需要手动重启 Harness 或 DSH Desktop 才能生效。请在机器人任务空闲时更新,安装期间不要同时通过终端或插件市场修改该 profile。
不能承诺安装过程对运行中机器人完全无影响:包文件及依赖可能在磁盘上发生变化。按钮不会主动停止任务、断开机器人或安排重启。
右上角操作组在窄屏允许换行,不挤压品牌和渠道内容;补齐中英文文案、键盘操作、可访问名称及状态播报。离开页面停止前端轮询,不停止已提交的 Host 安装任务。
## 4. npm 来源与版本规则
### 4.1 首期来源选择
为使检查结果与实际安装一致,首期使用公开 npm 官方 registry:`https://registry.npmjs.org/`。本期不新增镜像、私有 registry 或源切换设置。
版本检查由 Host 发起,请求固定包的 npm 元数据,例如 `https://registry.npmjs.org/@xmanrui%2Fdsh-im/latest`。浏览器不直接访问 registry,不提交自定义 URL、包名或安装 spec。
安装时明确使用同一 registry。安装前还要核对 `@xmanrui` 的有效作用域 registry:如果它覆盖到了其他地址,不能以为仅传 `--registry` 就必然消除了覆盖。首期对此显示手动处理说明,不改写用户 `.npmrc`、不读取或回传其中的认证值,不混用两个来源。pnpm 的 registry 与认证配置规则见[配置文档](https://pnpm.io/cli/config)。
不进行 GitHub 回退,不执行 `git clone`、`git pull` 或 GitHub 安装 spec。发行检查应保证 dsh-im 发布包的运行依赖不依赖 GitHub 拉取;不对同一 profile 中其他插件已有的依赖来源做自动迁移。
### 4.2 检查规则
1. 校验返回的包名、版本、运行时要求和必要发布字段;响应不合法就报告检查失败。校验下载来源和完整性元数据,实际包校验交给包管理器,不关闭其完整性检查,也不把响应中的任意 tarball URL 拼进安装命令。
2. 使用成熟 SemVer 库比较,不能按字符串大小或简单拆分比较 `3.0.9` 与 `3.0.10`。
3. 只接受 `latest` 指向的稳定版;不自动选择 `beta`、`rc`、其他 dist-tag,也不把意外指向预发布版的 `latest` 当成稳定升级。
4. 目标必须高于当前运行版本;若磁盘已有尚未生效的更新,先显示待重启,不叠加第二次安装。
5. 校验目标 `engines.node` 与实际 Host 运行时;Desktop 使用 Electron 内置 Node 的版本,不使用外部终端 Node 版本冒充。
6. 显式检查成功后生成短时有效的更新确认记录,绑定目标版本、registry、profile 和当前安装状态。
7. 确认前若记录过期、安装状态改变或目标元数据改变,应重新检查并要求确认,不能静默改装另一个版本。
初始建议:检查超时 10 秒,完整响应限制 256 KiB,同一 Host 合并并发检查;短时间连续点击做限流。上次成功结果可以保留用于展示,但失败后必须标出它是旧结果。确认记录有效期建议 10 分钟。这些是实现默认值,不是已经存在的配置项。
### 4.3 三个版本必须分开
| 字段 | 来源 | 用途 |
| --- | --- | --- |
| `clientVersion` | 前端构建时的版本 | 识别旧页面、旧缓存 |
| `runningVersion` | Host 模块加载时固定的版本 | 判断当前实际运行的插件 |
| `installedVersion` | 从当前 profile 的包入口重新解析并读取 | 判断磁盘上安装了什么 |
不能用安装结束后读取的 `package.json` 冒充当前 Host 已运行的新版本;不能通过旧模块缓存读取磁盘版本。npm 的 `latestVersion` 是第四个独立事实。
状态接口可用后,页面以 Host 的运行版本为准。若前后端不同,明确提示手动刷新或重启;旧 Host 没有新接口时只展示静态版本和手动升级说明,不循环报错。
## 5. 环境识别与安装资格
### 5.1 当前 profile 的确定
Desktop 优先读取已声明的可选 `desktopProfiles` 服务的 `current.name` 和 `current.dir`;不得调用 `select()`,因为切换 profile 本身可能触发 Desktop 重启。可选服务使用 Cordis 的可选注入或受支持的 `ctx.get` 方式,不能随意读取未声明的 `ctx` 属性。
标准 CLI 只识别明确支持的启动形式:`dsh web` 和 `dsh --profile <name>`。按 CLI 参数语义解析,结合实际加载包的位置与 profile 清单校验;不能从当前工作目录猜 profile,也不能扫描到第一个装有 dsh-im 的 profile 就选择它。
安装前验证 Harness home、profile 目录、插件依赖声明、bundle 列表及实际加载包属于同一环境。来源不明、存在多义性或无法证明当前运行包归属时,禁止自动安装。
### 5.2 资格矩阵
| 安装或运行方式 | 检查 npm | 一键安装 |
| --- | --- | --- |
| 标准 npm 依赖,profile 与执行器可确认 | 支持 | 支持 |
| Desktop 中的标准 npm 安装,适配器通过检查 | 支持 | 支持 |
| `link:`、本地目录、`file:`、workspace 开发安装 | 支持 | 禁用,保留源码链接 |
| GitHub、Git、任意远程 tarball、来源不明 | 支持 | 禁用,不自动换源 |
| 只读安装、缺少运行命令、profile 不明确 | 支持或给出检查环境限制 | 禁用,提供说明 |
| 目标 Node 要求不满足 | 显示发现的版本和不兼容原因 | 禁用,不升级 Host 运行时 |
| 已安装更新但尚未重启 | 可展示已知信息 | 禁用第二次安装 |
**不能把所有符号链接都判定为开发安装。** pnpm 正常依赖可能通过符号链接指向其存储目录;必须结合依赖 spec、清单和解析结果判断。相反,不能仅因目录里有 `package.json` 就把 `link:` 安装当成 npm 包。
源码模式的手动说明应警告:执行 npm 安装会替换该 profile 的开发链接。本期不提供一键迁移,也不替用户恢复、切换或拉取源码。
## 6. 实际执行命令与 Desktop 适配
### 6.1 标准 Harness
以 `web` profile、目标版本 `3.0.8` 为示例,安装命令语义为:
```sh
dsh plugin --profile web add -w --save-exact @xmanrui/dsh-im@3.0.8 --registry=https://registry.npmjs.org/
```
实际 profile 和版本来自 Host 已验证的更新记录,不写死为示例值。`--save-exact` 保存精确版本;不使用 `@latest` 在点击后重新选择版本,不执行无包名的全量 `update`。[pnpm 参数说明](https://pnpm.io/cli/add)
Host 通过异步子进程执行,传递参数数组,不拼接 shell 命令。包管理器可能为了目标包而调整共享依赖,因此不能承诺 profile 的其他依赖文件完全不变;必须记录并检查变更。
系统没有可用 `dsh` 时不自动全局安装它,也不转而执行 `npx ...@latest` 下载另一个 Harness。本机源码启动可使用明确的源码 CLI 入口,但只用于已验证的开发适配;无法确定时给出手动指引。
### 6.2 DSH Desktop
**实际实现已简化:**直接复用 Desktop 提供的 `desktopPnpm.runPlugin(args, profileDir, signal)`,由宿主自己配置以下 bootstrap、Electron Node、pnpm 和进程生命周期;不在插件里另造执行器。通过 `ctx.get()` 读取并验证 `desktopProfiles.current`、`desktopPnpm`、`desktopPnpmBootstrap`,不切换 profile。
macOS 适配器使用 Desktop 内置的 CLI bootstrap,与 Desktop 自己的终端执行路径保持一致:
```text
可执行程序:当前 Desktop 的 process.execPath
参数:--expose-internals <已验证的 desktop-cli.js>
plugin --profile <当前 profile> add -w --save-exact
@xmanrui/dsh-im@<确认版本> --registry=https://registry.npmjs.org/
子进程环境:ELECTRON_RUN_AS_NODE=1,当前 DSH_HOME,Desktop 的 pnpm PATH
```
`desktop-cli.js` 路径从当前安装的 Desktop 解析,核对包身份、版本与文件存在性,不把本机 `/Applications/...` 路径写死在插件里。保留 Desktop 的 pnpm、Electron 原生依赖构建环境和安全策略;不偷偷改用外部 Node 处理 Desktop 的依赖。
这属于有版本边界的 Desktop 适配,不应被描述成所有 Electron 应用都提供的通用 API。已完成的只读帮助探测证明入口可以启动,尚不证明安装、失败恢复和各平台兼容已经通过。
Windows 应使用受控的 Node/CLI 启动方式,或专门处理 `.cmd` shim 的适配器;不能假设 `shell: false` 能直接执行所有 `.cmd`。未知 Desktop 版本或入口变化时返回“不支持自动更新”,保留手动入口,不跨 Host 回退。
无论哪个执行器,都不调用 Desktop 的 `requestRestart()`、profile 切换、Loader 刷新、浏览器 reload 或进程退出接口来应用更新。
## 7. 一次更新的完整流程
```mermaid
flowchart TD
A[进入 IM 机器人设置页] --> B[读取 Host 本地更新状态]
B --> C[用户点击检查更新]
C --> D[读取 npm 稳定版并校验]
D --> E{有新版且允许安装?}
E -->|否| F[显示结果或手动指引]
E -->|是| G[用户确认目标版本与 profile]
G --> H[锁定任务并再次校验环境]
H --> I[备份恢复信息并异步安装]
I --> J{安装命令与磁盘校验通过?}
J -->|否| K[显示失败或待人工恢复]
J -->|是| L[已安装,待手动重启]
L --> M[用户自行重启 Harness 或 Desktop]
M --> N[核验运行版本和安装版本]
N --> O[确认更新生效]
```
安装任务由 Host 持有。RPC 提交后立即返回任务标识,页面轮询状态,不把整个安装过程挂在一个长连接请求上。建议安装期间每 1 秒查询一次,结束后停止轮询;卸载组件时清理定时器和正在读取状态的请求。
开始安装前获取该 profile 的更新锁,校验确认记录和权限,重新读取清单,确认来源仍为 npm、已安装版本未变化,随后写任务记录及恢复信息。只有全部预检成功才启动子进程。
安装退出码为 0 后,还需重新解析当前 profile 的包入口,核对包名、目标版本、必要的 Host/Client 入口和 bundle 声明。通过后仅进入 `restart-required`,不提前宣布新代码已运行。
手动重启后,新 Host 读取自己的运行版本及该 profile 的任务记录;只有运行版本、安装版本与该任务目标一致,才记录生效。若外部操作装了另一个版本,显示实际状态和不一致原因,不冒认本次任务成功。
## 8. RPC 与状态设计
新增独立逻辑通道 `/dsh-im`,不挂到任一渠道 RPC 下。
| 方法 | 输入 | 职责 |
| --- | --- | --- |
| `update.status` | 空对象 | 返回当前版本、安装资格、检查结果和安装任务状态,不访问外网 |
| `update.check` | 空对象 | 查询固定 npm 包,返回校验结果和短时有效的 `checkId` |
| `update.install` | `checkId`、`requestId` | 消费已确认记录,幂等创建安装任务,立即返回任务状态 |
前端不传包名、registry URL、shell 命令、profile、文件路径或任意版本。即使用户绕过页面直接调用 RPC,也必须执行同一套预检。
公开状态字段包括:`runningVersion`、`installedVersion`、`latestVersion`、`profileName`、`environmentKind`、`canInstall`、`blockedReason`、`checkedAt` 和任务的 `id/state/targetVersion/message`。绝对路径、环境变量、认证值及子进程原始输出不直接返回浏览器。
检查结果与安装任务分开记录。安装状态至少包含:`idle`、`installing`、`verifying`、`restart-required`、`completed`、`failed`、`interrupted`。页面刷新后从 Host 恢复,不能仅靠 React state 判定成功。
同一 `requestId` 重试应返回原任务,不启动第二个安装。提交响应丢失时先查询状态;`checkId` 失效不自动安装当前最新版本。安装期间的其他提交返回“已有更新任务”,待重启状态也不接受新安装。
## 9. 权限、失败与恢复
### 9.1 权限与并发
更新属于安装本机代码的管理操作。本期 RPC 固定使用 Harness 已有的本机/受信 IPC 授权边界,采用 `loopback` 策略,不因为普通机器人管理配置使用 `trusted-host` 就自动放宽更新权限。不得只靠隐藏按钮、User-Agent 或客户端声明来识别权限。
不新增聊天内 `/update` 命令,不让普通 IM 联系人触发安装。不使用 `sudo`,不自动授权构建脚本,不绕过 pnpm 安全策略,不修改全局 PATH 或用户 shell 配置。
锁以 Harness home 与 profile 为作用域,同时防止本插件多窗口、多 Host 重复安装。它不能阻止用户另开终端或插件市场修改同一个 profile;对此必须提示并检测异常变化,不能声称已经提供跨所有工具的全局互斥。
锁必须原子获取,并记录任务所有者;释放时核对所有者,不能用“先检查文件不存在,再创建”的方式冒充互斥。任务状态文件采用原子替换,写入失败时不继续启动安装。
### 9.2 持久记录
任务与恢复信息放在 Harness home 下独立的 dsh-im 更新状态目录,按 profile 分隔,不能放进会被替换的包目录。目录和文件使用适当的私有权限,并校验路径归属。
记录目标包和版本、旧版本、profile 身份、阶段、时间、已脱敏错误,以及安装前 `package.json`、锁文件和必要 pnpm 工作区配置的快照/摘要。不要备份整份 Harness home,不复制机器人 Token、会话历史或 `.npmrc` 认证内容。
子进程输出限长、脱敏后仅保留必要诊断,不收集完整环境变量。恢复信息的保留与清理限定在更新器自有目录,不能删除包管理器缓存、现有 profile 或正在运行的任务记录。
### 9.3 失败行为
| 情况 | 处理 |
| --- | --- |
| npm 网络失败、超时、限流、非法响应 | 检查失败;可展示带时间的旧结果,不显示已是最新,不换源 |
| 确认记录过期、版本或 profile 已变化 | 拒绝安装,重新检查和确认 |
| 目录不可写、命令缺失、运行时不兼容 | 尽量在启动安装前拒绝,返回明确原因 |
| 构建策略或交互确认阻塞 | 不自动批准;终止该次自动流程,转手动处理 |
| 命令非零退出 | 记录失败;重新读取磁盘状态,不能假定包完全没变 |
| 命令成功但版本/入口校验不符 | 校验失败,不显示待重启成功 |
| 页面关闭、切换渠道或刷新 | Host 任务继续,页面再次打开读取同一任务 |
| Host 正常退出或插件卸载 | 对本任务子进程做有界清理,记录中断;不安排自动重启 |
| Host 崩溃或断电 | 下次启动检查残留任务和安装状态,不自动继续安装或直接删除锁 |
| 用户通过其他工具更改包 | 显示实际状态;不恢复旧快照覆盖他人的变更 |
建议给安装设置 15 分钟截止时间及有界的子进程清理时间;只处理本任务拥有的进程树,不能误杀其他 pnpm、Node 或 Harness 进程。无法确认残留安装是否结束时保持“需人工检查”,不直接重试。
**本期不承诺自动回滚或零中断升级。** pnpm 失败可能已经修改部分依赖;备份清单不等于还原安装目录。首期提供旧版本和对应的精确 npm 重装指引,避免自动覆盖整个 profile。回滚是否完成同样需要重新校验及手动重启,不以执行过一条恢复命令为准。
## 10. 本机与 Desktop 测试方案
### 10.1 保留日常开发环境
现有 `web`、`desktop` 的源码链接保持不变。实现后在这套环境构建并手动重启,可验证入口位置、npm 检查、状态显示,以及“源码安装不能自动替换”的保护逻辑。
不修改当前仓库版本号来伪造旧版本,不把生产 `link:` 安装改成 npm 安装来凑一次演示。测试用版本和故障只通过测试工厂、临时包及受控 fixture 注入,不新增生产可调用的“强制更新”“忽略来源”开关。
### 10.2 真实安装测试必须隔离数据
真实安装使用单独的 `DSH_HOME`、测试 profile 和临时工作目录,不复制已有机器人凭据、会话、用户 patch 或设置,不绑定日常机器人。
仅新建 profile 不足以隔离:当前渠道默认把配置和会话映射放在 `DSH_HOME/integrations` 下。Desktop 还要核对 Electron user-data、profile 选择状态和单实例锁;不能仅凭 `open -n` 或另一个窗口就断言隔离成功。具体隔离启动方式需在安装前实测,未确认时不在日常实例上尝试升级。
在 Desktop 的测试终端先核对欢迎信息中的 profile 和 DSH home,再验证内置 CLI、pnpm 与目标目录一致。测试准备阶段的任何 profile 切换或重启均由用户明确操作,不由更新按钮触发。
### 10.3 分层验证,分别记录证据
| 层次 | 验证内容 | 不可替代的部分 |
| --- | --- | --- |
| 纯逻辑与 RPC 测试 | SemVer、来源、权限、确认记录、任务互斥、脱敏、重启状态 | 不能证明真实包安装成功 |
| UI 交互测试 | 真实组件的检查、确认、错误、轮询、卸载与窄屏表现 | Mock 安装成功不等于完成更新 |
| 安装器集成测试 | 临时 profile 中实际执行 npm 旧版到新版安装,检查清单与入口 | 不证明旧版 npm 包拥有新按钮 |
| Desktop 按钮集成测试 | 含新功能的测试构建,在隔离 Desktop 中走完整按钮流程 | 使用测试 registry/fixture 时必须如实标注 |
| 公开 npm 完整验收 | 含更新功能的旧版检测并安装公开 npm 新版,手动重启后确认 | 需要实际存在合适的已发布版本 |
目前公开的 3.0.7、3.0.8 不含此更新按钮;把它们互相安装只能验证安装器,不能声称验收了新按钮。
首次开发期间可以使用测试专用 npm Registry 和临时测试构建覆盖完整交互,但生产包不能带可远程开启的测试来源或版本伪造功能。公开 npm 的完整闭环需要合适的已发布候选与目标版本;如采用独立 dist-tag 的预发布流程,必须另获发布授权,不默认发布测试包或改动 `latest`。
### 10.4 重点测试用例
- 相等版本、正常升级、`3.0.9 → 3.0.10`、预发布版、当前高于 npm、非法元数据。
- 源码 `link:` 与正常 pnpm 链接区分;GitHub 来源不自动迁移。
- Web 和 Desktop 的 profile 识别;多个 profile 共用源码时不误选。
- macOS Desktop 不依赖全局 `dsh`;缺少内置入口时明确降级;Windows shim 参数边界单独覆盖。
- registry 配置冲突、网络超时、401/403/429/5xx、镜像/重定向异常不触发 GitHub 回退。
- 未授权 RPC、畸形输入、伪造 `checkId`、重复 `requestId`、跨窗口并发、过期确认记录。
- 安装失败、部分写入、校验失败、超时、页面刷新、Host 中断及残留锁。
- 安装后 Host 仍旧版时必须显示待重启;旧前端不能把新磁盘版本当成运行版本。
- 手动重启后核对运行版本、前后端一致性、界面可用及机器人配置未被重置。
- 所有路径均不调用自动重启、热更新、GitHub 网络请求或全局安装。
标准 Web 与 macOS Desktop 真机测试通过后再声明相应支持。Windows 和其他 Desktop 版本分别记录测试结果,不能用 macOS 的帮助命令探测替代平台验收。
## 11. 实现拆分与文件范围
实际新增四个功能文件:`update-panel.js`、`update-runtime.mjs`、`update-service.mjs`、`update-rpc.mjs`。原方案的 environment/installer 合并为 runtime;请求适配复用现有页面,不新增 update-api 文件。以下表格保留原职责拆分,实际文件以第 13 节为准。
| 位置 | 改动 |
| --- | --- |
| `plugin-src/client/index.js` | 标题操作组,注入插件级 RPC;不改渠道业务 |
| `plugin-src/client/update-panel.js`(新增) | 更新按钮、确认窗口、任务状态与轮询生命周期 |
| `plugin-src/client/update-api.js`(新增) | 通道和 endpoint 常量、请求适配 |
| `plugin-src/client/styles.js`、`i18n.js` | 布局、窄屏与中英文文案 |
| `plugin-src/host/update-service.mjs`(新增) | 检查、确认记录、任务状态与恢复协调 |
| `plugin-src/host/update-environment.mjs`(新增) | profile、安装来源、运行时和 registry 资格判断 |
| `plugin-src/host/update-installer.mjs`(新增) | 标准 CLI / Desktop 执行器及有界子进程管理 |
| `plugin-src/host/update-rpc.mjs`(新增) | 参数验证、权限和公开状态投影 |
| `plugin-src/host/index.mjs` | 注册独立更新能力;初始化失败不得拖垮已有渠道 |
| `test/update-*.test.mjs`、现有 UI/Host 测试 | 测试矩阵与回归 |
| `package.json`、锁文件及构建检查 | 按需加入 SemVer 依赖,确认 Host/Client 打包正确 |
| `README.md`、`README.en.md`、`CHANGELOG.md` | 更新来源、操作步骤、限制和手动恢复说明 |
不修改九个渠道的消息处理、登录、会话或凭据协议。`lib/index.js`、`lib/client.js` 只通过构建生成,不手改产物。本期无需修改现有 GitHub 源安装 CLI;新更新功能不调用该路径。如要调整 CLI 默认安装来源,应另列改动,避免混入本需求。
## 12. 实施顺序与完成标准
### 实施顺序
1. 实现只读检查、版本区分和安装资格判断,先验证源码模式保护。
2. 实现有测试接缝的异步执行器、持久任务、锁、校验和失败说明。
3. 接入统一页面按钮、确认、进度与待重启状态。
4. 在隔离环境验证真实 npm 安装器,再做 Desktop 和 Web 交互测试。
5. 执行目标测试与既有 `npm run check`,更新文档和发布说明,列出尚未实测的平台。
### 完成标准
- 页面位置与状态符合第 3 节,版本号和渠道页面不回退。
- 更新链路只使用 npm,确认版本与安装版本一致,不安装其他目标包。
- 只有可确认的 npm 安装、正确 profile 和受信执行器能启动更新。
- 安装结果经过磁盘校验,重启前不显示已生效,重启后能核验。
- 不自动重启、不热更新、不改动现有源码链接,不重置机器人数据。
- 网络、权限、并发、中断和部分安装失败均有真实测试证据及明确提示。
- Web、Desktop、模拟测试与公开 npm 验收分开记录;未执行的步骤不写成已通过。
本次已实施并执行隔离 Desktop 和本机 npm 全局安装版 Web 的安装与手动重启测试;未发布 npm 包或关闭 Issue。仓库版本号仍为 3.0.8。
## 13. 实施与验收记录
### 实际改动
- `plugin-src/host/update-runtime.mjs`:校验当前 profile、包来源、入口和作用域 registry;复用宿主的 Desktop/CLI 异步执行能力。
- `plugin-src/host/update-service.mjs`:固定 npm 检查、SemVer、确认记录、任务锁、持久状态和磁盘校验。
- `plugin-src/host/update-rpc.mjs`:独立 `/dsh-im` 通道,严格输入和固定 `loopback` 权限。
- `plugin-src/client/update-panel.js`:一个按钮和对话框,复用轮询调度器,重新挂载后从 Host 恢复状态;手动重启后也可在原页面刷新本地状态。
- 修改现有 client/host 入口、样式、翻译、包验证脚本和文档;新增四组更新测试。
- 新增构建依赖 `semver@7.8.5`,打入 Host;未改渠道消息、登录、会话或凭据协议。
- 没有新配置项、强制更新开关、全局安装、自动回滚或自动重启逻辑;未改独立 GitHub 源安装器。
### Desktop 自带前端刷新
真机发现 Desktop 2.0.3 的 `dsh-client-hmr` 每 500ms 检测前端 bundle,改变后重建插件界面;Host HMR 则默认关闭。该刷新是宿主已有行为,不是更新功能主动调用。
本实现不包装宿主服务、不禁用全局 HMR,也不新增旧 bundle 路由。新版页面挂载后立即查询 `update.status`,以旧 Host 的 `runningVersion` 显示标题和“待手动重启”。界面更新不代表后台新代码已经生效。UI 和中英文文档已据此修正文案。
### 隔离启动
测试使用本机已安装的 `/Applications/DSH Desktop.app`(2.0.3),同时隔离 `DSH_HOME` 和 Electron user-data:
```sh
DSH_HOME="<独立临时目录>/dsh-home" \
DSH_TELEMETRY_DISABLED=1 \
"/Applications/DSH Desktop.app/Contents/MacOS/DSH Desktop" \
--user-data-dir="<独立临时目录>/electron-data"
```
本次使用系统临时目录下的独立根目录,profile 为 `update-test`;具体路径仅保留在本地验收记录中。没有复制日常配置、机器人凭据或模型 Key;日常源码链接没有替换为 npm 安装。
### Desktop 实测结果
1. 在隔离 profile 从官方 npm 安装真实 3.0.7,再覆盖含本功能的 3.0.7 测试构建;仓库版本未降低。覆盖使用新文件加原子替换,没有原地修改 pnpm 硬链接。
2. 在原生设置页点击新按钮,查到官方 npm 3.0.8,并显示正确目标 `update-test`。
3. 原生界面确认安装后,真实 pnpm 完成 3.0.7 → 3.0.8,profile 保存精确依赖 3.0.8;磁盘入口校验通过,任务为 `restart-required`。
4. 安装期间 Desktop PID 17477 及启动时间保持不变,没有更新器发起的重启。
5. 公开 3.0.8 尚不含新按钮,宿主加载该前端后按钮消失。为验证含功能目标版本的状态恢复,在隔离包覆盖当前代码的 3.0.8 测试构建;没有伪造 registry 或再次安装。
6. 新前端正确显示:后台运行 3.0.7、已安装 3.0.8、页面 3.0.8、待手动重启。由此确认后台没有随前端重新挂载而升级。
7. 在测试窗口使用 Desktop 原有 Restart 菜单并手动确认;新 PID 19287,界面从新 Host 获取 3.0.8,任务变为 completed。
8. 临时给测试 profile 设置冲突的作用域 registry,界面正确显示源冲突且不允许安装;该临时设置已删除。
真实安装记录位于临时根目录 `real-npm-install-result.json`,截图位于 `evidence/`。记录不包含模型或机器人凭据。
### 验收边界
- 已验证新按钮驱动真实官方 npm 安装、磁盘校验、重启前旧 Host、手动重启后新 Host和前端状态恢复。
- 当前公开 3.0.7/3.0.8 都不含更新器,因此还不能验证“已发布且含更新器的旧版 → 已发布且含更新器的新版”完整闭环;没有为此发布包或改动 latest。
- 开发期间连续覆盖同版本前端测试构建后,旧设置页曾无法再次打开对话框;手动刷新后最终构建的检查和已生效状态均通过。没有为此加入自动刷新或修改宿主 HMR,发布后的版本间前端替换仍需单独验收。
- Windows/Linux Desktop 未真机验收;不能把 macOS 的测试推广为所有平台和版本已通过。
- 标准 Windows CLI 目前关闭按钮安装,保留检查和手动更新;没有猜测执行 .cmd shim。
- Desktop 阶段的 `npm run check`:1619/1619 通过,0 失败、跳过或取消;Host/Client 构建和发行包校验均通过。当时更新专项为 59 项,后续 Web 修复增加了回归用例。
- 早期 Web 烟测使用本机已有的 `apps/cli/lib/bin.js` 和独立临时 DSH_HOME:`update.status`、`update.check` 均 HTTP 200,正确返回 web/cli、运行及安装版本 3.0.8、官方 npm 最新版本 3.0.8、`source-install` 和 `canInstall:false`。首页 200 且包含插件;进程及监听已清理。当时未做视觉测试或真实安装;该限制已通过第 14 节的本机 npm 全局安装版 Web 验收补充。源码 CLI 的外部 FiberState 导出不匹配没有通过修改外部仓库来绕过。
- 模型调用和 IM 发消息不属于本次更新功能验收,无需模型 Key,本次没有使用或保存。
## 14. 本机 npm 全局安装版 Web 验收
### 环境与实际命令
本次使用已安装的 `@deepseek-ai/dsh@0.1.1-rc.2`,其目录不是源码链接:
- Node:`~/.nvm/versions/node/v24.13.0/bin/node`,版本 24.13.0。
- CLI:`~/.nvm/versions/node/v24.13.0/lib/node_modules/@deepseek-ai/dsh/lib/bin.js`。
- pnpm:10.29.2,由该 CLI 的既有插件管理流程调用。
- 独立测试根目录:系统临时目录下的新目录,仅使用其中的 `dsh-home/profiles/web` 和空 workspace;具体路径仅保留在本地验收记录中。
- 浏览器:本机内嵌浏览器,默认 1280 × 720 视口。
启动的是全局安装入口的 `web --host 127.0.0.1 --port 63972 --no-open`。测试设置独立 `DSH_HOME`、禁用 telemetry,没有填写模型 Key 或机器人凭据。没有把源码仓库直接启动当作本机安装版 Web 验收。
按钮使用当前 Node 和已校验 CLI 入口执行的命令,等价于:
```sh
dsh plugin --profile web add -w --save-exact @xmanrui/dsh-im@3.0.8 --registry=https://registry.npmjs.org/
```
`web` 来自当前 Host 的 profile,不接受浏览器传入的替代 profile。这里的 3.0.8 是本次官方 npm 检查和用户确认的目标,不是运行时写死的更新版本。
### 实测结果与修复
1. 从官方 npm 安装真实 3.0.7 到隔离 profile,并原子替换为含新功能的 3.0.7 测试 bundle。仓库版本始终为 3.0.8,不修改公开 npm 元数据。
2. 在「设置 → IM bots」确认按钮位于 GitHub 左侧,查询到官方 npm 3.0.8,确认窗口显示正确版本、来源、目标 `web` 和手动重启提示。
3. 第一轮安装成功,但发现同端口手动重启后,原页面会继续显示 3.0.7 / 待重启,而 Host 已是 3.0.8 / completed。没有用刷新整页掩盖此问题。
4. 增加手动「刷新状态」;重新打开「待手动重启」窗口也读取一次本地 `update.status`。不增加待重启期间的持续轮询,不查询 npm、不重启、不刷新页面。
5. 补充安装重试的迟到响应回归:读取到权威新状态时同步取消旧轮询,旧 Host 的迟到结果不能把 3.0.8 改回 3.0.7;新状态仍在安装时继续正常轮询。刷新失败可再次操作,卸载页面会取消读取。
6. 使用修复后的构建重新执行真实按钮安装。安装前后 PID 均为 30383,启动时间均为 `2026-08-28 06:17:44`;profile 清单和磁盘包均为精确 3.0.8,旧 Host 仍为 3.0.7,任务为 `restart-required`。
7. 真实安装结果先保存,再在隔离包覆盖含功能的 3.0.8 测试 bundle。原页面正确显示运行 3.0.7、已安装 3.0.8、页面 3.0.8。
8. 保持更新窗口打开,人工停止测试 Host 并以相同入口、profile 和端口 63972 重启;新 PID 31522。原页面仍保留待重启状态,证明没有通过重载页面自动恢复。
9. 点击「刷新状态」后,原页面标题和运行版本变为 3.0.8,状态为 `Updated version is active`;该 Host 的 `latestVersion`、`checkedAt` 仍为 null,确认没有额外查询 npm。
10. 修复动作按钮变为禁用时的焦点丢失:提交刷新、检查或安装前先聚焦现有更新窗口;「刷新状态 / 重新检查」复用同一按钮节点。最终 Web 实测「重新检查」期间及完成后焦点都在更新窗口,按钮可再次操作,控制台没有 error。没有为此新增全局监听或刷新页面。
### 保护行为
- 临时冲突的 `@xmanrui:registry` 会显示源冲突并移除安装操作;移除该测试配置后可重新检查。未改用户的 registry 配置。
- 任意附加安装选项被 `bad-request` 拒绝,伪造确认被 `check-expired` 拒绝,跨源安装请求返回 HTTP 403。
- 另用相同的全局安装 CLI 验证源码 profile:status/check 正常,但始终是 `source-install`、`canInstall:false`、`checkId:null`;伪造安装没有创建任务,清单和锁文件不变。
- 日常 `~/.dsh/profiles/web` 和 `desktop` 仍通过 `link:<项目绝对路径>` 指向原源码目录,未替换为 npm 安装。
### 证据与边界
Web 测试根目录下的 `real-npm-install-result-fixed.json` 保存真实 npm 安装及进程不变的结果;`web-run-fixed-after-restart.json` 保存人工重启记录;`evidence/fixed-web-update-completed.txt` 和同名 PNG 保存原页面恢复结果。首次旧状态问题、registry 冲突、RPC 拒绝和磁盘校验也保留了独立记录。
公开 3.0.7/3.0.8 均尚未包含此更新功能,所以两端界面使用隔离测试构建,真实包安装使用官方 npm;不能写成公开发布版已经具备新按钮。未发布 npm 或关闭 Issue,也未增加任何自动重启、热更新或页面刷新逻辑。
### 最终回归与清理
- 最终 `npm run check`:**1626/1626 通过**,0 失败、取消或跳过;Host/Client 构建和发行包校验通过。更新专项共 66 项。
- `git diff --check` 通过。最终焦点和完成状态保存在 `evidence/final-web-focus.json`、`final-web-completed.txt` 和同名 PNG。
- 本次 Web 测试标签页已关闭,测试 PID 31522 及其进程组已退出,63972 端口已释放;临时 `.npmrc` 和更新锁均不存在。
- 对比日常 `web`、`desktop` profile 的清单、锁文件及链接目标,均未改变;未停止或重启用户的日常实例。
- `cleanup.json` 保存清理结果。隔离 profile、测试构建和验收证据继续保留在临时目录,未宣称删除整个目录。

View file

@ -42,7 +42,7 @@ __export(index_exports, {
name: () => name
});
module.exports = __toCommonJS(index_exports);
var React20 = __toESM(require("react"), 1);
var React21 = __toESM(require("react"), 1);
// package.json
var package_default = {
@ -149,7 +149,8 @@ var package_default = {
"https-proxy-agent": "5.0.1",
react: "18.3.1",
"react-dom": "18.3.1",
"react-test-renderer": "18.3.1"
"react-test-renderer": "18.3.1",
semver: "7.8.5"
}
};
@ -310,6 +311,62 @@ var EN = Object.freeze({
"IM \u6E20\u9053": "IM channels",
"\u8BA9 DeepSeek Harness \u89E6\u624B\u53EF\u53CA": "Connecting DeepSeek Harness",
"\u5F53\u524D\u7248\u672C": "Current version",
"DSH-IM \u66F4\u65B0": "DSH-IM update",
"\u68C0\u67E5\u66F4\u65B0": "Check for updates",
"\u91CD\u65B0\u68C0\u67E5": "Check again",
"\u5237\u65B0\u72B6\u6001": "Refresh status",
"\u66F4\u65B0\u81F3": "Update to",
"\u5B89\u88C5\u66F4\u65B0": "Install update",
"\u6B63\u5728\u66F4\u65B0\u2026": "Updating\u2026",
"\u5F85\u624B\u52A8\u91CD\u542F": "Restart needed",
"\u8FD0\u884C\u7248\u672C": "Running version",
"\u5DF2\u5B89\u88C5\u7248\u672C": "Installed version",
"npm \u6700\u65B0\u7248\u672C": "Latest on npm",
"\u76EE\u6807 profile": "Target profile",
"\u76EE\u6807\u7248\u672C": "Target version",
"\u66F4\u65B0\u6765\u6E90": "Update source",
"\u9875\u9762\u7248\u672C": "Page version",
"\u65E0\u6CD5\u786E\u8BA4": "Unknown",
"\u53D1\u73B0\u65B0\u7248\u672C": "Update available",
"\u5DF2\u662F\u6700\u65B0\u7248\u672C": "Up to date",
"\u5F53\u524D\u7248\u672C\u65E0\u9700\u66F4\u65B0": "No newer version available",
"\u5DF2\u83B7\u53D6 npm \u6700\u65B0\u7248\u672C": "Latest npm version retrieved",
"\u68C0\u67E5 npm \u6700\u65B0\u7248\u672C\uFF0C\u4E0D\u4F1A\u81EA\u52A8\u5B89\u88C5\u3002": "Check the latest npm version. Nothing is installed automatically.",
"\u6B63\u5728\u4ECE npm \u68C0\u67E5\u6700\u65B0\u7248\u672C\u2026": "Checking npm for the latest version\u2026",
"\u6B63\u5728\u5B89\u88C5\uFF0C\u8BF7\u7A0D\u5019\u2026": "Installing, please wait\u2026",
"\u6B63\u5728\u6821\u9A8C\u5B89\u88C5\u7ED3\u679C\u2026": "Verifying the installation\u2026",
"\u5DF2\u5B89\u88C5\uFF0C\u5F85\u624B\u52A8\u91CD\u542F": "Installed \u2014 manual restart needed",
"\u66F4\u65B0\u5DF2\u751F\u6548": "Updated version is active",
"\u66F4\u65B0\u5931\u8D25": "Update failed",
"\u66F4\u65B0\u8BF7\u6C42\u5931\u8D25": "Update request failed",
"\u4EC5\u66F4\u65B0 DSH-IM\u3002\u5B89\u88C5\u5B8C\u6210\u540E\u9700\u624B\u52A8\u91CD\u542F\u540E\u53F0\uFF1B\u672C\u529F\u80FD\u4E0D\u4F1A\u81EA\u52A8\u91CD\u542F\u6216\u4E3B\u52A8\u5237\u65B0\u9875\u9762\u3002": "Only DSH-IM will be updated. Restart the backend manually after installation. This feature does not automatically restart it or initiate a page refresh.",
"\u5F53\u524D\u662F\u6E90\u7801\u6216\u94FE\u63A5\u5B89\u88C5\uFF0C\u53EA\u80FD\u68C0\u67E5\u7248\u672C\uFF1B\u8BF7\u624B\u52A8\u66F4\u65B0\u6E90\u7801\uFF0C\u6216\u8FC1\u79FB\u5230 npm \u5B89\u88C5\u3002": "This is a source or linked installation. You can check versions, but must update the source manually or migrate to an npm installation.",
"\u65E0\u6CD5\u786E\u8BA4\u5F53\u524D profile\uFF0C\u8BF7\u5728\u5BF9\u5E94\u7684 Harness \u73AF\u5883\u4E2D\u624B\u52A8\u66F4\u65B0\u3002": "The current profile could not be identified. Update manually in the corresponding Harness environment.",
"\u5F53\u524D\u8FD0\u884C\u73AF\u5883\u4E0D\u652F\u6301\u6309\u94AE\u5B89\u88C5\uFF0C\u8BF7\u624B\u52A8\u66F4\u65B0\u63D2\u4EF6\u3002": "This runtime cannot install through this button. Update the plugin manually.",
"\u5F53\u524D npm \u6E90\u914D\u7F6E\u4E0E\u5B98\u65B9\u6E90\u4E0D\u4E00\u81F4\uFF0C\u8BF7\u5148\u68C0\u67E5 registry \u914D\u7F6E\u3002": "The npm registry configuration differs from the official registry. Check your registry settings first.",
"\u5F53\u524D Host \u7684 Node.js \u7248\u672C\u4E0D\u6EE1\u8DB3\u65B0\u7248\u8981\u6C42\uFF0C\u8BF7\u5148\u66F4\u65B0\u8FD0\u884C\u73AF\u5883\u3002": "The Host Node.js version does not meet the new version\u2019s requirements. Update the runtime first.",
"\u65B0\u7248\u672C\u5DF2\u5B89\u88C5\uFF0C\u8BF7\u5728\u65B9\u4FBF\u65F6\u624B\u52A8\u91CD\u542F\u5F53\u524D Harness \u6216 Desktop\u3002": "The new version is installed. Manually restart this Harness or Desktop when convenient.",
"\u4E0A\u6B21\u5B89\u88C5\u7ED3\u679C\u65E0\u6CD5\u786E\u8BA4\uFF0C\u8BF7\u5148\u68C0\u67E5\u6B64 profile \u7684\u63D2\u4EF6\u5B89\u88C5\u72B6\u6001\u3002": "The previous installation result is uncertain. Check this profile\u2019s plugin installation first.",
"\u65E0\u6CD5\u8BBF\u95EE npm \u6216\u8BF7\u6C42\u8D85\u65F6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u65B0\u68C0\u67E5\u3002": "npm is unreachable or the request timed out. Check again later.",
"npm \u8FD4\u56DE\u7684\u7248\u672C\u4FE1\u606F\u65E0\u6548\uFF0C\u6682\u65F6\u65E0\u6CD5\u66F4\u65B0\u3002": "npm returned invalid release information. Updating is unavailable.",
"\u7248\u672C\u786E\u8BA4\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u68C0\u67E5\u540E\u518D\u5B89\u88C5\u3002": "The version confirmation expired. Check again before installing.",
"\u63D2\u4EF6\u5B89\u88C5\u72B6\u6001\u5DF2\u53D1\u751F\u53D8\u5316\uFF0C\u8BF7\u91CD\u65B0\u68C0\u67E5\u3002": "The plugin installation changed. Check again.",
"\u6B64 profile \u6B63\u5728\u66F4\u65B0\uFF0C\u8BF7\u7A0D\u540E\u67E5\u770B\u72B6\u6001\u3002": "This profile is already updating. Check its status shortly.",
"\u5B89\u88C5\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5F53\u524D\u5B89\u88C5\u72B6\u6001\u540E\u91CD\u8BD5\u3002": "Installation failed. Check the current installation before retrying.",
"\u5B89\u88C5\u7ED3\u679C\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u68C0\u67E5\u63D2\u4EF6\u7248\u672C\u3002": "Installation verification failed. Check the plugin version manually.",
"\u65E0\u6CD5\u5B89\u5168\u4FDD\u5B58\u66F4\u65B0\u72B6\u6001\uFF0C\u8BF7\u5148\u68C0\u67E5\u5F53\u524D\u5B89\u88C5\u7ED3\u679C\u3002": "Update state could not be saved safely. Check the current installation result before retrying.",
"\u4E0A\u6B21\u66F4\u65B0\u5DF2\u4E2D\u65AD\uFF0C\u8BF7\u68C0\u67E5\u5B89\u88C5\u72B6\u6001\u540E\u91CD\u8BD5\u3002": "The previous update was interrupted. Check the installation before retrying.",
"\u66F4\u65B0\u670D\u52A1\u5DF2\u5173\u95ED\uFF0C\u8BF7\u624B\u52A8\u91CD\u65B0\u6253\u5F00\u8BBE\u7F6E\u9875\u3002": "The update service has closed. Reopen settings manually.",
"\u66F4\u65B0\u8BF7\u6C42\u65E0\u6548\uFF0C\u8BF7\u91CD\u65B0\u68C0\u67E5\u7248\u672C\u3002": "The update request is invalid. Check the version again.",
"\u5F53\u524D\u63D2\u4EF6\u5B89\u88C5\u4E0D\u5B8C\u6574\u6216\u4E0E profile \u4E0D\u7B26\uFF0C\u8BF7\u624B\u52A8\u68C0\u67E5\u5B89\u88C5\u914D\u7F6E\u3002": "The plugin installation is incomplete or does not match this profile. Check the installation configuration manually.",
"\u65E0\u6CD5\u786E\u8BA4\u5F53\u524D npm \u6E90\u914D\u7F6E\uFF0C\u6682\u65F6\u4E0D\u80FD\u5B89\u88C5\u66F4\u65B0\u3002": "The npm registry configuration could not be verified. Installation is unavailable.",
"\u5B89\u88C5\u8D85\u65F6\uFF0C\u8BF7\u5148\u786E\u8BA4\u5F53\u524D\u5B89\u88C5\u72B6\u6001\uFF0C\u518D\u51B3\u5B9A\u662F\u5426\u91CD\u8BD5\u3002": "Installation timed out. Check the current installation before retrying.",
"\u66F4\u65B0\u8BF7\u6C42\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002": "The update request failed. Try again.",
"\u66F4\u65B0\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94\u3002": "The update service returned an unrecognized response.",
"\u5F53\u524D Host \u4E0D\u652F\u6301\u66F4\u65B0\u63A5\u53E3\uFF0C\u8BF7\u5148\u624B\u52A8\u66F4\u65B0\u63D2\u4EF6\u5E76\u91CD\u542F\u3002": "This Host does not support the update API. Update the plugin manually and restart first.",
"\u5173\u95ED\u7A97\u53E3\u4E0D\u4F1A\u53D6\u6D88\u5B89\u88C5\u3002\u8BF7\u52FF\u540C\u65F6\u5728\u5176\u4ED6\u7A97\u53E3\u7BA1\u7406\u6B64 profile \u7684\u63D2\u4EF6\u3002": "Closing this window does not cancel installation. Do not manage this profile\u2019s plugins in another window at the same time.",
"\u9875\u9762\u7248\u672C\u4E0E\u8FD0\u884C\u7248\u672C\u4E0D\u540C\uFF0C\u8BF7\u624B\u52A8\u5237\u65B0\u9875\u9762\uFF1B\u82E5\u4ECD\u4E0D\u4E00\u81F4\uFF0C\u8BF7\u624B\u52A8\u91CD\u542F Harness \u6216 Desktop\u3002": "The page and running versions differ. Refresh the page manually; if they still differ, manually restart Harness or Desktop.",
"\u8BF7\u5728\u673A\u5668\u4EBA\u7A7A\u95F2\u65F6\u5B89\u88C5\uFF1B\u5B89\u88C5\u4F1A\u4FEE\u6539\u5F53\u524D profile \u7684\u4F9D\u8D56\uFF0C\u5B8C\u6210\u540E\u9700\u624B\u52A8\u91CD\u542F\u3002": "Install while bots are idle. This changes the current profile\u2019s dependencies and requires a manual restart afterward.",
"AI Office": "AI Office",
"\uFF08\u5B9E\u9A8C\u529F\u80FD\uFF09": "(Experimental)",
"AI Office \u8BBE\u7F6E": "AI Office settings",
@ -2414,7 +2471,7 @@ function AccountCard({
const state = busy === "reconnect" ? "connecting" : account.state;
const tone = account.connected ? "success" : state === "error" ? "error" : "warning";
const stateLabel2 = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA";
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
const summary2 = account.error?.message ?? (account.connected ? null : account.health.summary);
return h2(
"article",
{ className: "ddt-card dim-botCard", tabIndex: -1, "data-bot-id": account.botId },
@ -2474,7 +2531,7 @@ function AccountCard({
"\u79FB\u9664\u63A5\u5165"
)
),
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
summary2 ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary2) : null,
account.lastMessageError ? h2(LastMessageErrorSummary, {
className: "ddt-summary",
error: account.lastMessageError
@ -3137,11 +3194,11 @@ function createTokenChannelApi(channel4, connectionSummary, {
agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog)
};
};
const presentError10 = (error) => ({
const presentError11 = (error) => ({
code: text2(error?.code, `${channel4.toUpperCase()}_ERROR`, 80),
message: text2(error?.message, `${channel4}\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5`)
});
return Object.freeze({ unwrapRpcResult: unwrapRpcResult10, normalizeSnapshot: normalizeSnapshot9, presentError: presentError10 });
return Object.freeze({ unwrapRpcResult: unwrapRpcResult10, normalizeSnapshot: normalizeSnapshot9, presentError: presentError11 });
}
// plugin-src/client/channels/discord/api.js
@ -3210,7 +3267,7 @@ function createTokenChannelSettings(definition) {
const state = busy === "reconnect" ? "connecting" : account.state;
const tone = account.connected ? "success" : state === "error" ? "error" : "warning";
const stateLabel2 = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA";
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
const summary2 = account.error?.message ?? (account.connected ? null : account.health.summary);
const identity = account.bot.username ? `@${account.bot.username}` : account.bot.idMasked;
return h2(
"article",
@ -3281,7 +3338,7 @@ function createTokenChannelSettings(definition) {
disabled: Boolean(busy)
}, "\u79FB\u9664\u63A5\u5165")
),
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
summary2 ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary2) : null,
account.lastMessageError ? h2(LastMessageErrorSummary, {
className: "ddt-summary",
error: account.lastMessageError
@ -4497,7 +4554,7 @@ function BotCard({
const repairTooltipId = React12.useId();
const stateForDisplay = busy === "reconnect" ? "connecting" : state;
const tone = stateForDisplay === "connected" ? "success" : stateForDisplay === "connecting" ? "warning" : "error";
const summary = actionError?.message ?? connection.error?.message ?? (connected ? null : health.summary);
const summary2 = actionError?.message ?? connection.error?.message ?? (connected ? null : health.summary);
const titleId = `bxf-bot-${connection.botId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
return h2(
"article",
@ -4611,10 +4668,10 @@ function BotCard({
"aria-label": `\u4ECE DeepSeek Harness \u79FB\u9664${bot.name}`
}, "\u79FB\u9664\u63A5\u5165")
),
summary ? h2(
summary2 ? h2(
"div",
{ className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : void 0 },
summary
summary2
) : null,
connection.lastMessageError ? h2(LastMessageErrorSummary, {
className: "bxf-healthSummary",
@ -6358,7 +6415,7 @@ function AccountCard2({
}) {
const tone = account.connected ? "success" : account.state === "error" ? "error" : "warning";
const stateLabel2 = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : account.state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA";
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
const summary2 = account.error?.message ?? (account.connected ? null : account.health.summary);
return h2(
"article",
{ className: "ddt-card dim-botCard", "data-bot-id": account.botId },
@ -6410,7 +6467,7 @@ function AccountCard2({
h2(Button7, { className: "dim-cardAction", onClick: onReconnect, disabled: Boolean(busy) }, busy === "reconnect" ? "\u68C0\u67E5\u4E2D\u2026" : account.connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"),
h2(Button7, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165")
),
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
summary2 ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary2) : null,
account.lastMessageError ? h2(LastMessageErrorSummary, {
className: "ddt-summary",
error: account.lastMessageError
@ -7906,7 +7963,7 @@ function AccountCard3({
}) {
const tone = account.connected ? "success" : account.state === "error" ? "error" : "warning";
const stateLabel2 = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : account.state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA";
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
const summary2 = account.error?.message ?? (account.connected ? null : account.health.summary);
return h2(
"article",
{ className: "ddt-card dim-botCard", "data-bot-id": account.botId },
@ -7958,7 +8015,7 @@ function AccountCard3({
h2(Button10, { className: "dim-cardAction", onClick: onReconnect, disabled: Boolean(busy) }, busy === "reconnect" ? "\u68C0\u67E5\u4E2D\u2026" : account.connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"),
h2(Button10, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165")
),
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
summary2 ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary2) : null,
account.lastMessageError ? h2(LastMessageErrorSummary, {
className: "ddt-summary",
error: account.lastMessageError
@ -8730,7 +8787,7 @@ function AccountCard4({
}) {
const state = busy === "reconnect" ? "connecting" : account.state;
const tone = account.connected ? "success" : state === "error" ? "error" : "warning";
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
const summary2 = account.error?.message ?? (account.connected ? null : account.health.summary);
return h2(
"article",
{ className: "dxw-card dim-botCard", tabIndex: -1, "data-bot-id": account.botId },
@ -8781,7 +8838,7 @@ function AccountCard4({
),
h2(Button12, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165")
),
summary ? h2("div", { className: "dxw-summary dim-cardSummary" }, summary) : null,
summary2 ? h2("div", { className: "dxw-summary dim-cardSummary" }, summary2) : null,
account.lastMessageError ? h2(LastMessageErrorSummary, {
className: "dxw-summary",
error: account.lastMessageError
@ -9933,7 +9990,7 @@ function WhatsappAccountCard({
const state = busy === "reconnect" ? "connecting" : account.state;
const tone = account.connected ? "success" : state === "error" ? "error" : "warning";
const stateLabel2 = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA";
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
const summary2 = account.error?.message ?? (account.connected ? null : account.health.summary);
return h2(
"article",
{ className: "ddt-card dim-botCard", "data-bot-id": account.botId },
@ -10002,7 +10059,7 @@ function WhatsappAccountCard({
disabled: Boolean(busy)
}, "\u79FB\u9664\u63A5\u5165")
),
summary ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary) : null,
summary2 ? h2("div", { className: "ddt-summary dim-cardSummary" }, summary2) : null,
account.lastMessageError ? h2(LastMessageErrorSummary, {
className: "ddt-summary",
error: account.lastMessageError
@ -10387,6 +10444,32 @@ var CSS11 = String.raw`
.dim-brandName { color: var(--dsw-alias-label-primary, #1f2329); font-size: 20px; line-height: 24px; font-weight: 800; letter-spacing: .04em; }
.dim-brandVersion { color: var(--dsw-alias-label-tertiary, #8f959e); font: 500 10px/16px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0; }
.dim-title p { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; font-weight: 500; white-space: nowrap; }
.dim-titleActions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.dim-updateButton { min-height: 30px; display: inline-flex; align-items: center; justify-content: center; gap: 3px; padding: 5px 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 18px; font-weight: 560; cursor: pointer; }
.dim-updateButton:hover:not(:disabled) { border-color: #aeb3bb; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
.dim-updateButton:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
.dim-updateButton:disabled { opacity: .55; cursor: default; }
.dim-updateTrigger { white-space: nowrap; }
.dim-updateBackdrop { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; background: rgb(15 17 21 / 42%); }
.dim-updateBackdrop, .dim-updateBackdrop * { box-sizing: border-box; }
.dim-updateDialog { width: min(480px, 100%); max-height: calc(100vh - 48px); overflow-y: auto; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 16px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 20px 70px rgb(0 0 0 / 20%); text-align: left; }
.dim-updateDialog:focus { outline: none; }
.dim-updateDialog h3 { margin: 22px 24px 8px; font-size: 18px; line-height: 25px; font-weight: 680; }
.dim-updateDescription { margin: 0 24px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 20px; }
.dim-updateBody { padding: 18px 24px 20px; }
.dim-updateVersions { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 18px; margin: 0 0 18px; font-size: 12px; line-height: 18px; }
.dim-updateVersions dt { color: var(--dsw-alias-label-secondary, #646a73); }
.dim-updateVersions dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dim-updateStatus { padding: 12px 14px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #f7f8fa); font-size: 13px; line-height: 20px; }
.dim-updateStatus strong { font-weight: 600; }
.dim-updateStatus p { margin: 6px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 19px; overflow-wrap: anywhere; }
.dim-updateStatusError { border-color: color-mix(in srgb, var(--dsw-alias-state-danger-primary, #d92d20) 25%, var(--dsw-alias-border-l2, #dfe1e5)); }
.dim-updateHint, .dim-updateError { margin: 12px 0 0; font-size: 12px; line-height: 19px; overflow-wrap: anywhere; }
.dim-updateHint { color: var(--dsw-alias-label-secondary, #646a73); }
.dim-updateError { color: var(--dsw-alias-state-danger-primary, #d92d20); }
.dim-updateFooter { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 14px 24px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
.dim-updateFooter .dim-updateButton:first-child { margin-right: auto; }
.dim-updatePrimary, .dim-updatePrimary:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #3370ff); color: #fff; background: var(--dsw-alias-state-business-primary, #3370ff); }
.dim-githubAction { position: relative; display: inline-flex; flex: none; }
.dim-githubLink { min-height: 30px; display: inline-flex; align-items: center; gap: 5px; flex: none; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-layer-1, #fff); font-size: 12px; line-height: normal; font-weight: 560; text-decoration: none; transition: border-color .15s ease, color .15s ease, background .15s ease; }
.dim-githubLink:hover { border-color: #aeb3bb; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
@ -10654,6 +10737,13 @@ var CSS11 = String.raw`
@media (max-width: 560px) {
.dim-title { flex-direction: column; gap: 10px; }
.dim-title p { white-space: normal; }
.dim-titleActions { justify-content: flex-start; }
.dim-updateBackdrop { padding: 12px; }
.dim-updateDialog { max-height: calc(100vh - 24px); }
.dim-updateDialog h3 { margin: 18px 18px 8px; }
.dim-updateDescription { margin: 0 18px; }
.dim-updateBody { padding: 16px 18px; }
.dim-updateFooter { padding: 12px 18px; }
.dim-githubTooltip { right: auto; left: 0; }
.dim-rail { grid-template-columns: minmax(0, 1fr); }
.dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }
@ -10686,6 +10776,393 @@ function installImStyles() {
return () => style.remove();
}
// plugin-src/client/update-panel.js
var React20 = __toESM(require("react"), 1);
var import_react_dom2 = require("react-dom");
var UPDATE_RPC_CHANNEL = "/dsh-im";
var ACTIVE_STATES4 = /* @__PURE__ */ new Set(["installing", "verifying"]);
var BLOCKED_REASONS = Object.freeze({
"source-install": "\u5F53\u524D\u662F\u6E90\u7801\u6216\u94FE\u63A5\u5B89\u88C5\uFF0C\u53EA\u80FD\u68C0\u67E5\u7248\u672C\uFF1B\u8BF7\u624B\u52A8\u66F4\u65B0\u6E90\u7801\uFF0C\u6216\u8FC1\u79FB\u5230 npm \u5B89\u88C5\u3002",
"unknown-profile": "\u65E0\u6CD5\u786E\u8BA4\u5F53\u524D profile\uFF0C\u8BF7\u5728\u5BF9\u5E94\u7684 Harness \u73AF\u5883\u4E2D\u624B\u52A8\u66F4\u65B0\u3002",
"unsupported-runtime": "\u5F53\u524D\u8FD0\u884C\u73AF\u5883\u4E0D\u652F\u6301\u6309\u94AE\u5B89\u88C5\uFF0C\u8BF7\u624B\u52A8\u66F4\u65B0\u63D2\u4EF6\u3002",
"registry-conflict": "\u5F53\u524D npm \u6E90\u914D\u7F6E\u4E0E\u5B98\u65B9\u6E90\u4E0D\u4E00\u81F4\uFF0C\u8BF7\u5148\u68C0\u67E5 registry \u914D\u7F6E\u3002",
"incompatible-node": "\u5F53\u524D Host \u7684 Node.js \u7248\u672C\u4E0D\u6EE1\u8DB3\u65B0\u7248\u8981\u6C42\uFF0C\u8BF7\u5148\u66F4\u65B0\u8FD0\u884C\u73AF\u5883\u3002",
"pending-restart": "\u65B0\u7248\u672C\u5DF2\u5B89\u88C5\uFF0C\u8BF7\u5728\u65B9\u4FBF\u65F6\u624B\u52A8\u91CD\u542F\u5F53\u524D Harness \u6216 Desktop\u3002",
"recovery-required": "\u4E0A\u6B21\u5B89\u88C5\u7ED3\u679C\u65E0\u6CD5\u786E\u8BA4\uFF0C\u8BF7\u5148\u68C0\u67E5\u6B64 profile \u7684\u63D2\u4EF6\u5B89\u88C5\u72B6\u6001\u3002"
});
var ERROR_MESSAGES = Object.freeze({
"check-failed": "\u65E0\u6CD5\u8BBF\u95EE npm \u6216\u8BF7\u6C42\u8D85\u65F6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u65B0\u68C0\u67E5\u3002",
"update-failed": "\u66F4\u65B0\u8BF7\u6C42\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002",
"invalid-release": "npm \u8FD4\u56DE\u7684\u7248\u672C\u4FE1\u606F\u65E0\u6548\uFF0C\u6682\u65F6\u65E0\u6CD5\u66F4\u65B0\u3002",
"check-expired": "\u7248\u672C\u786E\u8BA4\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u68C0\u67E5\u540E\u518D\u5B89\u88C5\u3002",
"installation-changed": "\u63D2\u4EF6\u5B89\u88C5\u72B6\u6001\u5DF2\u53D1\u751F\u53D8\u5316\uFF0C\u8BF7\u91CD\u65B0\u68C0\u67E5\u3002",
"update-busy": "\u6B64 profile \u6B63\u5728\u66F4\u65B0\uFF0C\u8BF7\u7A0D\u540E\u67E5\u770B\u72B6\u6001\u3002",
"install-failed": "\u5B89\u88C5\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5F53\u524D\u5B89\u88C5\u72B6\u6001\u540E\u91CD\u8BD5\u3002",
"verify-failed": "\u5B89\u88C5\u7ED3\u679C\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u68C0\u67E5\u63D2\u4EF6\u7248\u672C\u3002",
"state-unavailable": "\u65E0\u6CD5\u5B89\u5168\u4FDD\u5B58\u66F4\u65B0\u72B6\u6001\uFF0C\u8BF7\u5148\u68C0\u67E5\u5F53\u524D\u5B89\u88C5\u7ED3\u679C\u3002",
interrupted: "\u4E0A\u6B21\u66F4\u65B0\u5DF2\u4E2D\u65AD\uFF0C\u8BF7\u68C0\u67E5\u5B89\u88C5\u72B6\u6001\u540E\u91CD\u8BD5\u3002",
disposed: "\u66F4\u65B0\u670D\u52A1\u5DF2\u5173\u95ED\uFF0C\u8BF7\u624B\u52A8\u91CD\u65B0\u6253\u5F00\u8BBE\u7F6E\u9875\u3002",
"bad-request": "\u66F4\u65B0\u8BF7\u6C42\u65E0\u6548\uFF0C\u8BF7\u91CD\u65B0\u68C0\u67E5\u7248\u672C\u3002",
"invalid-installation": "\u5F53\u524D\u63D2\u4EF6\u5B89\u88C5\u4E0D\u5B8C\u6574\u6216\u4E0E profile \u4E0D\u7B26\uFF0C\u8BF7\u624B\u52A8\u68C0\u67E5\u5B89\u88C5\u914D\u7F6E\u3002",
"executor-unavailable": BLOCKED_REASONS["unsupported-runtime"],
"registry-check-failed": "\u65E0\u6CD5\u786E\u8BA4\u5F53\u524D npm \u6E90\u914D\u7F6E\uFF0C\u6682\u65F6\u4E0D\u80FD\u5B89\u88C5\u66F4\u65B0\u3002",
"install-interrupted": "\u4E0A\u6B21\u66F4\u65B0\u5DF2\u4E2D\u65AD\uFF0C\u8BF7\u68C0\u67E5\u5B89\u88C5\u72B6\u6001\u540E\u91CD\u8BD5\u3002",
"install-timeout": "\u5B89\u88C5\u8D85\u65F6\uFF0C\u8BF7\u5148\u786E\u8BA4\u5F53\u524D\u5B89\u88C5\u72B6\u6001\uFF0C\u518D\u51B3\u5B9A\u662F\u5426\u91CD\u8BD5\u3002",
"invalid-version": "npm \u8FD4\u56DE\u7684\u7248\u672C\u4FE1\u606F\u65E0\u6548\uFF0C\u6682\u65F6\u65E0\u6CD5\u66F4\u65B0\u3002"
});
function unwrapSnapshot(result) {
if (result?.ok === false) {
const error = new Error(result.error?.message || "\u66F4\u65B0\u8BF7\u6C42\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002");
error.code = result.error?.code;
throw error;
}
const value = result?.value;
if (result?.ok !== true || typeof value?.runningVersion !== "string" || typeof value?.canInstall !== "boolean") {
throw new Error("\u66F4\u65B0\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94\u3002");
}
return value;
}
function presentError10(error) {
const code = error?.rpcError?.code ?? error?.code ?? "";
const message = error?.rpcError?.message ?? error?.message ?? "";
if (code === "update-unavailable" || /(?:not[-_ ]found|unimplemented|unknown[-_ ](?:endpoint|channel)|no[-_ ]handler)/i.test(code) || /(?:not found|unknown (?:endpoint|channel)|not registered|no .*handler|HTTP 404)/i.test(message)) {
return "\u5F53\u524D Host \u4E0D\u652F\u6301\u66F4\u65B0\u63A5\u53E3\uFF0C\u8BF7\u5148\u624B\u52A8\u66F4\u65B0\u63D2\u4EF6\u5E76\u91CD\u542F\u3002";
}
return BLOCKED_REASONS[code] ?? ERROR_MESSAGES[code] ?? (message.slice(0, 400) || "\u66F4\u65B0\u8BF7\u6C42\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002");
}
function summary(snapshot, action, error) {
if (action === "checking") return "\u6B63\u5728\u4ECE npm \u68C0\u67E5\u6700\u65B0\u7248\u672C\u2026";
if (action === "starting" || snapshot?.job?.state === "installing") return "\u6B63\u5728\u5B89\u88C5\uFF0C\u8BF7\u7A0D\u5019\u2026";
if (snapshot?.job?.state === "verifying") return "\u6B63\u5728\u6821\u9A8C\u5B89\u88C5\u7ED3\u679C\u2026";
if (snapshot?.job?.state === "restart-required" || snapshot?.blockedReason === "pending-restart") {
return "\u5DF2\u5B89\u88C5\uFF0C\u5F85\u624B\u52A8\u91CD\u542F";
}
if (snapshot?.job?.state === "completed") return "\u66F4\u65B0\u5DF2\u751F\u6548";
if (snapshot?.job?.state === "failed") return "\u66F4\u65B0\u5931\u8D25";
if (snapshot?.job?.state === "interrupted") return "\u4E0A\u6B21\u66F4\u65B0\u5DF2\u4E2D\u65AD\uFF0C\u8BF7\u68C0\u67E5\u5B89\u88C5\u72B6\u6001\u540E\u91CD\u8BD5\u3002";
if (error) return "\u66F4\u65B0\u8BF7\u6C42\u5931\u8D25";
if (snapshot?.canInstall) return "\u53D1\u73B0\u65B0\u7248\u672C";
if (snapshot?.checkedAt && snapshot.latestVersion === snapshot.runningVersion) return "\u5DF2\u662F\u6700\u65B0\u7248\u672C";
if (snapshot?.blockedReason === "no-update") return "\u5F53\u524D\u7248\u672C\u65E0\u9700\u66F4\u65B0";
if (snapshot?.checkedAt) return "\u5DF2\u83B7\u53D6 npm \u6700\u65B0\u7248\u672C";
return "\u68C0\u67E5 npm \u6700\u65B0\u7248\u672C\uFF0C\u4E0D\u4F1A\u81EA\u52A8\u5B89\u88C5\u3002";
}
function retainDialogFocus(event) {
event?.currentTarget?.closest?.(".dim-updateDialog")?.focus?.({ preventScroll: true });
}
function UpdateDialog({ children, onClose }) {
const dialogRef = React20.useRef(null);
const titleId = React20.useId();
const descriptionId = React20.useId();
React20.useEffect(() => {
const previous = globalThis.document?.activeElement;
dialogRef.current?.focus?.();
return () => {
if (previous?.isConnected) previous.focus?.();
};
}, []);
const content = h2(
"div",
{
className: "dim-updateBackdrop",
onMouseDown: (event) => {
if (event.target === event.currentTarget) onClose();
}
},
h2(
"section",
{
ref: dialogRef,
className: "dim-updateDialog",
role: "dialog",
"aria-modal": "true",
"aria-labelledby": titleId,
"aria-describedby": descriptionId,
tabIndex: -1,
onKeyDown: (event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
}
if (event.key !== "Tab") return;
const buttons = dialogRef.current?.querySelectorAll?.("button:not(:disabled)");
if (!buttons?.length) return;
const first = buttons[0];
const last = buttons[buttons.length - 1];
const active = globalThis.document?.activeElement;
if (event.shiftKey && (active === first || active === dialogRef.current)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (active === last || active === dialogRef.current)) {
event.preventDefault();
first.focus();
}
}
},
h2("h3", { id: titleId }, "DSH-IM \u66F4\u65B0"),
h2(
"p",
{ id: descriptionId, className: "dim-updateDescription" },
"\u4EC5\u66F4\u65B0 DSH-IM\u3002\u5B89\u88C5\u5B8C\u6210\u540E\u9700\u624B\u52A8\u91CD\u542F\u540E\u53F0\uFF1B\u672C\u529F\u80FD\u4E0D\u4F1A\u81EA\u52A8\u91CD\u542F\u6216\u4E3B\u52A8\u5237\u65B0\u9875\u9762\u3002"
),
children
)
);
return typeof document !== "undefined" && document.body ? (0, import_react_dom2.createPortal)(content, document.body) : content;
}
function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const [snapshot, setSnapshot] = React20.useState(null);
const [action, setAction] = React20.useState("status");
const [error, setError] = React20.useState(null);
const [open, setOpen] = React20.useState(false);
const [uncertainInstall, setUncertainInstall] = React20.useState(false);
const mounted = React20.useRef(false);
const busy = React20.useRef(false);
const readController = React20.useRef(null);
const pollReadController = React20.useRef(null);
const installRequest = React20.useRef(null);
const onStatusRef = React20.useRef(onStatus);
onStatusRef.current = onStatus;
const accept = React20.useCallback((next) => {
setSnapshot(next);
onStatusRef.current?.(next);
}, []);
const acceptAuthoritative = (next) => {
pollReadController.current?.abort();
setUncertainInstall(false);
accept(next);
};
const invoke = React20.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== "function") {
const unavailable = new Error("\u5F53\u524D Host \u4E0D\u652F\u6301\u66F4\u65B0\u63A5\u53E3\uFF0C\u8BF7\u5148\u624B\u52A8\u66F4\u65B0\u63D2\u4EF6\u5E76\u91CD\u542F\u3002");
unavailable.code = "update-unavailable";
throw unavailable;
}
return unwrapSnapshot(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
React20.useEffect(() => {
mounted.current = true;
busy.current = true;
const controller = new AbortController();
void invoke("update.status", {}, controller.signal).then((next) => {
if (!controller.signal.aborted) accept(next);
}).catch((cause) => {
if (!controller.signal.aborted) setError(presentError10(cause));
}).finally(() => {
if (!controller.signal.aborted) {
busy.current = false;
setAction(null);
}
});
return () => {
mounted.current = false;
controller.abort();
readController.current?.abort();
};
}, [accept, invoke]);
const activeJob = ACTIVE_STATES4.has(snapshot?.job?.state);
const restartRequired = snapshot?.job?.state === "restart-required" || snapshot?.blockedReason === "pending-restart";
const shouldPoll = activeJob || uncertainInstall;
React20.useEffect(() => {
if (!shouldPoll) return void 0;
let controller;
const scheduler = createPollScheduler({
setTimeoutFn: (callback, delay) => globalThis.setTimeout(callback, delay),
clearTimeoutFn: (timer) => globalThis.clearTimeout(timer)
});
const poll = async () => {
const pendingController = new AbortController();
controller = pendingController;
pollReadController.current = pendingController;
let keepPolling = true;
try {
const next = await invoke("update.status", {}, pendingController.signal);
if (!pendingController.signal.aborted) {
accept(next);
setError(null);
setUncertainInstall(false);
keepPolling = ACTIVE_STATES4.has(next.job?.state);
}
} catch (cause) {
if (!pendingController.signal.aborted) setError(presentError10(cause));
} finally {
if (pollReadController.current === pendingController) pollReadController.current = null;
}
if (keepPolling) scheduler.schedule(poll, 1e3);
};
scheduler.schedule(poll, 1e3);
return () => {
controller?.abort();
scheduler.dispose();
};
}, [accept, invoke, shouldPoll]);
const readSnapshot = async (endpoint) => {
const checkLatest = endpoint === "update.check";
if (!mounted.current || busy.current || checkLatest && (activeJob || restartRequired)) return;
busy.current = true;
setAction(checkLatest ? "checking" : "status");
setError(null);
if (checkLatest) {
installRequest.current = null;
setSnapshot((current) => current ? { ...current, canInstall: false, checkId: null, latestVersion: null, checkedAt: null } : current);
}
const controller = new AbortController();
readController.current = controller;
try {
const next = await invoke(endpoint, {}, controller.signal);
if (!controller.signal.aborted && mounted.current) {
if (checkLatest) accept(next);
else acceptAuthoritative(next);
}
} catch (cause) {
if (!controller.signal.aborted && mounted.current) setError(presentError10(cause));
} finally {
if (!controller.signal.aborted && mounted.current) {
busy.current = false;
setAction(null);
}
}
};
const check = () => readSnapshot("update.check");
const refreshStatus = () => readSnapshot("update.status");
const install = async () => {
if (busy.current || !snapshot?.canInstall || !snapshot.checkId) return;
busy.current = true;
setAction("starting");
setError(null);
if (installRequest.current?.checkId !== snapshot.checkId) {
installRequest.current = {
checkId: snapshot.checkId,
requestId: globalThis.crypto?.randomUUID?.() ?? `update-${Date.now()}-${Math.random().toString(36).slice(2)}`
};
}
try {
const next = await invoke("update.install", installRequest.current);
if (mounted.current) acceptAuthoritative(next);
} catch (cause) {
if (mounted.current) {
setError(presentError10(cause));
setUncertainInstall(true);
}
} finally {
if (mounted.current) {
busy.current = false;
setAction(null);
}
}
};
const busyAction = action !== null;
const blocked = BLOCKED_REASONS[snapshot?.blockedReason] ?? ERROR_MESSAGES[snapshot?.blockedReason];
const canConfirm = snapshot?.canInstall && snapshot.checkId && !activeJob && !restartRequired;
const versionsDiffer = snapshot && clientVersion && snapshot.runningVersion !== clientVersion;
const failedJob = ["failed", "interrupted"].includes(snapshot?.job?.state);
const jobMessage = snapshot?.job?.message;
const targetVersion = snapshot?.job?.targetVersion;
const buttonLabel = action === "checking" ? "\u68C0\u67E5\u4E2D\u2026" : action === "starting" || activeJob ? "\u6B63\u5728\u66F4\u65B0\u2026" : restartRequired ? "\u5F85\u624B\u52A8\u91CD\u542F" : snapshot?.canInstall ? "\u66F4\u65B0\u81F3" : "\u68C0\u67E5\u66F4\u65B0";
return h2(
React20.Fragment,
null,
h2("button", {
type: "button",
className: "dim-updateButton dim-updateTrigger",
disabled: busyAction,
"aria-haspopup": "dialog",
onClick: () => {
setOpen(true);
if (restartRequired) void refreshStatus();
else if (!snapshot?.canInstall && !snapshot?.job) void check();
}
}, buttonLabel, buttonLabel === "\u66F4\u65B0\u81F3" ? ` v${snapshot.latestVersion}` : null),
open ? h2(
UpdateDialog,
{ onClose: () => setOpen(false) },
h2(
"div",
{ className: "dim-updateBody" },
h2(
"dl",
{ className: "dim-updateVersions" },
h2("dt", null, "\u8FD0\u884C\u7248\u672C"),
h2("dd", null, `v${snapshot?.runningVersion ?? clientVersion}`),
snapshot?.installedVersion && snapshot.installedVersion !== snapshot.runningVersion ? h2(
React20.Fragment,
null,
h2("dt", null, "\u5DF2\u5B89\u88C5\u7248\u672C"),
h2("dd", null, `v${snapshot.installedVersion}`)
) : null,
h2("dt", null, "npm \u6700\u65B0\u7248\u672C"),
h2("dd", null, snapshot?.latestVersion ? `v${snapshot.latestVersion}` : "\u5C1A\u672A\u68C0\u67E5"),
targetVersion ? h2(
React20.Fragment,
null,
h2("dt", null, "\u76EE\u6807\u7248\u672C"),
h2("dd", null, `v${targetVersion}`)
) : null,
h2("dt", null, "\u76EE\u6807 profile"),
h2("dd", null, snapshot?.profileName ?? "\u65E0\u6CD5\u786E\u8BA4"),
h2("dt", null, "\u66F4\u65B0\u6765\u6E90"),
h2("dd", null, "registry.npmjs.org"),
versionsDiffer ? h2(
React20.Fragment,
null,
h2("dt", null, "\u9875\u9762\u7248\u672C"),
h2("dd", null, `v${clientVersion}`)
) : null
),
h2(
"div",
{
className: `dim-updateStatus${error || failedJob ? " dim-updateStatusError" : ""}`,
role: "status",
"aria-live": "polite",
"aria-atomic": "true"
},
h2("strong", null, summary(snapshot, action, error)),
activeJob || action === "starting" ? h2("p", null, "\u5173\u95ED\u7A97\u53E3\u4E0D\u4F1A\u53D6\u6D88\u5B89\u88C5\u3002\u8BF7\u52FF\u540C\u65F6\u5728\u5176\u4ED6\u7A97\u53E3\u7BA1\u7406\u6B64 profile \u7684\u63D2\u4EF6\u3002") : null,
restartRequired ? h2("p", null, BLOCKED_REASONS["pending-restart"]) : null,
failedJob && jobMessage ? h2(
"p",
null,
BLOCKED_REASONS[jobMessage] ?? ERROR_MESSAGES[jobMessage] ?? jobMessage
) : null
),
error ? h2("p", { className: "dim-updateError", role: "alert" }, error) : null,
blocked && !restartRequired ? h2("p", { className: "dim-updateHint" }, blocked) : null,
versionsDiffer && !restartRequired ? h2("p", { className: "dim-updateHint" }, "\u9875\u9762\u7248\u672C\u4E0E\u8FD0\u884C\u7248\u672C\u4E0D\u540C\uFF0C\u8BF7\u624B\u52A8\u5237\u65B0\u9875\u9762\uFF1B\u82E5\u4ECD\u4E0D\u4E00\u81F4\uFF0C\u8BF7\u624B\u52A8\u91CD\u542F Harness \u6216 Desktop\u3002") : null,
canConfirm ? h2(
"p",
{ className: "dim-updateHint" },
"\u8BF7\u5728\u673A\u5668\u4EBA\u7A7A\u95F2\u65F6\u5B89\u88C5\uFF1B\u5B89\u88C5\u4F1A\u4FEE\u6539\u5F53\u524D profile \u7684\u4F9D\u8D56\uFF0C\u5B8C\u6210\u540E\u9700\u624B\u52A8\u91CD\u542F\u3002"
) : null
),
h2(
"footer",
{ className: "dim-updateFooter" },
h2("button", { type: "button", className: "dim-updateButton", onClick: () => setOpen(false) }, "\u5173\u95ED"),
restartRequired || !activeJob ? h2("button", {
type: "button",
className: "dim-updateButton",
disabled: busyAction,
onClick: (event) => {
retainDialogFocus(event);
void (restartRequired ? refreshStatus() : check());
}
}, restartRequired ? "\u5237\u65B0\u72B6\u6001" : "\u91CD\u65B0\u68C0\u67E5") : null,
canConfirm ? h2("button", {
type: "button",
className: "dim-updateButton dim-updatePrimary",
disabled: busyAction,
onClick: (event) => {
retainDialogFocus(event);
void install();
}
}, "\u5B89\u88C5\u66F4\u65B0") : null
)
) : null
);
}
// plugin-src/client/index.js
var name = "im-settings";
var inject = ["slots", "connection", "locale", "workspaces"];
@ -10808,18 +11285,23 @@ function IMSettingsTab({
weixinRpcCall,
whatsappRpcCall,
officeRpcCall,
updateRpcCall,
workspaceDirectoryPicker,
browserLocation = globalThis.location,
navigateToRecoveryUrl = replacePageLocation
}) {
const [selected, setSelected] = React20.useState("weixin");
const [loopbackRecovery, setLoopbackRecovery] = React20.useState(null);
const githubTooltipId = React20.useId();
const [selected, setSelected] = React21.useState("weixin");
const [loopbackRecovery, setLoopbackRecovery] = React21.useState(null);
const [runningVersion, setRunningVersion] = React21.useState(IM_PLUGIN_VERSION);
const githubTooltipId = React21.useId();
const active = CHANNELS.find((channel4) => channel4.id === selected) ?? CHANNELS[0];
const reportLoopbackRecovery = React20.useCallback((recovery) => {
const reportLoopbackRecovery = React21.useCallback((recovery) => {
setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery);
}, []);
const rpcCalls = React20.useMemo(() => createLoopbackAwareRpcCalls({
const reportUpdateStatus = React21.useCallback((snapshot) => {
setRunningVersion(snapshot.runningVersion);
}, []);
const rpcCalls = React21.useMemo(() => createLoopbackAwareRpcCalls({
dingtalkRpcCall,
discordRpcCall,
feishuRpcCall,
@ -10829,7 +11311,8 @@ function IMSettingsTab({
wecomRpcCall,
weixinRpcCall,
whatsappRpcCall,
officeRpcCall
officeRpcCall,
updateRpcCall
}, {
location: browserLocation,
onRecovery: reportLoopbackRecovery
@ -10843,6 +11326,7 @@ function IMSettingsTab({
reportLoopbackRecovery,
slackRpcCall,
telegramRpcCall,
updateRpcCall,
wecomRpcCall,
weixinRpcCall,
whatsappRpcCall
@ -10863,31 +11347,40 @@ function IMSettingsTab({
"div",
{ className: "dim-brandHeading" },
h2("strong", { className: "dim-brandName" }, "DSH-IM"),
h2("span", { className: "dim-brandVersion" }, `v${IM_PLUGIN_VERSION}`)
h2("span", { className: "dim-brandVersion" }, `v${runningVersion}`)
),
h2("p", null, "\u8BA9 DeepSeek Harness \u89E6\u624B\u53EF\u53CA")
),
h2(
"span",
{ className: "dim-githubAction" },
"div",
{ className: "dim-titleActions" },
h2(UpdatePanel, {
rpcCall: rpcCalls.updateRpcCall,
clientVersion: IM_PLUGIN_VERSION,
onStatus: reportUpdateStatus
}),
h2(
"a",
{
className: "dim-githubLink",
href: "https://github.com/xmanrui/dsh-im",
target: "_blank",
rel: "noopener noreferrer",
"aria-label": "dsh-im GitHub",
"aria-describedby": githubTooltipId
},
h2("span", null, "GitHub"),
h2("span", { className: "dim-githubArrow", "aria-hidden": "true" }, "\u2197")
),
h2("span", {
id: githubTooltipId,
className: "dim-githubTooltip",
role: "tooltip"
}, "\u5E2E\u52A9\u4E0E\u53CD\u9988 \xB7 \u524D\u5F80 GitHub")
"span",
{ className: "dim-githubAction" },
h2(
"a",
{
className: "dim-githubLink",
href: "https://github.com/xmanrui/dsh-im",
target: "_blank",
rel: "noopener noreferrer",
"aria-label": "dsh-im GitHub",
"aria-describedby": githubTooltipId
},
h2("span", null, "GitHub"),
h2("span", { className: "dim-githubArrow", "aria-hidden": "true" }, "\u2197")
),
h2("span", {
id: githubTooltipId,
className: "dim-githubTooltip",
role: "tooltip"
}, "\u5E2E\u52A9\u4E0E\u53CD\u9988 \xB7 \u524D\u5F80 GitHub")
)
)
),
h2(
@ -10970,6 +11463,7 @@ function apply(ctx) {
const whatsappRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal);
const slackRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal);
const officeRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(OFFICE_RPC_CHANNEL, endpoint, payload, signal);
const updateRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(UPDATE_RPC_CHANNEL, endpoint, payload, signal);
const workspaceDirectoryPicker = Object.freeze({
listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
pickDirectory: () => ctx.workspaces.pickDirectory()
@ -10991,6 +11485,7 @@ function apply(ctx) {
weixinRpcCall,
whatsappRpcCall,
officeRpcCall,
updateRpcCall,
workspaceDirectoryPicker
})
}, IMSettingsTab));

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View file

@ -25,7 +25,8 @@
"https-proxy-agent": "5.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-test-renderer": "18.3.1"
"react-test-renderer": "18.3.1",
"semver": "7.8.5"
},
"engines": {
"node": ">=22.19"
@ -2507,7 +2508,6 @@
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"peer": true,
"bin": {
"semver": "bin/semver.js"
},

View file

@ -102,6 +102,7 @@
"https-proxy-agent": "5.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-test-renderer": "18.3.1"
"react-test-renderer": "18.3.1",
"semver": "7.8.5"
}
}

View file

@ -9,6 +9,62 @@ const EN = Object.freeze({
'IM 渠道': 'IM channels',
'让 DeepSeek Harness 触手可及': 'Connecting DeepSeek Harness',
'当前版本': 'Current version',
'DSH-IM 更新': 'DSH-IM update',
'检查更新': 'Check for updates',
'重新检查': 'Check again',
'刷新状态': 'Refresh status',
'更新至': 'Update to',
'安装更新': 'Install update',
'正在更新…': 'Updating…',
'待手动重启': 'Restart needed',
'运行版本': 'Running version',
'已安装版本': 'Installed version',
'npm 最新版本': 'Latest on npm',
'目标 profile': 'Target profile',
'目标版本': 'Target version',
'更新来源': 'Update source',
'页面版本': 'Page version',
'无法确认': 'Unknown',
'发现新版本': 'Update available',
'已是最新版本': 'Up to date',
'当前版本无需更新': 'No newer version available',
'已获取 npm 最新版本': 'Latest npm version retrieved',
'检查 npm 最新版本,不会自动安装。': 'Check the latest npm version. Nothing is installed automatically.',
'正在从 npm 检查最新版本…': 'Checking npm for the latest version…',
'正在安装,请稍候…': 'Installing, please wait…',
'正在校验安装结果…': 'Verifying the installation…',
'已安装,待手动重启': 'Installed — manual restart needed',
'更新已生效': 'Updated version is active',
'更新失败': 'Update failed',
'更新请求失败': 'Update request failed',
'仅更新 DSH-IM。安装完成后需手动重启后台;本功能不会自动重启或主动刷新页面。': 'Only DSH-IM will be updated. Restart the backend manually after installation. This feature does not automatically restart it or initiate a page refresh.',
'当前是源码或链接安装,只能检查版本;请手动更新源码,或迁移到 npm 安装。': 'This is a source or linked installation. You can check versions, but must update the source manually or migrate to an npm installation.',
'无法确认当前 profile,请在对应的 Harness 环境中手动更新。': 'The current profile could not be identified. Update manually in the corresponding Harness environment.',
'当前运行环境不支持按钮安装,请手动更新插件。': 'This runtime cannot install through this button. Update the plugin manually.',
'当前 npm 源配置与官方源不一致,请先检查 registry 配置。': 'The npm registry configuration differs from the official registry. Check your registry settings first.',
'当前 Host 的 Node.js 版本不满足新版要求,请先更新运行环境。': 'The Host Node.js version does not meet the new version’s requirements. Update the runtime first.',
'新版本已安装,请在方便时手动重启当前 Harness 或 Desktop。': 'The new version is installed. Manually restart this Harness or Desktop when convenient.',
'上次安装结果无法确认,请先检查此 profile 的插件安装状态。': 'The previous installation result is uncertain. Check this profile’s plugin installation first.',
'无法访问 npm 或请求超时,请稍后重新检查。': 'npm is unreachable or the request timed out. Check again later.',
'npm 返回的版本信息无效,暂时无法更新。': 'npm returned invalid release information. Updating is unavailable.',
'版本确认已过期,请重新检查后再安装。': 'The version confirmation expired. Check again before installing.',
'插件安装状态已发生变化,请重新检查。': 'The plugin installation changed. Check again.',
'此 profile 正在更新,请稍后查看状态。': 'This profile is already updating. Check its status shortly.',
'安装失败,请检查当前安装状态后重试。': 'Installation failed. Check the current installation before retrying.',
'安装结果校验失败,请手动检查插件版本。': 'Installation verification failed. Check the plugin version manually.',
'无法安全保存更新状态,请先检查当前安装结果。': 'Update state could not be saved safely. Check the current installation result before retrying.',
'上次更新已中断,请检查安装状态后重试。': 'The previous update was interrupted. Check the installation before retrying.',
'更新服务已关闭,请手动重新打开设置页。': 'The update service has closed. Reopen settings manually.',
'更新请求无效,请重新检查版本。': 'The update request is invalid. Check the version again.',
'当前插件安装不完整或与 profile 不符,请手动检查安装配置。': 'The plugin installation is incomplete or does not match this profile. Check the installation configuration manually.',
'无法确认当前 npm 源配置,暂时不能安装更新。': 'The npm registry configuration could not be verified. Installation is unavailable.',
'安装超时,请先确认当前安装状态,再决定是否重试。': 'Installation timed out. Check the current installation before retrying.',
'更新请求失败,请重试。': 'The update request failed. Try again.',
'更新服务返回了无法识别的响应。': 'The update service returned an unrecognized response.',
'当前 Host 不支持更新接口,请先手动更新插件并重启。': 'This Host does not support the update API. Update the plugin manually and restart first.',
'关闭窗口不会取消安装。请勿同时在其他窗口管理此 profile 的插件。': 'Closing this window does not cancel installation. Do not manage this profile’s plugins in another window at the same time.',
'页面版本与运行版本不同,请手动刷新页面;若仍不一致,请手动重启 Harness 或 Desktop。': 'The page and running versions differ. Refresh the page manually; if they still differ, manually restart Harness or Desktop.',
'请在机器人空闲时安装;安装会修改当前 profile 的依赖,完成后需手动重启。': 'Install while bots are idle. This changes the current profile’s dependencies and requires a manual restart afterward.',
'AI Office': 'AI Office',
'(实验功能)': '(Experimental)',
'AI Office 设置': 'AI Office settings',

View file

@ -48,6 +48,7 @@ import {
replacePageLocation,
} from './loopback-recovery.js';
import { installImStyles } from './styles.js';
import { UpdatePanel, UPDATE_RPC_CHANNEL } from './update-panel.js';
import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
export const name = 'im-settings';
@ -155,17 +156,22 @@ export function IMSettingsTab({
weixinRpcCall,
whatsappRpcCall,
officeRpcCall,
updateRpcCall,
workspaceDirectoryPicker,
browserLocation = globalThis.location,
navigateToRecoveryUrl = replacePageLocation,
}) {
const [selected, setSelected] = React.useState('weixin');
const [loopbackRecovery, setLoopbackRecovery] = React.useState(null);
const [runningVersion, setRunningVersion] = React.useState(IM_PLUGIN_VERSION);
const githubTooltipId = React.useId();
const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
const reportLoopbackRecovery = React.useCallback((recovery) => {
setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery);
}, []);
const reportUpdateStatus = React.useCallback((snapshot) => {
setRunningVersion(snapshot.runningVersion);
}, []);
const rpcCalls = React.useMemo(() => createLoopbackAwareRpcCalls({
dingtalkRpcCall,
discordRpcCall,
@ -177,6 +183,7 @@ export function IMSettingsTab({
weixinRpcCall,
whatsappRpcCall,
officeRpcCall,
updateRpcCall,
}, {
location: browserLocation,
onRecovery: reportLoopbackRecovery,
@ -190,6 +197,7 @@ export function IMSettingsTab({
reportLoopbackRecovery,
slackRpcCall,
telegramRpcCall,
updateRpcCall,
wecomRpcCall,
weixinRpcCall,
whatsappRpcCall,
@ -200,8 +208,14 @@ export function IMSettingsTab({
h('div', { className: 'dim-brand' },
h('div', { className: 'dim-brandHeading' },
h('strong', { className: 'dim-brandName' }, 'DSH-IM'),
h('span', { className: 'dim-brandVersion' }, `v${IM_PLUGIN_VERSION}`)),
h('span', { className: 'dim-brandVersion' }, `v${runningVersion}`)),
h('p', null, '让 DeepSeek Harness 触手可及')),
h('div', { className: 'dim-titleActions' },
h(UpdatePanel, {
rpcCall: rpcCalls.updateRpcCall,
clientVersion: IM_PLUGIN_VERSION,
onStatus: reportUpdateStatus,
}),
h('span', { className: 'dim-githubAction' },
h('a', {
className: 'dim-githubLink',
@ -217,7 +231,7 @@ export function IMSettingsTab({
id: githubTooltipId,
className: 'dim-githubTooltip',
role: 'tooltip',
}, '帮助与反馈 · 前往 GitHub')),
}, '帮助与反馈 · 前往 GitHub'))),
),
h('div', { className: 'dim-layout' },
h('nav', { className: 'dim-rail', role: 'tablist', 'aria-label': 'IM 渠道' },
@ -318,6 +332,8 @@ export function apply(ctx) {
ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal);
const officeRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(OFFICE_RPC_CHANNEL, endpoint, payload, signal);
const updateRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(UPDATE_RPC_CHANNEL, endpoint, payload, signal);
const workspaceDirectoryPicker = Object.freeze({
listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
pickDirectory: () => ctx.workspaces.pickDirectory(),
@ -340,6 +356,7 @@ export function apply(ctx) {
weixinRpcCall,
whatsappRpcCall,
officeRpcCall,
updateRpcCall,
workspaceDirectoryPicker,
}),
}, IMSettingsTab));

View file

@ -17,6 +17,32 @@ const CSS = String.raw`
.dim-brandName { color: var(--dsw-alias-label-primary, #1f2329); font-size: 20px; line-height: 24px; font-weight: 800; letter-spacing: .04em; }
.dim-brandVersion { color: var(--dsw-alias-label-tertiary, #8f959e); font: 500 10px/16px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0; }
.dim-title p { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; font-weight: 500; white-space: nowrap; }
.dim-titleActions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.dim-updateButton { min-height: 30px; display: inline-flex; align-items: center; justify-content: center; gap: 3px; padding: 5px 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 18px; font-weight: 560; cursor: pointer; }
.dim-updateButton:hover:not(:disabled) { border-color: #aeb3bb; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
.dim-updateButton:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
.dim-updateButton:disabled { opacity: .55; cursor: default; }
.dim-updateTrigger { white-space: nowrap; }
.dim-updateBackdrop { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; background: rgb(15 17 21 / 42%); }
.dim-updateBackdrop, .dim-updateBackdrop * { box-sizing: border-box; }
.dim-updateDialog { width: min(480px, 100%); max-height: calc(100vh - 48px); overflow-y: auto; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 16px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 20px 70px rgb(0 0 0 / 20%); text-align: left; }
.dim-updateDialog:focus { outline: none; }
.dim-updateDialog h3 { margin: 22px 24px 8px; font-size: 18px; line-height: 25px; font-weight: 680; }
.dim-updateDescription { margin: 0 24px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 20px; }
.dim-updateBody { padding: 18px 24px 20px; }
.dim-updateVersions { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 18px; margin: 0 0 18px; font-size: 12px; line-height: 18px; }
.dim-updateVersions dt { color: var(--dsw-alias-label-secondary, #646a73); }
.dim-updateVersions dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dim-updateStatus { padding: 12px 14px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #f7f8fa); font-size: 13px; line-height: 20px; }
.dim-updateStatus strong { font-weight: 600; }
.dim-updateStatus p { margin: 6px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 19px; overflow-wrap: anywhere; }
.dim-updateStatusError { border-color: color-mix(in srgb, var(--dsw-alias-state-danger-primary, #d92d20) 25%, var(--dsw-alias-border-l2, #dfe1e5)); }
.dim-updateHint, .dim-updateError { margin: 12px 0 0; font-size: 12px; line-height: 19px; overflow-wrap: anywhere; }
.dim-updateHint { color: var(--dsw-alias-label-secondary, #646a73); }
.dim-updateError { color: var(--dsw-alias-state-danger-primary, #d92d20); }
.dim-updateFooter { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 14px 24px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
.dim-updateFooter .dim-updateButton:first-child { margin-right: auto; }
.dim-updatePrimary, .dim-updatePrimary:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #3370ff); color: #fff; background: var(--dsw-alias-state-business-primary, #3370ff); }
.dim-githubAction { position: relative; display: inline-flex; flex: none; }
.dim-githubLink { min-height: 30px; display: inline-flex; align-items: center; gap: 5px; flex: none; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-layer-1, #fff); font-size: 12px; line-height: normal; font-weight: 560; text-decoration: none; transition: border-color .15s ease, color .15s ease, background .15s ease; }
.dim-githubLink:hover { border-color: #aeb3bb; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
@ -284,6 +310,13 @@ const CSS = String.raw`
@media (max-width: 560px) {
.dim-title { flex-direction: column; gap: 10px; }
.dim-title p { white-space: normal; }
.dim-titleActions { justify-content: flex-start; }
.dim-updateBackdrop { padding: 12px; }
.dim-updateDialog { max-height: calc(100vh - 24px); }
.dim-updateDialog h3 { margin: 18px 18px 8px; }
.dim-updateDescription { margin: 0 18px; }
.dim-updateBody { padding: 16px 18px; }
.dim-updateFooter { padding: 12px 18px; }
.dim-githubTooltip { right: auto; left: 0; }
.dim-rail { grid-template-columns: minmax(0, 1fr); }
.dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }

View file

@ -0,0 +1,367 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { h } from './i18n.js';
import { createPollScheduler } from './lifecycle.js';
export const UPDATE_RPC_CHANNEL = '/dsh-im';
const ACTIVE_STATES = new Set(['installing', 'verifying']);
const BLOCKED_REASONS = Object.freeze({
'source-install': '当前是源码或链接安装,只能检查版本;请手动更新源码,或迁移到 npm 安装。',
'unknown-profile': '无法确认当前 profile,请在对应的 Harness 环境中手动更新。',
'unsupported-runtime': '当前运行环境不支持按钮安装,请手动更新插件。',
'registry-conflict': '当前 npm 源配置与官方源不一致,请先检查 registry 配置。',
'incompatible-node': '当前 Host 的 Node.js 版本不满足新版要求,请先更新运行环境。',
'pending-restart': '新版本已安装,请在方便时手动重启当前 Harness 或 Desktop。',
'recovery-required': '上次安装结果无法确认,请先检查此 profile 的插件安装状态。',
});
const ERROR_MESSAGES = Object.freeze({
'check-failed': '无法访问 npm 或请求超时,请稍后重新检查。',
'update-failed': '更新请求失败,请重试。',
'invalid-release': 'npm 返回的版本信息无效,暂时无法更新。',
'check-expired': '版本确认已过期,请重新检查后再安装。',
'installation-changed': '插件安装状态已发生变化,请重新检查。',
'update-busy': '此 profile 正在更新,请稍后查看状态。',
'install-failed': '安装失败,请检查当前安装状态后重试。',
'verify-failed': '安装结果校验失败,请手动检查插件版本。',
'state-unavailable': '无法安全保存更新状态,请先检查当前安装结果。',
interrupted: '上次更新已中断,请检查安装状态后重试。',
disposed: '更新服务已关闭,请手动重新打开设置页。',
'bad-request': '更新请求无效,请重新检查版本。',
'invalid-installation': '当前插件安装不完整或与 profile 不符,请手动检查安装配置。',
'executor-unavailable': BLOCKED_REASONS['unsupported-runtime'],
'registry-check-failed': '无法确认当前 npm 源配置,暂时不能安装更新。',
'install-interrupted': '上次更新已中断,请检查安装状态后重试。',
'install-timeout': '安装超时,请先确认当前安装状态,再决定是否重试。',
'invalid-version': 'npm 返回的版本信息无效,暂时无法更新。',
});
function unwrapSnapshot(result) {
if (result?.ok === false) {
const error = new Error(result.error?.message || '更新请求失败,请重试。');
error.code = result.error?.code;
throw error;
}
const value = result?.value;
if (result?.ok !== true || typeof value?.runningVersion !== 'string'
|| typeof value?.canInstall !== 'boolean') {
throw new Error('更新服务返回了无法识别的响应。');
}
return value;
}
function presentError(error) {
const code = error?.rpcError?.code ?? error?.code ?? '';
const message = error?.rpcError?.message ?? error?.message ?? '';
if (code === 'update-unavailable'
|| /(?:not[-_ ]found|unimplemented|unknown[-_ ](?:endpoint|channel)|no[-_ ]handler)/i.test(code)
|| /(?:not found|unknown (?:endpoint|channel)|not registered|no .*handler|HTTP 404)/i.test(message)) {
return '当前 Host 不支持更新接口,请先手动更新插件并重启。';
}
return BLOCKED_REASONS[code] ?? ERROR_MESSAGES[code]
?? (message.slice(0, 400) || '更新请求失败,请重试。');
}
function summary(snapshot, action, error) {
if (action === 'checking') return '正在从 npm 检查最新版本…';
if (action === 'starting' || snapshot?.job?.state === 'installing') return '正在安装,请稍候…';
if (snapshot?.job?.state === 'verifying') return '正在校验安装结果…';
if (snapshot?.job?.state === 'restart-required' || snapshot?.blockedReason === 'pending-restart') {
return '已安装,待手动重启';
}
if (snapshot?.job?.state === 'completed') return '更新已生效';
if (snapshot?.job?.state === 'failed') return '更新失败';
if (snapshot?.job?.state === 'interrupted') return '上次更新已中断,请检查安装状态后重试。';
if (error) return '更新请求失败';
if (snapshot?.canInstall) return '发现新版本';
if (snapshot?.checkedAt && snapshot.latestVersion === snapshot.runningVersion) return '已是最新版本';
if (snapshot?.blockedReason === 'no-update') return '当前版本无需更新';
if (snapshot?.checkedAt) return '已获取 npm 最新版本';
return '检查 npm 最新版本,不会自动安装。';
}
function retainDialogFocus(event) {
event?.currentTarget?.closest?.('.dim-updateDialog')?.focus?.({ preventScroll: true });
}
function UpdateDialog({ children, onClose }) {
const dialogRef = React.useRef(null);
const titleId = React.useId();
const descriptionId = React.useId();
React.useEffect(() => {
const previous = globalThis.document?.activeElement;
dialogRef.current?.focus?.();
return () => { if (previous?.isConnected) previous.focus?.(); };
}, []);
const content = h('div', {
className: 'dim-updateBackdrop',
onMouseDown: (event) => { if (event.target === event.currentTarget) onClose(); },
},
h('section', {
ref: dialogRef,
className: 'dim-updateDialog',
role: 'dialog',
'aria-modal': 'true',
'aria-labelledby': titleId,
'aria-describedby': descriptionId,
tabIndex: -1,
onKeyDown: (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onClose();
}
if (event.key !== 'Tab') return;
const buttons = dialogRef.current?.querySelectorAll?.('button:not(:disabled)');
if (!buttons?.length) return;
const first = buttons[0];
const last = buttons[buttons.length - 1];
const active = globalThis.document?.activeElement;
if (event.shiftKey && (active === first || active === dialogRef.current)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (active === last || active === dialogRef.current)) {
event.preventDefault();
first.focus();
}
},
},
h('h3', { id: titleId }, 'DSH-IM 更新'),
h('p', { id: descriptionId, className: 'dim-updateDescription' },
'仅更新 DSH-IM。安装完成后需手动重启后台;本功能不会自动重启或主动刷新页面。'),
children));
return typeof document !== 'undefined' && document.body
? createPortal(content, document.body)
: content;
}
export function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const [snapshot, setSnapshot] = React.useState(null);
const [action, setAction] = React.useState('status');
const [error, setError] = React.useState(null);
const [open, setOpen] = React.useState(false);
const [uncertainInstall, setUncertainInstall] = React.useState(false);
const mounted = React.useRef(false);
const busy = React.useRef(false);
const readController = React.useRef(null);
const pollReadController = React.useRef(null);
const installRequest = React.useRef(null);
const onStatusRef = React.useRef(onStatus);
onStatusRef.current = onStatus;
const accept = React.useCallback((next) => {
setSnapshot(next);
onStatusRef.current?.(next);
}, []);
const acceptAuthoritative = (next) => {
// Abort synchronously: effect cleanup alone can lose to an already queued reply.
pollReadController.current?.abort();
setUncertainInstall(false);
accept(next);
};
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== 'function') {
const unavailable = new Error('当前 Host 不支持更新接口,请先手动更新插件并重启。');
unavailable.code = 'update-unavailable';
throw unavailable;
}
return unwrapSnapshot(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
React.useEffect(() => {
mounted.current = true;
busy.current = true;
const controller = new AbortController();
void invoke('update.status', {}, controller.signal).then((next) => {
if (!controller.signal.aborted) accept(next);
}).catch((cause) => {
if (!controller.signal.aborted) setError(presentError(cause));
}).finally(() => {
if (!controller.signal.aborted) {
busy.current = false;
setAction(null);
}
});
return () => {
mounted.current = false;
controller.abort();
readController.current?.abort();
};
}, [accept, invoke]);
const activeJob = ACTIVE_STATES.has(snapshot?.job?.state);
const restartRequired = snapshot?.job?.state === 'restart-required'
|| snapshot?.blockedReason === 'pending-restart';
const shouldPoll = activeJob || uncertainInstall;
React.useEffect(() => {
if (!shouldPoll) return undefined;
let controller;
const scheduler = createPollScheduler({
setTimeoutFn: (callback, delay) => globalThis.setTimeout(callback, delay),
clearTimeoutFn: (timer) => globalThis.clearTimeout(timer),
});
const poll = async () => {
const pendingController = new AbortController();
controller = pendingController;
pollReadController.current = pendingController;
let keepPolling = true;
try {
const next = await invoke('update.status', {}, pendingController.signal);
if (!pendingController.signal.aborted) {
accept(next);
setError(null);
setUncertainInstall(false);
keepPolling = ACTIVE_STATES.has(next.job?.state);
}
} catch (cause) {
if (!pendingController.signal.aborted) setError(presentError(cause));
} finally {
if (pollReadController.current === pendingController) pollReadController.current = null;
}
if (keepPolling) scheduler.schedule(poll, 1_000);
};
scheduler.schedule(poll, 1_000);
return () => {
controller?.abort();
scheduler.dispose();
};
}, [accept, invoke, shouldPoll]);
const readSnapshot = async (endpoint) => {
const checkLatest = endpoint === 'update.check';
if (!mounted.current || busy.current || (checkLatest && (activeJob || restartRequired))) return;
busy.current = true;
setAction(checkLatest ? 'checking' : 'status');
setError(null);
if (checkLatest) {
installRequest.current = null;
setSnapshot((current) => current
? { ...current, canInstall: false, checkId: null, latestVersion: null, checkedAt: null }
: current);
}
const controller = new AbortController();
readController.current = controller;
try {
const next = await invoke(endpoint, {}, controller.signal);
if (!controller.signal.aborted && mounted.current) {
if (checkLatest) accept(next);
else acceptAuthoritative(next);
}
} catch (cause) {
if (!controller.signal.aborted && mounted.current) setError(presentError(cause));
} finally {
if (!controller.signal.aborted && mounted.current) {
busy.current = false;
setAction(null);
}
}
};
const check = () => readSnapshot('update.check');
const refreshStatus = () => readSnapshot('update.status');
const install = async () => {
if (busy.current || !snapshot?.canInstall || !snapshot.checkId) return;
busy.current = true;
setAction('starting');
setError(null);
if (installRequest.current?.checkId !== snapshot.checkId) {
installRequest.current = {
checkId: snapshot.checkId,
requestId: globalThis.crypto?.randomUUID?.()
?? `update-${Date.now()}-${Math.random().toString(36).slice(2)}`,
};
}
try {
// Closing this page only aborts reads, never an accepted Host installation.
const next = await invoke('update.install', installRequest.current);
if (mounted.current) acceptAuthoritative(next);
} catch (cause) {
if (mounted.current) {
setError(presentError(cause));
setUncertainInstall(true);
}
} finally {
if (mounted.current) {
busy.current = false;
setAction(null);
}
}
};
const busyAction = action !== null;
const blocked = BLOCKED_REASONS[snapshot?.blockedReason] ?? ERROR_MESSAGES[snapshot?.blockedReason];
const canConfirm = snapshot?.canInstall && snapshot.checkId && !activeJob && !restartRequired;
const versionsDiffer = snapshot && clientVersion && snapshot.runningVersion !== clientVersion;
const failedJob = ['failed', 'interrupted'].includes(snapshot?.job?.state);
const jobMessage = snapshot?.job?.message;
const targetVersion = snapshot?.job?.targetVersion;
const buttonLabel = action === 'checking' ? '检查中…'
: action === 'starting' || activeJob ? '正在更新…'
: restartRequired ? '待手动重启'
: snapshot?.canInstall ? '更新至'
: '检查更新';
return h(React.Fragment, null,
h('button', {
type: 'button',
className: 'dim-updateButton dim-updateTrigger',
disabled: busyAction,
'aria-haspopup': 'dialog',
onClick: () => {
setOpen(true);
if (restartRequired) void refreshStatus();
else if (!snapshot?.canInstall && !snapshot?.job) void check();
},
}, buttonLabel, buttonLabel === '更新至' ? ` v${snapshot.latestVersion}` : null),
open ? h(UpdateDialog, { onClose: () => setOpen(false) },
h('div', { className: 'dim-updateBody' },
h('dl', { className: 'dim-updateVersions' },
h('dt', null, '运行版本'), h('dd', null, `v${snapshot?.runningVersion ?? clientVersion}`),
snapshot?.installedVersion && snapshot.installedVersion !== snapshot.runningVersion
? h(React.Fragment, null,
h('dt', null, '已安装版本'), h('dd', null, `v${snapshot.installedVersion}`)) : null,
h('dt', null, 'npm 最新版本'), h('dd', null, snapshot?.latestVersion ? `v${snapshot.latestVersion}` : '尚未检查'),
targetVersion ? h(React.Fragment, null,
h('dt', null, '目标版本'), h('dd', null, `v${targetVersion}`)) : null,
h('dt', null, '目标 profile'), h('dd', null, snapshot?.profileName ?? '无法确认'),
h('dt', null, '更新来源'), h('dd', null, 'registry.npmjs.org'),
versionsDiffer ? h(React.Fragment, null,
h('dt', null, '页面版本'), h('dd', null, `v${clientVersion}`)) : null),
h('div', {
className: `dim-updateStatus${error || failedJob ? ' dim-updateStatusError' : ''}`,
role: 'status',
'aria-live': 'polite',
'aria-atomic': 'true',
},
h('strong', null, summary(snapshot, action, error)),
activeJob || action === 'starting'
? h('p', null, '关闭窗口不会取消安装。请勿同时在其他窗口管理此 profile 的插件。') : null,
restartRequired ? h('p', null, BLOCKED_REASONS['pending-restart']) : null,
failedJob && jobMessage ? h('p', null,
BLOCKED_REASONS[jobMessage] ?? ERROR_MESSAGES[jobMessage] ?? jobMessage) : null),
error ? h('p', { className: 'dim-updateError', role: 'alert' }, error) : null,
blocked && !restartRequired ? h('p', { className: 'dim-updateHint' }, blocked) : null,
versionsDiffer && !restartRequired ? h('p', { className: 'dim-updateHint' }, '页面版本与运行版本不同,请手动刷新页面;若仍不一致,请手动重启 Harness 或 Desktop。') : null,
canConfirm ? h('p', { className: 'dim-updateHint' },
'请在机器人空闲时安装;安装会修改当前 profile 的依赖,完成后需手动重启。') : null),
h('footer', { className: 'dim-updateFooter' },
h('button', { type: 'button', className: 'dim-updateButton', onClick: () => setOpen(false) }, '关闭'),
restartRequired || !activeJob ? h('button', {
type: 'button', className: 'dim-updateButton', disabled: busyAction,
onClick: (event) => {
retainDialogFocus(event);
void (restartRequired ? refreshStatus() : check());
},
}, restartRequired ? '刷新状态' : '重新检查') : null,
canConfirm ? h('button', {
type: 'button', className: 'dim-updateButton dim-updatePrimary', disabled: busyAction,
onClick: (event) => {
retainDialogFocus(event);
void install();
},
}, '安装更新') : null)) : null);
}

View file

@ -10,6 +10,7 @@ import { apply as applyWeixin } from './channels/weixin/index.mjs';
import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
import { installUpdateRpc } from './update-rpc.mjs';
export const name = 'dsh-im-host';
export const inject = [
@ -27,6 +28,7 @@ function channelConfig(config, name) {
}
export function createImHostPlugin(internals = {}) {
const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
const startFeishu = internals.applyFeishu ?? applyFeishu;
const startWeixin = internals.applyWeixin ?? applyWeixin;
const startDingtalk = internals.applyDingtalk ?? applyDingtalk;
@ -64,6 +66,13 @@ export function createImHostPlugin(internals = {}) {
const logger = typeof ctx?.logger === 'function'
? ctx.logger(name)
: (ctx?.logger ?? console);
if (ctx?.connection?.rpc) {
try {
startUpdate(ctx);
} catch (error) {
logger.error?.('[dsh-im] failed to activate update management; continuing with channels', error);
}
}
const failures = [];
for (const [channel, start] of channels) {
try {

View file

@ -0,0 +1,50 @@
import { createUpdateRuntime } from './update-runtime.mjs';
import { createUpdateService } from './update-service.mjs';
export const UPDATE_RPC_CHANNEL = '/dsh-im';
export const UPDATE_ENDPOINTS = Object.freeze(['update.status', 'update.check', 'update.install']);
function validPayload(endpoint, payload) {
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
const keys = Object.keys(payload);
if (endpoint !== 'update.install') return keys.length === 0;
return keys.length === 2 && keys.every((key) => key === 'checkId' || key === 'requestId')
&& ['checkId', 'requestId'].every((key) => typeof payload[key] === 'string'
&& /^[A-Za-z0-9_-]{1,128}$/.test(payload[key]));
}
const PUBLIC_ERRORS = new Set([
'check-failed', 'invalid-release', 'check-expired', 'installation-changed', 'update-busy',
'install-failed', 'verify-failed', 'state-unavailable', 'interrupted', 'disposed',
'source-install', 'unknown-profile', 'unsupported-runtime', 'registry-conflict',
'incompatible-node', 'pending-restart', 'recovery-required', 'executor-unavailable',
'invalid-installation', 'registry-check-failed', 'install-timeout', 'install-interrupted', 'invalid-version',
]);
export function createUpdateRpcHandler(service) {
return async (endpoint, payload, signal) => {
if (!UPDATE_ENDPOINTS.includes(endpoint) || !validPayload(endpoint, payload)) {
return { ok: false, error: { code: 'bad-request', message: 'Invalid update request.' } };
}
if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'Request cancelled.' } };
try {
// The submitted install belongs to the Host, not the lifetime of this browser request.
const value = endpoint === 'update.install' ? await service.install(payload)
: endpoint === 'update.check' ? await service.check() : await service.status();
return { ok: true, value };
} catch (error) {
const code = PUBLIC_ERRORS.has(error?.code) ? error.code : 'update-failed';
return { ok: false, error: { code, message: code } };
}
};
}
export function installUpdateRpc(ctx, options = {}) {
const runtime = options.runtime ?? createUpdateRuntime({ ctx, moduleUrl: import.meta.url });
const service = options.service ?? createUpdateService({ runtime });
const dispose = ctx.connection.rpc.handle(UPDATE_RPC_CHANNEL, createUpdateRpcHandler(service), {
authority: 'loopback',
});
ctx.effect(() => () => service.close(), 'dsh-im: close update installer');
return dispose;
}

View file

@ -0,0 +1,338 @@
import { createHash } from 'node:crypto';
import { readFile, realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import semver from 'semver';
export const PACKAGE_NAME = '@xmanrui/dsh-im';
export const NPM_REGISTRY = 'https://registry.npmjs.org/';
const INSTALL_TIMEOUT_MS = 15 * 60_000;
const CONFIG_TIMEOUT_MS = 10_000;
const OUTPUT_LIMIT = 16 * 1024;
function failure(code, extra = {}) {
return Object.assign(new Error(code), { code, ...extra });
}
// Cordis 4's get() is the supported optional-service lookup. Putting these
// Desktop-only services in the plugin's inject array would disable web Hosts.
function service(ctx, name) {
return typeof ctx?.get === 'function' ? ctx.get(name) : ctx?.[name];
}
function inside(directory, filename) {
const suffix = relative(directory, filename);
return suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix);
}
async function readOptional(filename) {
try {
return await readFile(filename, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') return '';
throw error;
}
}
async function packageAt(directory) {
const contents = await readFile(join(directory, 'package.json'), 'utf8');
return { directory: await realpath(directory), manifest: JSON.parse(contents), contents };
}
async function containingPackage(filename, name) {
let directory = dirname(await realpath(filename));
while (true) {
try {
const found = await packageAt(directory);
return found.manifest?.name === name ? found : null;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
const parent = dirname(directory);
if (parent === directory) return null;
directory = parent;
}
}
function profileNameValid(name) {
return typeof name === 'string' && name.length > 0 && Buffer.byteLength(name) <= 255
&& !name.startsWith('-') && !['.', '..', 'node_modules'].includes(name)
&& !/[\\/\x00-\x1f\x7f<>:"|?*]/u.test(name);
}
function cliProfile(args) {
if (args[0] === 'web') return 'web';
let name;
for (let index = 0; index < args.length; index += 1) {
const token = args[index];
if (token === '--profile') name = args[++index];
else if (token.startsWith('--profile=')) name = token.slice('--profile='.length);
else if (token === '--patch') index += 1;
else if (!token.startsWith('--patch=')) break;
}
return name;
}
function dshHome(env, osHome) {
let selected = env.DSH_HOME?.trim() ? env.DSH_HOME : join(osHome, '.dsh');
if (selected === '~') selected = osHome;
else if (/^~[\\/]/u.test(selected)) selected = join(osHome, selected.slice(2));
return resolve(selected);
}
function registrySpec(spec) {
return typeof spec === 'string' && spec.trim().length > 0
&& (semver.validRange(spec) !== null || /^[A-Za-z][A-Za-z0-9._-]*$/u.test(spec));
}
async function validPackage(pkg) {
if (pkg.manifest?.name !== PACKAGE_NAME || semver.valid(pkg.manifest.version) === null) return false;
const entries = [
pkg.manifest.main,
pkg.manifest.exports?.['./client'],
pkg.manifest.dsh?.bundle?.patch,
];
for (const entry of entries) {
if (typeof entry !== 'string' || !entry || isAbsolute(entry) || entry.includes('\0')) return false;
const filename = resolve(pkg.directory, entry);
if (!inside(pkg.directory, filename) || !inside(pkg.directory, await realpath(filename))) return false;
if (!(await stat(filename)).isFile()) return false;
}
return true;
}
function officialRegistry(value) {
if (value === undefined || value === null || value === '') return true;
if (typeof value !== 'string') return false;
try {
const url = new URL(value);
return url.href === NPM_REGISTRY;
} catch {
return false;
}
}
/** Drain bounded output; raw subprocess diagnostics never cross the RPC boundary. */
async function run(start, { signal, timeoutMs, errorCode, capture = false }) {
if (signal?.aborted) throw failure('install-interrupted');
const controller = new AbortController();
let interrupted;
let operation;
const cancel = (code) => {
interrupted ??= code;
controller.abort();
operation?.cancel?.();
};
const onAbort = () => cancel('install-interrupted');
signal?.addEventListener('abort', onAbort, { once: true });
const timer = setTimeout(() => cancel('install-timeout'), timeoutMs);
let stdout = '';
let stderr = '';
try {
operation = start(controller.signal);
// The Host services own process-tree termination and wait for actual exit.
// Do not race their completion: a timed-out installer may still hold files.
operation.stdout?.on('data', (chunk) => {
if (capture) stdout = (stdout + chunk.toString()).slice(-OUTPUT_LIMIT);
});
operation.stderr?.on('data', (chunk) => {
stderr = (stderr + chunk.toString()).slice(-OUTPUT_LIMIT);
});
operation.stdout?.on('error', () => cancel(errorCode));
operation.stderr?.on('error', () => cancel(errorCode));
const outcome = await operation.done;
if (interrupted) throw failure(interrupted);
if (outcome.exitCode !== 0 || outcome.signal) {
throw failure(errorCode, {
exitCode: outcome.exitCode,
diagnosticCode: stderr.match(/\bERR_PNPM_[A-Z0-9_]+\b/u)?.[0],
});
}
return { exitCode: 0, signal: null, ...(capture ? { stdout } : {}) };
} catch (error) {
if (interrupted) throw failure(interrupted);
if (error.code === errorCode) throw error;
throw failure(errorCode);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
}
/**
* Adapt the running Host's existing package-management capabilities. Options
* replace process facts in tests only; no runtime path comes from RPC input.
*/
export function createUpdateRuntime(options = {}) {
const ctx = options.ctx;
const env = options.env ?? process.env;
const argv = options.argv ?? process.argv;
const execArgv = options.execArgv ?? process.execArgv;
const execPath = options.execPath ?? process.execPath;
const cwd = options.cwd ?? process.cwd();
const platform = options.platform ?? process.platform;
const osHome = options.osHome ?? homedir();
const electron = options.electron ?? process.versions.electron;
const moduleUrl = options.moduleUrl ?? import.meta.url;
const loadedPackage = containingPackage(fileURLToPath(moduleUrl), PACKAGE_NAME).catch(() => null);
let boundProfile;
async function environment() {
const profiles = service(ctx, 'desktopProfiles');
const desktop = service(ctx, 'desktopPnpm');
const bootstrap = service(ctx, 'desktopPnpmBootstrap');
const isDesktop = Boolean(electron || profiles || desktop || bootstrap);
const current = profiles?.current;
const profileName = isDesktop ? current?.name : cliProfile(argv.slice(2));
const homeDir = isDesktop && bootstrap?.homeDir ? resolve(bootstrap.homeDir) : dshHome(env, osHome);
const result = { environmentKind: isDesktop ? 'desktop' : 'cli', homeDir, profileName };
if (!profileNameValid(profileName)) return { ...result, blockedReason: 'unknown-profile' };
const profileDir = await realpath(join(homeDir, 'profiles', profileName));
const home = await realpath(homeDir);
const base = { ...result, homeDir: home, profileDir };
if (isDesktop) {
try {
if (typeof desktop?.runPlugin !== 'function' || typeof desktop?.run !== 'function' || !bootstrap
|| !current?.dir || bootstrap.activeProfileName !== profileName
|| await realpath(current.dir) !== profileDir
|| await realpath(bootstrap.activeProfileDir) !== profileDir
|| await realpath(bootstrap.appExecutable) !== await realpath(execPath)) {
return { ...base, blockedReason: 'executor-unavailable' };
}
const desktopPackage = await containingPackage(bootstrap.dshBootstrapPath, 'dsh-plugin-desktop');
if (!desktopPackage || basename(bootstrap.dshBootstrapPath) !== 'desktop-cli.js'
|| dirname(await realpath(bootstrap.dshBootstrapPath)) !== join(desktopPackage.directory, 'lib')) {
return { ...base, blockedReason: 'executor-unavailable' };
}
return { ...base, desktop, executable: execPath, cliEntry: bootstrap.dshBootstrapPath };
} catch {
return { ...base, blockedReason: 'executor-unavailable' };
}
}
const subprocess = service(ctx, 'subprocess');
if (typeof subprocess?.spawn !== 'function' || typeof argv[1] !== 'string' || !isAbsolute(argv[1]) || platform === 'win32') {
return { ...base, blockedReason: 'executor-unavailable' };
}
try {
const cli = await containingPackage(argv[1], '@deepseek-ai/dsh');
if (!cli) return { ...base, blockedReason: 'executor-unavailable' };
const cliEntry = await realpath(argv[1]);
const declared = typeof cli.manifest.bin === 'string' ? cli.manifest.bin : cli.manifest.bin?.dsh;
const publishedEntry = typeof declared === 'string' ? resolve(cli.directory, declared) : '';
if (cliEntry !== publishedEntry && cliEntry !== join(cli.directory, 'src', 'bin.ts')) {
return { ...base, blockedReason: 'executor-unavailable' };
}
return { ...base, subprocess, executable: execPath, cliEntry };
} catch {
return { ...base, blockedReason: 'executor-unavailable' };
}
}
function cliOperation(runtime, args, signal, directPnpm = false) {
const child = runtime.subprocess.spawn({
argv: directPnpm ? ['pnpm', ...args] : [execPath, ...execArgv, runtime.cliEntry, ...args],
cwd: directPnpm ? runtime.profileDir : cwd,
env: { DSH_HOME: runtime.homeDir, CI: 'true' },
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
graceMs: 3_000,
signal,
});
return {
stdout: child.stdout,
stderr: child.stderr,
cancel: () => child.terminate(),
done: (async () => {
try { return await child.done; }
finally { await child.waitForExit(); }
})(),
};
}
async function checkRegistry(runtime, signal) {
const args = ['config', 'get', '@xmanrui:registry', '--json'];
const result = await run(
(childSignal) => runtime.desktop
? runtime.desktop.run(args, childSignal)
: cliOperation(runtime, args, childSignal, true),
{ signal, timeoutMs: options.configTimeoutMs ?? CONFIG_TIMEOUT_MS, errorCode: 'registry-check-failed', capture: true },
);
const output = result.stdout.trim();
let value;
try { value = output === '' || output === 'undefined' ? undefined : JSON.parse(output); }
catch { throw failure('registry-check-failed'); }
if (!officialRegistry(value)) throw failure('registry-conflict');
}
async function inspect({ preflight = false } = {}) {
let runtime;
const result = { installedVersion: null, packageValid: false, eligible: false, installationKey: null };
try {
runtime = await environment();
for (const key of ['homeDir', 'profileDir', 'profileName', 'environmentKind', 'executable', 'cliEntry']) {
result[key] = runtime[key] ?? null;
}
if (!runtime.profileDir) return { ...result, blockedReason: runtime.blockedReason ?? 'unknown-profile' };
const profile = await packageAt(runtime.profileDir);
const installed = await packageAt(join(runtime.profileDir, 'node_modules', PACKAGE_NAME));
result.installedVersion = typeof installed.manifest.version === 'string' ? installed.manifest.version : null;
result.packageValid = await validPackage(installed);
const loaded = await loadedPackage;
const identity = `${runtime.homeDir}\0${runtime.profileDir}\0${runtime.profileName}`;
const sameLoadedPackage = loaded?.directory === installed.directory
&& loaded?.manifest.version === installed.manifest.version;
if (boundProfile === undefined && sameLoadedPackage && result.packageValid) boundProfile = identity;
const stateFiles = await Promise.all(['pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package-lock.json']
.map((filename) => readOptional(join(runtime.profileDir, filename))));
result.installationKey = createHash('sha256').update(JSON.stringify([
identity, profile.contents, installed.directory, installed.contents,
runtime.cliEntry, runtime.executable, ...stateFiles,
])).digest('hex');
if (boundProfile !== undefined && boundProfile !== identity) result.blockedReason = 'installation-changed';
else if (!sameLoadedPackage && boundProfile === undefined) result.blockedReason = 'installation-changed';
else if (!result.packageValid) result.blockedReason = 'invalid-installation';
else if (!registrySpec(profile.manifest.dependencies?.[PACKAGE_NAME])
|| !inside(join(runtime.profileDir, 'node_modules'), installed.directory)) result.blockedReason = 'source-install';
else if (runtime.blockedReason) result.blockedReason = runtime.blockedReason;
else if (!sameLoadedPackage) result.blockedReason = 'pending-restart';
else if (preflight) await checkRegistry(runtime);
result.eligible = !result.blockedReason;
return { ...result, blockedReason: result.blockedReason ?? null };
} catch (error) {
const known = ['registry-conflict', 'registry-check-failed', 'install-timeout', 'install-interrupted'];
return { ...result, blockedReason: known.includes(error.code) ? error.code : runtime?.profileDir ? 'invalid-installation' : 'unknown-profile' };
}
}
async function install(version, { signal, expectedInstallationKey } = {}) {
if (semver.valid(version) !== version || semver.prerelease(version)) throw failure('invalid-version');
const before = await inspect();
if (expectedInstallationKey !== undefined && before.installationKey !== expectedInstallationKey) {
throw failure('installation-changed');
}
if (!before.eligible) throw failure(before.blockedReason);
const runtime = await environment();
await checkRegistry(runtime, signal);
// Config validation is asynchronous: do not replace a package another
// package manager changed while it ran.
const checked = await inspect();
if (!checked.eligible || checked.installationKey !== before.installationKey) throw failure('installation-changed');
const args = ['add', '-w', '--save-exact', `${PACKAGE_NAME}@${version}`, `--registry=${NPM_REGISTRY}`];
return run(
(childSignal) => runtime.desktop
? runtime.desktop.runPlugin(args, runtime.profileDir, childSignal)
: cliOperation(runtime, ['plugin', '--profile', runtime.profileName, ...args], childSignal),
{ signal, timeoutMs: options.installTimeoutMs ?? INSTALL_TIMEOUT_MS, errorCode: 'install-failed' },
);
}
return Object.freeze({ inspect, install });
}

View file

@ -0,0 +1,385 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { isAbsolute, join } from 'node:path';
import semver from 'semver';
import manifest from '../../package.json' with { type: 'json' };
import { withSessionBindingLock } from '../../src/channels/shared/session-binding-lock.mjs';
import { NPM_REGISTRY, PACKAGE_NAME } from './update-runtime.mjs';
const ACTIVE_STATES = new Set(['installing', 'verifying']);
const JOB_STATES = new Set([...ACTIVE_STATES, 'restart-required', 'completed', 'failed', 'interrupted']);
const MAX_METADATA_BYTES = 256 * 1024;
const SNAPSHOT_FILES = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'];
const NO_LOCK = Symbol('no update lock');
export function updateError(code) {
return Object.assign(new Error(code), { code });
}
/** Read only the fixed npm package; neither RPC callers nor registry metadata choose a command. */
export async function fetchNpmRelease(fetchImpl = globalThis.fetch, timeoutMs = 10_000) {
let response;
try {
response = await fetchImpl(`${NPM_REGISTRY}${encodeURIComponent(PACKAGE_NAME)}/latest`, {
headers: { accept: 'application/json' },
redirect: 'error',
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) throw updateError('check-failed');
if (Number(response.headers.get('content-length')) > MAX_METADATA_BYTES) {
throw updateError('invalid-release');
}
const chunks = [];
let length = 0;
for await (const chunk of response.body) {
length += chunk.byteLength;
if (length > MAX_METADATA_BYTES) throw updateError('invalid-release');
chunks.push(Buffer.from(chunk));
}
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
const version = value.version;
if (value.name !== PACKAGE_NAME || typeof version !== 'string'
|| semver.valid(version) !== version || semver.prerelease(version)) {
throw updateError('invalid-release');
}
const nodeRange = value.engines?.node;
if (nodeRange !== undefined && (typeof nodeRange !== 'string' || !semver.validRange(nodeRange))) {
throw updateError('invalid-release');
}
const tarball = new URL(value.dist?.tarball);
const integrity = value.dist?.integrity;
if (tarball.origin !== new URL(NPM_REGISTRY).origin || tarball.username || tarball.password
|| tarball.search || tarball.hash
|| tarball.pathname !== `/@xmanrui/dsh-im/-/dsh-im-${version}.tgz`
|| typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity)) {
throw updateError('invalid-release');
}
return { version, nodeRange: nodeRange ?? '*', integrity, tarball: tarball.href };
} catch (error) {
if (error?.code === 'invalid-release' || error instanceof SyntaxError || error instanceof TypeError && response?.ok) {
throw updateError('invalid-release');
}
throw updateError('check-failed');
}
}
function pathsFor(environment) {
if (!isAbsolute(environment.homeDir ?? '') || !isAbsolute(environment.profileDir ?? '')) return null;
const key = createHash('sha256').update(environment.profileDir).digest('hex').slice(0, 24);
const directory = join(environment.homeDir, 'updates', 'dsh-im', key);
return {
directory,
state: join(directory, 'state.json'),
lock: join(directory, 'install.lock'),
backup: join(directory, 'before.json'),
};
}
async function readJson(path, missing = null) {
try {
if ((await stat(path)).size > 10 * 1024 * 1024) throw updateError('state-unavailable');
return JSON.parse(await readFile(path, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return missing;
throw updateError('state-unavailable');
}
}
async function writeJson(path, value) {
const temporary = `${path}.${randomUUID()}.tmp`;
try {
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
await rename(temporary, path);
} catch {
throw updateError('state-unavailable');
} finally {
await unlink(temporary).catch((error) => {
if (error.code !== 'ENOENT') throw updateError('state-unavailable');
});
}
}
function processAlive(pid) {
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code !== 'ESRCH';
}
}
function publicJob(job) {
if (!job) return null;
const { id, state, targetVersion, message } = job;
return { id, state, targetVersion, message };
}
/** One update job per profile. Persist intent before starting pnpm, and never apply a restart here. */
export function createUpdateService({
runtime,
runningVersion = manifest.version,
nodeVersion = process.versions.node,
fetchImpl = globalThis.fetch,
now = Date.now,
checkTimeoutMs = 10_000,
confirmationTtlMs = 10 * 60_000,
installTimeoutMs = 15 * 60_000,
} = {}) {
const queue = {};
let checked = null;
let checking = null;
let lastCheckAt = -Infinity;
let activeJob = null;
let activeTask = null;
let abortController = null;
let unsavedJob = null;
let disposed = false;
function assertActive() {
if (disposed) throw updateError('disposed');
}
async function readJob(environment) {
const paths = pathsFor(environment);
if (!paths) return null;
// A failed final write must not make this Host display the old "installing"
// record forever. Keep the lock and expose the outcome we actually observed.
if (unsavedJob?.statePath === paths.state) return unsavedJob.job;
let job = await readJson(paths.state);
const lock = await readJson(paths.lock, NO_LOCK);
if (!job) {
if (lock !== NO_LOCK) {
return { id: 'locked', state: 'interrupted', message: 'recovery-required', targetVersion: null };
}
return null;
}
if (!JOB_STATES.has(job.state) || typeof job.id !== 'string' || !semver.valid(job.targetVersion)) {
throw updateError('state-unavailable');
}
if (ACTIVE_STATES.has(job.state) && job.id !== activeJob?.id) {
if (lock === NO_LOCK || lock?.id !== job.id || !processAlive(lock?.pid)) {
job = { ...job, state: 'interrupted', message: 'recovery-required' };
}
}
if (job.id !== activeJob?.id && !ACTIVE_STATES.has(job.state)) {
if (lock !== NO_LOCK) return { ...job, state: 'interrupted', message: 'recovery-required' };
// A later Host can retry after a verified manual repair. Never infer this
// from version equality while an old process lock is still present.
if (['failed', 'interrupted'].includes(job.state) && environment.packageValid === true
&& environment.installedVersion === runningVersion && !environment.blockedReason) {
return job.targetVersion === runningVersion
? { ...job, state: 'completed', message: 'recovered' }
: { ...job, recoverable: true };
}
}
if (job.state === 'restart-required' || job.state === 'completed') {
if (environment.installedVersion !== job.targetVersion || environment.packageValid !== true) {
return { ...job, state: 'interrupted', message: 'installation-changed' };
}
return { ...job, state: runningVersion === job.targetVersion ? 'completed' : 'restart-required' };
}
return job;
}
function snapshot(environment, job) {
let blockedReason = environment.blockedReason ?? checked?.blockedReason ?? null;
if (job?.state === 'interrupted' && !job.recoverable
|| job?.state === 'failed' && environment.installedVersion !== runningVersion) {
blockedReason = 'recovery-required';
} else if (job?.state === 'restart-required' || environment.installedVersion && environment.installedVersion !== runningVersion) {
blockedReason = 'pending-restart';
}
const busy = ACTIVE_STATES.has(job?.state);
const canInstall = Boolean(environment.eligible && !blockedReason && !busy && checked?.checkId
&& now() < checked.expiresAt && checked.installationKey === environment.installationKey
&& semver.valid(runningVersion) && semver.gt(checked.release.version, runningVersion));
return {
runningVersion,
installedVersion: environment.installedVersion ?? null,
latestVersion: checked?.release.version ?? null,
profileName: environment.profileName ?? null,
environmentKind: environment.environmentKind ?? 'cli',
canInstall,
blockedReason,
checkedAt: checked?.checkedAt ?? null,
checkId: canInstall ? checked.checkId : null,
job: publicJob(job),
};
}
async function status() {
assertActive();
const environment = await runtime.inspect();
return snapshot(environment, await readJob(environment));
}
async function check() {
assertActive();
if (checking) return checking;
// Collapse double clicks without turning a failed request into a cached success.
if (checked?.checkId && now() - lastCheckAt < 2_000) return status();
lastCheckAt = now();
checking = (async () => {
try {
const environment = await runtime.inspect({ preflight: true });
const release = await fetchNpmRelease(fetchImpl, checkTimeoutMs);
assertActive();
checked = {
release,
checkId: randomUUID(),
checkedAt: now(),
expiresAt: now() + confirmationTtlMs,
installationKey: environment.installationKey,
profileDir: environment.profileDir,
blockedReason: environment.blockedReason
?? (!semver.satisfies(nodeVersion, release.nodeRange) ? 'incompatible-node' : null),
};
return snapshot(environment, await readJob(environment));
} catch (error) {
if (checked) checked = { ...checked, checkId: null, expiresAt: 0 };
throw error;
} finally {
checking = null;
}
})();
return checking;
}
async function releaseLock(paths, id) {
const lock = await readJson(paths.lock);
if (lock?.id === id) await unlink(paths.lock);
}
async function backupProfile(environment, paths, job) {
const files = {};
for (const filename of SNAPSHOT_FILES) {
try {
const path = join(environment.profileDir, filename);
if ((await stat(path)).size > 3 * 1024 * 1024) throw updateError('state-unavailable');
files[filename] = await readFile(path, 'utf8');
} catch (error) {
if (error.code !== 'ENOENT') throw updateError('state-unavailable');
}
}
// Keep only the previous attempt's small manifests, never credentials or the entire home.
await writeJson(paths.backup, { jobId: job.id, previousVersion: job.previousVersion, files });
}
function rememberUnsavedJob(paths) {
activeJob = { ...activeJob, state: 'interrupted', message: 'state-unavailable' };
unsavedJob = { statePath: paths.state, job: activeJob };
}
async function execute(paths, job, originalEnvironment) {
const deadline = AbortSignal.timeout(installTimeoutMs);
try {
await runtime.install(job.targetVersion, {
signal: AbortSignal.any([abortController.signal, deadline]),
expectedInstallationKey: originalEnvironment.installationKey,
});
if (disposed) throw updateError('interrupted');
activeJob = { ...activeJob, state: 'verifying', updatedAt: now() };
await writeJson(paths.state, activeJob);
const environment = await runtime.inspect();
if (environment.homeDir !== originalEnvironment.homeDir || environment.profileDir !== originalEnvironment.profileDir
|| environment.profileName !== originalEnvironment.profileName || environment.blockedReason === 'installation-changed') {
throw updateError('installation-changed');
}
if (environment.installedVersion !== job.targetVersion || environment.packageValid !== true) {
throw updateError('verify-failed');
}
if (environment.blockedReason && environment.blockedReason !== 'pending-restart') throw updateError('verify-failed');
activeJob = { ...activeJob, state: 'restart-required', message: null, updatedAt: now() };
await writeJson(paths.state, activeJob);
} catch (error) {
const timedOut = deadline.aborted || error.code === 'install-timeout';
const interrupted = disposed || abortController.signal.aborted
|| !timedOut && ['interrupted', 'install-interrupted'].includes(error.code);
activeJob = {
...activeJob,
state: interrupted ? 'interrupted' : 'failed',
message: interrupted ? 'interrupted' : timedOut ? 'install-timeout'
: ['verify-failed', 'state-unavailable', 'installation-changed', 'registry-conflict'].includes(error.code)
? error.code : 'install-failed',
updatedAt: now(),
};
try {
await writeJson(paths.state, activeJob);
} catch {
// Retain the lock when the final record cannot be saved; the next Host must inspect it.
rememberUnsavedJob(paths);
return;
}
}
await releaseLock(paths, job.id);
}
function install({ checkId, requestId }) {
return withSessionBindingLock(queue, 'install', async () => {
assertActive();
let environment = await runtime.inspect({ preflight: true });
const previous = await readJob(environment);
if (previous?.requestId === requestId) return snapshot(environment, previous);
if (ACTIVE_STATES.has(previous?.state) || previous?.state === 'restart-required') throw updateError('update-busy');
const confirmation = checked;
if (!confirmation || !checkId || confirmation.checkId !== checkId || now() >= confirmation.expiresAt) {
throw updateError('check-expired');
}
if (environment.profileDir !== confirmation.profileDir || environment.installationKey !== confirmation.installationKey) {
throw updateError('installation-changed');
}
if (!snapshot(environment, previous).canInstall) throw updateError(environment.blockedReason ?? 'update-busy');
const paths = pathsFor(environment);
if (!paths) throw updateError('state-unavailable');
const job = {
id: randomUUID(), requestId, state: 'installing', message: null,
targetVersion: confirmation.release.version, previousVersion: environment.installedVersion,
startedAt: now(), updatedAt: now(),
};
let locked = false;
try {
await mkdir(paths.directory, { recursive: true, mode: 0o700 });
const lock = await open(paths.lock, 'wx', 0o600);
locked = true;
try {
await lock.writeFile(JSON.stringify({ id: job.id, pid: process.pid, startedAt: now() }));
} finally {
await lock.close();
}
const currentRelease = await fetchNpmRelease(fetchImpl, checkTimeoutMs);
if (JSON.stringify(currentRelease) !== JSON.stringify(confirmation.release)) throw updateError('check-expired');
environment = await runtime.inspect({ preflight: true });
if (environment.profileDir !== confirmation.profileDir || environment.installationKey !== confirmation.installationKey) {
throw updateError('installation-changed');
}
if (!environment.eligible || environment.blockedReason) throw updateError(environment.blockedReason ?? 'update-busy');
assertActive();
await backupProfile(environment, paths, job);
await writeJson(paths.state, job);
assertActive();
} catch (error) {
if (locked) await releaseLock(paths, job.id);
if (error.code === 'EEXIST') throw updateError('update-busy');
if (error.code?.startsWith('E')) throw updateError('state-unavailable');
throw error;
}
activeJob = job;
abortController = new AbortController();
activeTask = execute(paths, job, environment).catch(() => {
// Keep unknown cleanup failures local and keep the on-disk lock for manual recovery.
rememberUnsavedJob(paths);
});
return snapshot(environment, job);
});
}
async function close() {
disposed = true;
abortController?.abort();
if (activeTask) await activeTask;
}
return Object.freeze({ status, check, install, close });
}

View file

@ -28,6 +28,10 @@ const required = [
'plugin-src/client/channels/dingtalk/index.js',
'plugin-src/client/channels/slack/index.js',
'plugin-src/client/i18n.js',
'plugin-src/client/update-panel.js',
'plugin-src/host/update-service.mjs',
'plugin-src/host/update-runtime.mjs',
'plugin-src/host/update-rpc.mjs',
'plugin-src/host/channels/feishu/index.mjs',
'plugin-src/host/channels/weixin/index.mjs',
'plugin-src/host/channels/dingtalk/index.mjs',
@ -143,6 +147,14 @@ for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/slac
throw new Error(`host bundle does not contain the internal ${marker} RPC provider`);
}
}
for (const marker of ['update.status', 'update.check', 'update.install']) {
if (!host.includes(marker) || !client.includes(marker)) {
throw new Error(`update RPC endpoint missing from Host or Client bundle: ${marker}`);
}
}
if (!host.includes('https://registry.npmjs.org/') || !host.includes('desktopPnpm')) {
throw new Error('host bundle is missing the npm updater or Desktop package-management adapter');
}
for (const marker of ['/session Session ID', 'bindWorkspaceSession', 'session-subagent-unsupported']) {
if (!host.includes(marker)) {
throw new Error(`host bundle does not contain the Session binding marker: ${marker}`);

View file

@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { readFile, readdir } from 'node:fs/promises';
import test from 'node:test';
import { transform } from 'esbuild';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
@ -130,6 +131,8 @@ test('IM settings renders nine IM channels plus the AI Office connector', async
assert.match(markup, /target="_blank"/);
assert.match(markup, /rel="noopener noreferrer"/);
assert.match(markup, /aria-label="dsh-im GitHub"/);
assert.match(markup, /dim-updateTrigger[^>]*aria-haspopup="dialog"[^>]*>检查更新</);
assert.ok(markup.indexOf('dim-updateTrigger') < markup.indexOf('dim-githubAction'));
assert.match(markup, /aria-describedby="[^"]+"/);
assert.match(markup, /role="tooltip"[^>]*>帮助与反馈 · 前往 GitHub</);
assert.match(styles, /\.dim-title \{[^}]*margin: 0 0 18px;/);
@ -650,6 +653,12 @@ test('the bundled DingTalk channel has no local sender approval workflow', async
);
});
test('the bilingual dictionary has no duplicate object keys', async () => {
const source = await readFile(new URL('../plugin-src/client/i18n.js', import.meta.url), 'utf8');
const { warnings } = await transform(source, { loader: 'js', logLevel: 'silent' });
assert.deepEqual(warnings.filter((warning) => warning.id === 'duplicate-object-key'), []);
});
test('every shipped Chinese client string has an English projection', async () => {
const paths = (await readdir(CLIENT_SOURCE_DIRECTORY_URL, { recursive: true }))
.filter((path) => path.endsWith('.js') && path !== 'i18n.js');
@ -692,7 +701,11 @@ test('client registers one top-level bilingual IM settings section with a direct
const registrations = [];
const dictionaries = [];
const directoryCalls = [];
const rpcCall = async () => ({ ok: true, value: {} });
const rpcCalls = [];
const rpcCall = async (...args) => {
rpcCalls.push(args);
return { ok: true, value: {} };
};
const ctx = {
effect(install, label) {
effects.push({ install, label });
@ -749,6 +762,8 @@ test('client registers one top-level bilingual IM settings section with a direct
const injected = registrations[0].options.inject();
const signal = new AbortController().signal;
await injected.updateRpcCall('update.status', {}, signal);
assert.deepEqual(rpcCalls, [['/dsh-im', 'update.status', {}, signal]]);
assert.deepEqual(
await injected.workspaceDirectoryPicker.listDirectory('/workspace/current', signal),
{ path: '/workspace/current', entries: [] },

66
test/update-rpc.test.mjs Normal file
View file

@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createUpdateRpcHandler, installUpdateRpc } from '../plugin-src/host/update-rpc.mjs';
import { createImHostPlugin } from '../plugin-src/host/index.mjs';
test('update RPC rejects arbitrary commands, paths, profiles, sources and invalid request identifiers', async () => {
const calls = [];
const service = Object.fromEntries(['status', 'check', 'install'].map((key) => [key, async (value) => calls.push([key, value])]));
const handle = createUpdateRpcHandler(service);
for (const [endpoint, payload] of [
['unknown', {}], ['update.check', { registry: 'https://evil.test/' }],
['update.status', { profile: '../other' }], ['update.install', null],
['update.install', { checkId: 'id', requestId: 'request', command: 'anything' }],
['update.install', { checkId: 'id', requestId: '; rm -rf /' }],
['update.install', { checkId: 'id', requestId: 'request', version: '9.0.0' }],
]) {
assert.equal((await handle(endpoint, payload)).error.code, 'bad-request');
}
assert.deepEqual(calls, []);
});
test('update RPC keeps local authority even when other management uses trusted-host', () => {
const calls = [];
const service = { close: async () => {} };
const ctx = {
connection: { rpc: { handle: (...args) => calls.push(args) } },
effect: () => {},
};
installUpdateRpc(ctx, { service, runtime: {} });
assert.equal(calls[0][0], '/dsh-im');
assert.deepEqual(calls[0][2], { authority: 'loopback' });
});
test('update RPC returns only safe codes for unanticipated runtime errors', async () => {
const handle = createUpdateRpcHandler({ status: async () => {
throw new Error('token=secret /Users/private/profile');
} });
assert.deepEqual(await handle('update.status', {}), {
ok: false, error: { code: 'update-failed', message: 'update-failed' },
});
});
test('aborting a submitted browser request does not cancel the Host installation', async () => {
const abort = new AbortController();
let complete;
const pending = new Promise((resolve) => { complete = resolve; });
const handle = createUpdateRpcHandler({ install: async () => pending });
const result = handle('update.install', { checkId: 'confirmed', requestId: 'request' }, abort.signal);
abort.abort();
complete({ job: { state: 'installing' } });
assert.deepEqual(await result, { ok: true, value: { job: { state: 'installing' } } });
});
test('Host update initialization failure leaves all channel activations available', async () => {
const calls = [];
const channels = ['Feishu', 'Weixin', 'Dingtalk', 'Wecom', 'Qq', 'Slack', 'Telegram', 'Discord', 'Whatsapp', 'Office'];
const internals = Object.fromEntries(channels.map((channel) => [`apply${channel}`, async () => calls.push(channel)]));
internals.installUpdateRpc = () => { throw new Error('updater unavailable'); };
const errors = [];
await createImHostPlugin(internals).apply({
connection: { rpc: {} }, logger: { error: (...args) => errors.push(args) },
});
assert.deepEqual(calls, channels);
assert.equal(errors.length, 1);
assert.match(errors[0][0], /update management/);
});

View file

@ -0,0 +1,366 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, readFile, realpath, rm, symlink, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
import { createUpdateRuntime, NPM_REGISTRY, PACKAGE_NAME } from '../plugin-src/host/update-runtime.mjs';
async function json(filename, value) {
await mkdir(join(filename, '..'), { recursive: true });
await writeFile(filename, JSON.stringify(value));
}
async function plugin(directory, version = '3.0.8') {
await mkdir(join(directory, 'lib'), { recursive: true });
await json(join(directory, 'package.json'), {
name: PACKAGE_NAME,
version,
main: './lib/index.js',
exports: { '.': './lib/index.js', './client': './lib/client.js' },
dsh: { bundle: { patch: './cordis.patch.yml' } },
});
await Promise.all(['lib/index.js', 'lib/client.js', 'cordis.patch.yml']
.map((entry) => writeFile(join(directory, entry), '')));
}
function operation({ output = '', errors = '', exitCode = 0, signal, pending = false, onCancel } = {}) {
const stdout = new PassThrough();
const stderr = new PassThrough();
let finish;
let closed = false;
const done = new Promise((resolve) => { finish = resolve; });
const settle = (code, terminationSignal = null) => {
if (closed) return;
closed = true;
signal?.removeEventListener('abort', cancel);
stdout.end(output);
stderr.end(errors);
finish({ exitCode: code, signal: terminationSignal });
};
const cancel = () => {
onCancel?.();
settle(null, 'SIGTERM');
};
signal?.addEventListener('abort', cancel, { once: true });
if (!pending) setImmediate(() => settle(exitCode));
return { stdout, stderr, done, cancel, terminate: cancel, waitForExit: () => done };
}
async function fixture(t, { desktop = false, name = 'update-test', hoisted = false } = {}) {
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-im-update-runtime-')));
t.after(() => rm(root, { recursive: true, force: true }));
const homeDir = join(root, 'dsh-home');
const profileDir = join(homeDir, 'profiles', name);
const installedLink = join(profileDir, 'node_modules', PACKAGE_NAME);
const installedDir = hoisted ? installedLink
: join(profileDir, 'node_modules/.pnpm/@xmanrui+dsh-im@3.0.8/node_modules', PACKAGE_NAME);
await plugin(installedDir);
if (!hoisted) {
await mkdir(join(installedLink, '..'), { recursive: true });
await symlink(installedDir, installedLink, 'dir');
}
const manifest = { name: `dsh-profile-${name}`, private: true, dependencies: { [PACKAGE_NAME]: '3.0.8' } };
await json(join(profileDir, 'package.json'), manifest);
await writeFile(join(profileDir, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n");
const cliRoot = join(root, 'harness');
const cliEntry = join(cliRoot, 'lib/bin.js');
await json(join(cliRoot, 'package.json'), { name: '@deepseek-ai/dsh', bin: { dsh: 'lib/bin.js' } });
await mkdir(join(cliEntry, '..'), { recursive: true });
await writeFile(cliEntry, '');
const calls = [];
let registry = '';
let installResult = {};
const ctx = {
subprocess: {
spawn(spec) {
calls.push(spec);
return operation({
...spec.argv.includes('config') ? { output: registry } : installResult,
signal: spec.signal,
});
},
},
};
const options = {
ctx,
env: { DSH_HOME: homeDir },
argv: [process.execPath, cliEntry, '--profile', name],
execArgv: [],
execPath: process.execPath,
cwd: root,
moduleUrl: pathToFileURL(join(installedDir, 'lib/index.js')).href,
platform: 'darwin',
electron: false,
};
if (desktop) {
const desktopRoot = join(root, 'desktop-app');
const bootstrapPath = join(desktopRoot, 'lib/desktop-cli.js');
await json(join(desktopRoot, 'package.json'), { name: 'dsh-plugin-desktop', version: '2.0.3' });
await mkdir(join(bootstrapPath, '..'), { recursive: true });
await writeFile(bootstrapPath, '');
ctx.desktopProfiles = { current: { name, dir: profileDir } };
ctx.desktopPnpmBootstrap = {
activeProfileName: name,
activeProfileDir: profileDir,
homeDir,
appExecutable: process.execPath,
dshBootstrapPath: bootstrapPath,
};
ctx.desktopPnpm = {
run(args, signal) {
calls.push({ method: 'run', args, signal });
return operation({ output: registry, signal });
},
runPlugin(args, cwd, signal) {
calls.push({ method: 'runPlugin', args, cwd, signal });
return operation({ ...installResult, signal });
},
};
options.electron = '43.4.0';
}
return {
root, homeDir, profileDir, installedDir, installedLink, manifest, cliEntry, ctx, options, calls,
runtime: () => createUpdateRuntime(options),
registry: (value) => { registry = value; },
installResult: (value) => { installResult = value; },
};
}
test('read-only inspection accepts normal pnpm symlinks and never starts a process', async (t) => {
const f = await fixture(t);
const info = await f.runtime().inspect();
assert.equal(info.profileName, 'update-test');
assert.equal(info.environmentKind, 'cli');
assert.equal(info.installedVersion, '3.0.8');
assert.equal(info.packageValid, true);
assert.equal(info.eligible, true);
assert.match(info.installationKey, /^[a-f0-9]{64}$/u);
assert.deepEqual(f.calls, []);
});
test('Desktop uses optional services and accepts the hoisted installation layout', async (t) => {
const f = await fixture(t, { desktop: true, hoisted: true });
const values = { ...f.ctx };
f.options.ctx = { get: (name) => values[name] };
const info = await f.runtime().inspect();
assert.equal(info.environmentKind, 'desktop');
assert.equal(info.profileName, 'update-test');
assert.equal(info.eligible, true);
assert.deepEqual(f.calls, []);
});
test('profile detection follows only launcher arguments and never guesses from cwd', async (t) => {
const f = await fixture(t, { name: 'web' });
for (const args of [['web', '--profile', 'wrong'], ['--profile=web', '--prompt', '--profile', 'wrong']]) {
f.options.argv = [process.execPath, f.cliEntry, ...args];
assert.equal((await f.runtime().inspect()).profileName, 'web');
}
f.options.argv = [process.execPath, f.cliEntry, '--prompt', '--profile', 'web'];
assert.equal((await f.runtime().inspect()).blockedReason, 'unknown-profile');
f.options.argv = [process.execPath, f.cliEntry, '--profile', '../web'];
assert.equal((await f.runtime().inspect()).blockedReason, 'unknown-profile');
});
test('local, Git, alias and external-directory installations cannot be replaced', async (t) => {
const f = await fixture(t);
for (const spec of ['link:/work/dsh-im', 'file:../dsh-im', 'github:xmanrui/dsh-im', 'git+ssh://git@example.com/repo.git', 'npm:another-plugin@1.0.0']) {
await json(join(f.profileDir, 'package.json'), { ...f.manifest, dependencies: { [PACKAGE_NAME]: spec } });
assert.equal((await f.runtime().inspect()).blockedReason, 'source-install', spec);
}
const sourceDir = join(f.root, 'source-checkout');
await plugin(sourceDir);
await unlink(f.installedLink);
await symlink(sourceDir, f.installedLink, 'dir');
await json(join(f.profileDir, 'package.json'), f.manifest);
f.options.moduleUrl = pathToFileURL(join(sourceDir, 'lib/index.js')).href;
assert.equal((await f.runtime().inspect()).blockedReason, 'source-install');
assert.deepEqual(f.calls, []);
});
test('a loaded plugin from another profile cannot authorize an installation', async (t) => {
const f = await fixture(t);
const otherPackage = join(f.root, 'other-profile-package');
await plugin(otherPackage);
f.options.moduleUrl = pathToFileURL(join(otherPackage, 'lib/index.js')).href;
assert.equal((await f.runtime().inspect()).blockedReason, 'installation-changed');
});
test('disk verification checks package identity and required entry files', async (t) => {
const f = await fixture(t);
const runtime = f.runtime();
assert.equal((await runtime.inspect()).packageValid, true);
await unlink(join(f.installedDir, 'lib/client.js'));
const missingClient = await runtime.inspect();
assert.equal(missingClient.packageValid, false);
assert.equal(missingClient.blockedReason, 'invalid-installation');
assert.equal(missingClient.installedVersion, '3.0.8');
});
test('inspection reads new disk versions and snapshots without require cache', async (t) => {
const f = await fixture(t);
const runtime = f.runtime();
const before = await runtime.inspect();
const newDir = join(f.profileDir, 'node_modules/.pnpm/@xmanrui+dsh-im@3.0.9/node_modules', PACKAGE_NAME);
await plugin(newDir, '3.0.9');
await unlink(f.installedLink);
await symlink(newDir, f.installedLink, 'dir');
const after = await runtime.inspect();
assert.equal(after.installedVersion, '3.0.9');
assert.equal(after.packageValid, true);
assert.equal(after.blockedReason, 'pending-restart');
assert.notEqual(after.installationKey, before.installationKey);
});
test('in-place Desktop replacement also requires restart until a new Host loads', async (t) => {
const f = await fixture(t, { desktop: true, hoisted: true });
const runtime = f.runtime();
await runtime.inspect();
await plugin(f.installedDir, '3.0.9');
assert.equal((await runtime.inspect()).blockedReason, 'pending-restart');
assert.equal((await f.runtime().inspect()).eligible, true);
});
test('manifest and lockfile changes invalidate an installation snapshot', async (t) => {
const f = await fixture(t);
const runtime = f.runtime();
const first = await runtime.inspect();
await writeFile(join(f.profileDir, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n# changed\n");
const second = await runtime.inspect();
assert.notEqual(first.installationKey, second.installationKey);
await json(join(f.profileDir, 'package.json'), { ...f.manifest, dependencies: { ...f.manifest.dependencies, another: '1.0.0' } });
assert.notEqual(second.installationKey, (await runtime.inspect()).installationKey);
});
test('installation rejects changes after service confirmation before starting any process', async (t) => {
for (const desktop of [false, true]) {
const f = await fixture(t, { desktop });
const runtime = f.runtime();
const confirmed = await runtime.inspect();
await writeFile(join(f.profileDir, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n# external change after confirmation\n");
await assert.rejects(runtime.install('3.0.9', { expectedInstallationKey: confirmed.installationKey }), {
code: 'installation-changed',
});
assert.deepEqual(f.calls, []);
}
});
test('Desktop refuses a mismatched bootstrap or a changed active profile', async (t) => {
const f = await fixture(t, { desktop: true });
const runtime = f.runtime();
assert.equal((await runtime.inspect()).eligible, true);
f.ctx.desktopPnpmBootstrap.activeProfileName = 'wrong';
assert.equal((await runtime.inspect()).blockedReason, 'executor-unavailable');
delete f.ctx.desktopPnpm;
assert.equal((await runtime.inspect()).eligible, false);
});
test('preflight validates the effective scoped registry without exposing credentials', async (t) => {
const f = await fixture(t, { desktop: true });
const runtime = f.runtime();
for (const output of ['', 'undefined\n', 'null\n', JSON.stringify(NPM_REGISTRY)]) {
f.registry(output);
assert.equal((await runtime.inspect({ preflight: true })).eligible, true);
}
for (const value of ['https://mirror.example/', 'https://user:password@registry.npmjs.org/', 'http://registry.npmjs.org/']) {
f.registry(JSON.stringify(value));
const result = await runtime.inspect({ preflight: true });
assert.equal(result.blockedReason, 'registry-conflict');
assert.doesNotMatch(JSON.stringify(result), /password|mirror\.example/u);
}
f.registry('not valid json');
assert.equal((await runtime.inspect({ preflight: true })).blockedReason, 'registry-check-failed');
assert.ok(f.calls.every((call) => call.method === 'run'));
});
test('Desktop installation reuses runPlugin with one pinned npm target and no restart', async (t) => {
const f = await fixture(t, { desktop: true });
let restartCalls = 0;
f.ctx.desktopProfiles.select = () => { restartCalls += 1; };
f.ctx.desktopActions = { restart: () => { restartCalls += 1; } };
const runtime = f.runtime();
const confirmed = await runtime.inspect();
assert.deepEqual(await runtime.install('3.0.9', { expectedInstallationKey: confirmed.installationKey }), { exitCode: 0, signal: null });
assert.equal(f.calls.length, 2);
assert.deepEqual(f.calls[0].args, ['config', 'get', '@xmanrui:registry', '--json']);
assert.deepEqual(f.calls[1].args, ['add', '-w', '--save-exact', `${PACKAGE_NAME}@3.0.9`, `--registry=${NPM_REGISTRY}`]);
assert.equal(f.calls[1].cwd, f.profileDir);
assert.equal(restartCalls, 0);
});
test('CLI installation uses the running Node and verified dsh entry, without shell interpolation', async (t) => {
const f = await fixture(t);
f.options.execArgv = ['--expose-internals'];
await f.runtime().install('3.0.9');
const [preflight, install] = f.calls;
assert.deepEqual(preflight.argv, ['pnpm', 'config', 'get', '@xmanrui:registry', '--json']);
assert.equal(preflight.cwd, f.profileDir);
assert.deepEqual(install.argv, [process.execPath, '--expose-internals', f.cliEntry, 'plugin', '--profile', 'update-test',
'add', '-w', '--save-exact', `${PACKAGE_NAME}@3.0.9`, `--registry=${NPM_REGISTRY}`]);
assert.equal(install.env.DSH_HOME, f.homeDir);
assert.equal(install.stdio.stdin, 'ignore');
assert.equal(install.env.CI, 'true');
assert.equal('shell' in install, false);
});
test('installation rejects ranges, prereleases and argument injection before starting any command', async (t) => {
const f = await fixture(t);
const runtime = f.runtime();
for (const version of ['latest', '^3.0.9', '3.0.9-beta.1', 'v3.0.9', '3.0.9; echo secret', undefined]) {
await assert.rejects(runtime.install(version), { code: 'invalid-version' });
}
assert.deepEqual(f.calls, []);
});
test('a registry conflict prevents the install command', async (t) => {
const f = await fixture(t, { desktop: true });
f.registry(JSON.stringify('https://mirror.example/'));
await assert.rejects(f.runtime().install('3.0.9'), { code: 'registry-conflict' });
assert.ok(f.calls.every((call) => call.method === 'run'));
});
test('nonzero installation exits return only safe diagnostics', async (t) => {
const f = await fixture(t, { desktop: true });
f.installResult({ exitCode: 1, errors: 'secret=abcd /Users/private ERR_PNPM_EACCES https://user:password@example.com/' });
await assert.rejects(f.runtime().install('3.0.9'), (error) => {
assert.equal(error.code, 'install-failed');
assert.equal(error.exitCode, 1);
assert.equal(error.diagnosticCode, 'ERR_PNPM_EACCES');
assert.doesNotMatch(JSON.stringify(error) + error.message, /abcd|private|password/u);
return true;
});
});
test('timeout and abort use the Host-owned cancellation and await exit', async (t) => {
for (const mode of ['timeout', 'abort']) {
const f = await fixture(t, { desktop: true });
let cancellations = 0;
f.installResult({ pending: true, onCancel: () => { cancellations += 1; } });
f.options.installTimeoutMs = mode === 'timeout' ? 20 : 10_000;
const controller = new AbortController();
const promise = f.runtime().install('3.0.9', { signal: controller.signal });
if (mode === 'abort') {
while (f.calls.length < 2) await new Promise((resolve) => setImmediate(resolve));
controller.abort();
}
await assert.rejects(promise, { code: mode === 'timeout' ? 'install-timeout' : 'install-interrupted' });
assert.ok(cancellations >= 1);
}
});
test('Windows CLI without a verified pnpm adapter fails closed', async (t) => {
const f = await fixture(t);
f.options.platform = 'win32';
assert.equal((await f.runtime().inspect()).blockedReason, 'executor-unavailable');
assert.deepEqual(f.calls, []);
});
test('package validation rejects entry files escaping the installed package', async (t) => {
const f = await fixture(t);
const metadata = JSON.parse(await readFile(join(f.installedDir, 'package.json'), 'utf8'));
metadata.exports['./client'] = '../../../../../outside.js';
await json(join(f.installedDir, 'package.json'), metadata);
assert.equal((await f.runtime().inspect()).packageValid, false);
});

View file

@ -0,0 +1,390 @@
import assert from 'node:assert/strict';
import { chmod, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createUpdateService, fetchNpmRelease } from '../plugin-src/host/update-service.mjs';
function release(version = '3.0.8', fields = {}) {
return {
name: '@xmanrui/dsh-im', version, engines: { node: '>=22.19' },
dist: {
tarball: `https://registry.npmjs.org/@xmanrui/dsh-im/-/dsh-im-${version}.tgz`,
integrity: `sha512-${Buffer.alloc(64, 1).toString('base64')}`,
},
...fields,
};
}
async function fixture(t, options = {}) {
const homeDir = await mkdtemp(join(tmpdir(), 'dsh-im-update-service-'));
const profileDir = join(homeDir, 'profiles', 'test');
await mkdir(profileDir, { recursive: true });
await writeFile(join(profileDir, 'package.json'), '{"dependencies":{"@xmanrui/dsh-im":"3.0.7"}}\n');
await writeFile(join(profileDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n');
await writeFile(join(profileDir, '.npmrc'), '//example.test/:_authToken=do-not-copy\n');
const environment = {
homeDir, profileDir, profileName: 'test', environmentKind: 'desktop',
installedVersion: '3.0.7', eligible: true, packageValid: true,
blockedReason: null, installationKey: 'old-installation',
...options.environment,
};
const state = { time: 10_000, release: release(), fetches: 0, installs: [], installKeys: [], failFetch: false };
const runtime = {
inspect: async () => ({ ...environment }),
install: async (version, { signal, expectedInstallationKey }) => {
state.installs.push(version);
state.installKeys.push(expectedInstallationKey);
if (options.install) return options.install({ version, signal, environment, expectedInstallationKey });
environment.installedVersion = version;
environment.installationKey = 'new-installation';
},
};
const serviceOptions = {
runtime, runningVersion: '3.0.7', nodeVersion: '22.20.0', now: () => state.time,
fetchImpl: async (url, init) => {
state.fetches++;
assert.equal(url, 'https://registry.npmjs.org/%40xmanrui%2Fdsh-im/latest');
assert.equal(init.redirect, 'error');
if (state.failFetch) throw new Error('private-path-and-token');
return new Response(JSON.stringify(state.release));
},
...options.service,
};
const service = createUpdateService(serviceOptions);
const services = [service];
t.after(async () => {
await Promise.all(services.map((item) => item.close()));
await rm(homeDir, { recursive: true, force: true });
});
return {
service, environment, state, homeDir,
restart(version = '3.0.8') {
const restarted = createUpdateService({ ...serviceOptions, runningVersion: version });
services.push(restarted);
return restarted;
},
async submit(requestId = 'request-1') {
const { checkId } = await service.check();
return service.install({ checkId, requestId });
},
async directory() {
const root = join(homeDir, 'updates', 'dsh-im');
return join(root, (await readdir(root))[0]);
},
};
}
async function waitForJob(service, expected) {
const deadline = Date.now() + 4_000;
let snapshot;
while (Date.now() < deadline) {
snapshot = await service.status();
if (snapshot.job?.state === expected) return snapshot;
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.fail(`Expected ${expected}, received ${JSON.stringify(snapshot)}`);
}
test('status is local and npm checking only happens on demand', async (t) => {
const f = await fixture(t);
assert.equal((await f.service.status()).latestVersion, null);
assert.equal(f.state.fetches, 0);
const checked = await f.service.check();
assert.equal(checked.latestVersion, '3.0.8');
assert.equal(checked.canInstall, true);
assert.ok(checked.checkId);
await f.service.check();
assert.equal(f.state.fetches, 1);
});
test('SemVer comparison handles two digit patches and refuses downgrades or equal versions', async (t) => {
for (const [runningVersion, latest, expected] of [
['3.0.9', '3.0.10', true], ['3.0.8', '3.0.8', false], ['3.0.10', '3.0.9', false],
]) {
const f = await fixture(t, { service: { runningVersion }, environment: { installedVersion: runningVersion } });
f.state.release = release(latest);
assert.equal((await f.service.check()).canInstall, expected);
}
});
test('npm metadata rejects pre-releases, other sources, wrong package and invalid integrity', async () => {
for (const metadata of [
release('3.0.9-beta.1'), release('not-a-version'), release('3.0.8', { name: 'other-package' }),
release('3.0.8', { dist: { ...release().dist, tarball: 'https://github.com/example/package.tgz' } }),
release('3.0.8', { dist: { ...release().dist, integrity: 'sha512-fake' } }),
release('3.0.8', { engines: { node: 'anything' } }),
]) {
await assert.rejects(fetchNpmRelease(async () => new Response(JSON.stringify(metadata))), { code: 'invalid-release' });
}
await assert.rejects(fetchNpmRelease(async () => new Response('x'.repeat(256 * 1024 + 1))), { code: 'invalid-release' });
await assert.rejects(fetchNpmRelease(async () => new Response('{}', { status: 429 })), { code: 'check-failed' });
});
test('source installs and incompatible Host Node can check but cannot install', async (t) => {
const source = await fixture(t, { environment: { eligible: false, blockedReason: 'source-install' } });
const checked = await source.service.check();
assert.equal(checked.latestVersion, '3.0.8');
assert.equal(checked.blockedReason, 'source-install');
assert.equal(checked.canInstall, false);
assert.equal(checked.checkId, null);
const oldNode = await fixture(t, { service: { nodeVersion: '22.18.0' } });
assert.equal((await oldNode.service.check()).blockedReason, 'incompatible-node');
assert.deepEqual(source.state.installs, []);
});
test('a failed check invalidates previous install confirmation without claiming latest', async (t) => {
const f = await fixture(t);
const checked = await f.service.check();
f.state.time += 3_000;
f.state.failFetch = true;
await assert.rejects(f.service.check(), { code: 'check-failed' });
assert.equal((await f.service.status()).canInstall, false);
await assert.rejects(f.service.install({ checkId: checked.checkId, requestId: 'retry' }), { code: 'check-expired' });
});
test('installation pins the confirmed version, verifies disk and waits for a manual restart', async (t) => {
const f = await fixture(t);
const submitted = await f.submit();
assert.equal(submitted.job.state, 'installing');
const finished = await waitForJob(f.service, 'restart-required');
assert.deepEqual(f.state.installs, ['3.0.8']);
assert.deepEqual(f.state.installKeys, ['old-installation']);
assert.equal(finished.runningVersion, '3.0.7');
assert.equal(finished.installedVersion, '3.0.8');
assert.equal(finished.blockedReason, 'pending-restart');
assert.equal(finished.canInstall, false);
const restarted = await f.restart().status();
assert.equal(restarted.job.state, 'completed');
assert.equal(restarted.runningVersion, '3.0.8');
const backup = await readFile(join(await f.directory(), 'before.json'), 'utf8');
assert.match(backup, /package\.json/);
assert.doesNotMatch(backup, /authToken|do-not-copy|\.npmrc/);
assert.doesNotMatch(JSON.stringify(finished), new RegExp(f.homeDir));
});
test('idempotent requests and multiple Host instances cannot start a second install', async (t) => {
let complete;
const gate = new Promise((resolve) => { complete = resolve; });
const f = await fixture(t, { install: async ({ version, environment }) => {
await gate;
environment.installedVersion = version;
} });
const checked = await f.service.check();
const first = await f.service.install({ checkId: checked.checkId, requestId: 'same-request' });
const again = await f.service.install({ checkId: checked.checkId, requestId: 'same-request' });
assert.equal(first.job.id, again.job.id);
const otherHost = f.restart('3.0.7');
const otherCheck = await otherHost.check();
await assert.rejects(otherHost.install({ checkId: otherCheck.checkId ?? 'unknown', requestId: 'another' }), { code: 'update-busy' });
assert.equal(f.state.installs.length, 1);
complete();
await waitForJob(f.service, 'restart-required');
});
test('changed installation, expired confirmation or changed latest require a fresh confirmation', async (t) => {
const changed = await fixture(t);
const first = await changed.service.check();
changed.environment.installationKey = 'external-change';
await assert.rejects(changed.service.install({ checkId: first.checkId, requestId: 'changed' }), { code: 'installation-changed' });
const expired = await fixture(t);
const second = await expired.service.check();
expired.state.time += 11 * 60_000;
await assert.rejects(expired.service.install({ checkId: second.checkId, requestId: 'expired' }), { code: 'check-expired' });
const newer = await fixture(t);
const third = await newer.service.check();
newer.state.release = release('3.0.9');
await assert.rejects(newer.service.install({ checkId: third.checkId, requestId: 'newer' }), { code: 'check-expired' });
assert.equal(newer.state.installs.length, 0);
assert.equal((await readdir(await newer.directory())).includes('install.lock'), false);
});
test('successful command without correct installed version or usable entry is a verification failure', async (t) => {
const unchanged = await fixture(t, { install: async () => {} });
await unchanged.submit();
assert.equal((await waitForJob(unchanged.service, 'failed')).job.message, 'verify-failed');
const invalid = await fixture(t, { install: async ({ environment, version }) => {
environment.installedVersion = version;
environment.packageValid = false;
} });
await invalid.submit();
assert.equal((await waitForJob(invalid.service, 'failed')).blockedReason, 'recovery-required');
});
test('failed package mutation reports recovery instead of claiming unchanged or updated', async (t) => {
const f = await fixture(t, { install: async ({ environment, version }) => {
environment.installedVersion = version;
throw new Error('stderr with private-path-and-token');
} });
await f.submit();
const result = await waitForJob(f.service, 'failed');
assert.equal(result.blockedReason, 'recovery-required');
assert.equal(result.canInstall, false);
assert.doesNotMatch(JSON.stringify(result), /private-path-and-token/);
});
test('closing the Host aborts its job and a later healthy Host can retry once the lock is released', async (t) => {
let signal;
const f = await fixture(t, { install: async (options) => {
signal = options.signal;
await new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { code: 'interrupted' })), { once: true });
});
} });
await f.submit();
await f.service.close();
assert.equal(signal.aborted, true);
const result = await f.restart('3.0.7').status();
assert.equal(result.job.state, 'interrupted');
assert.equal(result.blockedReason, null);
assert.equal(result.canInstall, false);
assert.equal((await f.restart('3.0.7').check()).canInstall, true);
});
test('foreign stale lock is reported for manual recovery and is not silently deleted', async (t) => {
const f = await fixture(t);
await f.submit();
await waitForJob(f.service, 'restart-required');
const directory = await f.directory();
await rm(join(directory, 'state.json'));
await writeFile(join(directory, 'install.lock'), JSON.stringify({ id: 'foreign', pid: 999_999_999 }));
const result = await f.restart().status();
assert.equal(result.job.state, 'interrupted');
assert.match(await readFile(join(directory, 'install.lock'), 'utf8'), /foreign/);
});
test('an external version change after installation is not reported as this job completing', async (t) => {
const f = await fixture(t);
await f.submit();
await waitForJob(f.service, 'restart-required');
f.environment.installedVersion = '3.0.9';
const result = await f.restart('3.0.9').status();
assert.equal(result.job.state, 'interrupted');
assert.equal(result.job.message, 'installation-changed');
});
test('post-install verification stays bound to the original home and profile', async (t) => {
for (const field of ['homeDir', 'profileDir', 'profileName']) {
const f = await fixture(t, { install: async ({ environment, version }) => {
environment.installedVersion = version;
environment[field] += '-other';
environment.blockedReason = 'installation-changed';
environment.eligible = false;
} });
await f.submit();
const statePath = join(await f.directory(), 'state.json');
const result = await waitForJob({ status: async () => ({ job: JSON.parse(await readFile(statePath, 'utf8')) }) }, 'failed');
assert.equal(result.job.message, 'installation-changed', field);
}
});
test('a correct target version from a changed installation source does not pass verification', async (t) => {
const f = await fixture(t, { install: async ({ environment, version }) => {
environment.installedVersion = version;
environment.blockedReason = 'source-install';
environment.eligible = false;
} });
await f.submit();
assert.equal((await waitForJob(f.service, 'failed')).job.message, 'verify-failed');
});
test('a runtime rejection of the confirmed installation snapshot is persisted as a failure', async (t) => {
const f = await fixture(t, { install: async ({ expectedInstallationKey }) => {
assert.equal(expectedInstallationKey, 'old-installation');
throw Object.assign(new Error('installation-changed'), { code: 'installation-changed' });
} });
await f.submit();
assert.equal((await waitForJob(f.service, 'failed')).job.message, 'installation-changed');
});
test('manual repair followed by a new healthy Host can recover failed and interrupted jobs', async (t) => {
for (const interrupted of [false, true]) {
const f = await fixture(t, { install: async ({ signal }) => {
if (interrupted) await new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(Object.assign(new Error('interrupted'), { code: 'interrupted' })), { once: true });
});
else throw new Error('installation failed');
} });
await f.submit();
if (!interrupted) await waitForJob(f.service, 'failed');
await f.service.close();
f.environment.installedVersion = '3.0.8';
f.environment.installationKey = 'manually-repaired';
f.state.release = release('3.0.9');
const result = await f.restart('3.0.8').check();
assert.equal(result.job.state, 'completed');
assert.equal(result.job.message, 'recovered');
assert.equal(result.blockedReason, null);
assert.equal(result.canInstall, true);
}
});
test('manual recovery never clears a residual lock, including a malformed lock document', async (t) => {
for (const contents of [JSON.stringify({ id: 'old-job', pid: process.pid }), JSON.stringify({ id: 'old-job', pid: 999_999_999 }), 'null']) {
const f = await fixture(t, { install: async () => { throw new Error('failed'); } });
await f.submit();
await waitForJob(f.service, 'failed');
await f.service.close();
const lockPath = join(await f.directory(), 'install.lock');
await writeFile(lockPath, contents);
const result = await f.restart('3.0.7').check();
assert.equal(result.job.state, 'interrupted');
assert.equal(result.blockedReason, 'recovery-required');
assert.equal(result.canInstall, false);
assert.equal(await readFile(lockPath, 'utf8'), contents);
}
});
test('an incomplete package cannot be reported as completed after a restart', async (t) => {
const f = await fixture(t);
await f.submit();
await waitForJob(f.service, 'restart-required');
await f.service.close();
f.environment.packageValid = false;
const result = await f.restart('3.0.8').status();
assert.equal(result.job.state, 'interrupted');
assert.equal(result.blockedReason, 'recovery-required');
});
test('a final journal write failure is visible in memory and leaves its lock intact', {
skip: process.platform === 'win32' || process.getuid?.() === 0,
}, async (t) => {
let directory;
const f = await fixture(t, { install: async ({ environment, version }) => {
directory = await f.directory();
environment.installedVersion = version;
await chmod(directory, 0o500);
} });
try {
await f.submit();
const result = await waitForJob(f.service, 'interrupted');
assert.equal(result.job.message, 'state-unavailable');
assert.equal(result.blockedReason, 'recovery-required');
assert.equal(JSON.parse(await readFile(join(directory, 'state.json'), 'utf8')).state, 'installing');
assert.equal(JSON.parse(await readFile(join(directory, 'install.lock'), 'utf8')).id, result.job.id);
await chmod(directory, 0o700);
assert.equal((await f.service.status()).job.state, 'interrupted');
} finally {
if (directory) await chmod(directory, 0o700);
}
});
test('the service watchdog reports timeout and waits for the owned installer to stop', async (t) => {
let stopped = false;
const f = await fixture(t, {
service: { installTimeoutMs: 20 },
install: async ({ signal }) => new Promise((resolve, reject) => {
signal.addEventListener('abort', () => {
stopped = true;
reject(Object.assign(new Error('install-interrupted'), { code: 'install-interrupted' }));
}, { once: true });
}),
});
await f.submit();
const result = await waitForJob(f.service, 'failed');
assert.equal(stopped, true);
assert.equal(result.job.message, 'install-timeout');
await f.service.close();
assert.equal((await readdir(await f.directory())).includes('install.lock'), false);
});

669
test/update-ui.test.mjs Normal file
View file

@ -0,0 +1,669 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as React from 'react';
import TestRenderer from 'react-test-renderer';
import { en, setImTranslator } from '../plugin-src/client/i18n.js';
import { UpdatePanel } from '../plugin-src/client/update-panel.js';
const { act, create } = TestRenderer;
function snapshot(overrides = {}) {
return {
runningVersion: '3.0.8',
installedVersion: '3.0.8',
latestVersion: null,
profileName: 'desktop-test',
environmentKind: 'desktop',
canInstall: false,
blockedReason: null,
checkedAt: null,
checkId: null,
job: null,
...overrides,
};
}
function available(overrides = {}) {
return snapshot({
latestVersion: '3.0.9', canInstall: true,
checkedAt: 1_000, checkId: 'check_one', ...overrides,
});
}
function installing(state = 'installing') {
return available({
canInstall: false,
job: { id: 'job_one', state, targetVersion: '3.0.9', message: '' },
});
}
function ok(value) { return { ok: true, value }; }
function textOf(node) {
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(textOf).join('');
return node?.children?.map(textOf).join('') ?? '';
}
function buttonNamed(renderer, label) {
return renderer.root.findAllByType('button').find((node) => textOf(node) === label);
}
async function flushMicrotasks() {
for (let index = 0; index < 8; index += 1) await Promise.resolve();
}
async function mount(t, rpcCall, props = {}, options) {
let renderer;
await act(async () => {
renderer = create(React.createElement(UpdatePanel, {
rpcCall, clientVersion: '3.0.8', ...props,
}), options);
await flushMicrotasks();
});
t.after(async () => { await act(async () => { renderer.unmount(); }); });
return renderer;
}
async function click(renderer, label) {
const button = buttonNamed(renderer, label);
assert.ok(button, `Missing button: ${label}`);
assert.equal(button.props.disabled === true, false, `Disabled button: ${label}`);
await act(async () => {
button.props.onClick();
await flushMicrotasks();
});
}
function deferred() {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
}
async function tick(t, milliseconds = 1_000) {
await act(async () => {
t.mock.timers.tick(milliseconds);
await flushMicrotasks();
});
}
test('update panel reads local status first and requires an explicit npm check and confirmation', async (t) => {
const calls = [];
const reported = [];
const renderer = await mount(t, async (endpoint, payload, signal) => {
calls.push({ endpoint, payload, signal });
if (endpoint === 'update.status') return ok(snapshot());
if (endpoint === 'update.check') return ok(available());
throw new Error(`Unexpected endpoint: ${endpoint}`);
}, { onStatus: (value) => reported.push(value.runningVersion) });
assert.deepEqual(calls.map((call) => call.endpoint), ['update.status']);
assert.deepEqual(calls[0].payload, {});
assert.deepEqual(reported, ['3.0.8']);
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
await click(renderer, '检查更新');
assert.deepEqual(calls.map((call) => call.endpoint), ['update.status', 'update.check']);
assert.deepEqual(calls[1].payload, {});
assert.equal(renderer.root.findByProps({ role: 'dialog' }).props['aria-modal'], 'true');
assert.match(textOf(renderer.toJSON()), /发现新版本/);
assert.match(textOf(renderer.toJSON()), /desktop-test/);
assert.match(textOf(renderer.toJSON()), /registry\.npmjs\.org/);
assert.match(textOf(renderer.toJSON()), /需手动重启后台.*本功能不会自动重启或主动刷新页面/);
assert.ok(buttonNamed(renderer, '安装更新'));
assert.ok(buttonNamed(renderer, '更新至 v3.0.9'));
assert.equal(calls.some((call) => call.endpoint === 'update.install'), false);
await click(renderer, '关闭');
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
});
test('confirmed installation ignores double clicks, polls the Host and stops at manual restart', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const pendingInstall = deferred();
const calls = [];
let current = snapshot();
const renderer = await mount(t, async (endpoint, payload, signal) => {
calls.push({ endpoint, payload, signal });
if (endpoint === 'update.status') return ok(current);
if (endpoint === 'update.check') return ok(current = available());
if (endpoint === 'update.install') return pendingInstall.promise;
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
await click(renderer, '检查更新');
const confirm = buttonNamed(renderer, '安装更新');
await act(async () => {
confirm.props.onClick();
confirm.props.onClick();
await flushMicrotasks();
});
const installs = calls.filter((call) => call.endpoint === 'update.install');
assert.equal(installs.length, 1);
assert.deepEqual(Object.keys(installs[0].payload).sort(), ['checkId', 'requestId']);
assert.equal(installs[0].payload.checkId, 'check_one');
assert.equal(typeof installs[0].payload.requestId, 'string');
assert.equal(installs[0].signal, undefined);
current = installing();
await act(async () => {
pendingInstall.resolve(ok(current));
await flushMicrotasks();
});
assert.match(textOf(renderer.toJSON()), /正在安装/);
await click(renderer, '关闭');
current = installing('verifying');
await tick(t);
await click(renderer, '正在更新…');
assert.match(textOf(renderer.toJSON()), /正在校验安装结果/);
current = installing('restart-required');
current.installedVersion = '3.0.9';
await tick(t);
assert.match(textOf(renderer.toJSON()), /已安装,待手动重启/);
assert.match(textOf(renderer.toJSON()), /已安装版本v3.0.9/);
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
assert.equal(buttonNamed(renderer, '重新检查'), undefined);
const callsAtCompletion = calls.length;
await tick(t, 10_000);
assert.equal(calls.length, callsAtCompletion);
assert.equal(calls.some((call) => /restart|reload/.test(call.endpoint)), false);
assert.ok(buttonNamed(renderer, '待手动重启'));
});
test('a retry after a lost installation response reuses the same request id', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const requests = [];
const renderer = await mount(t, async (endpoint, payload) => {
if (endpoint === 'update.status') return ok(available());
if (endpoint === 'update.install') {
requests.push(payload);
if (requests.length === 1) throw new Error('Connection closed');
return ok(installing());
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
await click(renderer, '更新至 v3.0.9');
await click(renderer, '安装更新');
assert.match(textOf(renderer.toJSON()), /Connection closed/);
await click(renderer, '安装更新');
assert.equal(requests.length, 2);
assert.deepEqual(requests[1], requests[0]);
});
test('a failed npm check cannot reuse an earlier up-to-date result', async (t) => {
const renderer = await mount(t, async (endpoint) => {
if (endpoint === 'update.status') {
return ok(snapshot({ latestVersion: '3.0.8', checkedAt: 1_000, blockedReason: 'no-update' }));
}
return { ok: false, error: { code: 'check-failed', message: 'npm timed out' } };
});
await click(renderer, '检查更新');
assert.match(textOf(renderer.toJSON()), /无法访问 npm 或请求超时/);
assert.match(textOf(renderer.toJSON()), /尚未检查/);
assert.doesNotMatch(textOf(renderer.toJSON()), /已是最新版本|当前版本无需更新/);
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
});
test('source installations can check npm but never offer automatic replacement', async (t) => {
const calls = [];
const renderer = await mount(t, async (endpoint) => {
calls.push(endpoint);
return ok(endpoint === 'update.status'
? snapshot({ blockedReason: 'source-install' })
: available({ blockedReason: 'source-install', canInstall: false, checkId: null }));
});
await click(renderer, '检查更新');
assert.deepEqual(calls, ['update.status', 'update.check']);
assert.match(textOf(renderer.toJSON()), /当前是源码或链接安装/);
assert.match(textOf(renderer.toJSON()), /v3.0.9/);
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
});
test('an older Host gives manual update guidance instead of breaking settings', async (t) => {
const renderer = await mount(t, async () => {
const error = new Error('Unknown endpoint update.status');
error.rpcError = { code: 'endpoint-not-found', message: error.message };
throw error;
});
await click(renderer, '检查更新');
assert.match(textOf(renderer.toJSON()), /当前 Host 不支持更新接口/);
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
assert.ok(buttonNamed(renderer, '重新检查'));
});
test('unmount aborts an in-flight status poll and cannot create a zombie timer', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const pendingPoll = deferred();
const signals = [];
let reads = 0;
const reported = [];
const renderer = await mount(t, async (endpoint, payload, signal) => {
assert.equal(endpoint, 'update.status');
signals.push(signal);
reads += 1;
return reads === 1 ? ok(installing()) : pendingPoll.promise;
}, { onStatus: (value) => reported.push(value) });
await tick(t);
assert.equal(reads, 2);
await act(async () => { renderer.unmount(); });
assert.equal(signals[1].aborted, true);
await act(async () => {
pendingPoll.resolve(ok(installing()));
await flushMicrotasks();
});
await tick(t, 10_000);
assert.equal(reads, 2);
assert.equal(reported.length, 1);
});
test('unmount aborts a pending version check but does not cancel an installation request', async (t) => {
const pending = deferred();
let checkSignal;
const checking = await mount(t, async (endpoint, payload, signal) => {
if (endpoint === 'update.status') return ok(snapshot());
checkSignal = signal;
return pending.promise;
});
await click(checking, '检查更新');
await act(async () => { checking.unmount(); });
assert.equal(checkSignal.aborted, true);
await act(async () => { pending.resolve(ok(available())); });
const pendingInstall = deferred();
let installSignal = 'not called';
const reports = [];
const installingRenderer = await mount(t, async (endpoint, payload, signal) => {
if (endpoint === 'update.status') return ok(available());
assert.equal(endpoint, 'update.install');
installSignal = signal;
return pendingInstall.promise;
}, { onStatus: (value) => reports.push(value) });
await click(installingRenderer, '更新至 v3.0.9');
await click(installingRenderer, '安装更新');
await act(async () => { installingRenderer.unmount(); });
assert.equal(installSignal, undefined);
await act(async () => { pendingInstall.resolve(ok(installing())); });
assert.equal(reports.length, 1);
});
test('pending restart shows manual restart guidance instead of a redundant page refresh suggestion', async (t) => {
const calls = [];
const current = installing('restart-required');
current.installedVersion = '3.0.9';
const renderer = await mount(t, async (endpoint) => {
calls.push(endpoint);
return ok(current);
}, { clientVersion: '3.0.7' });
await click(renderer, '待手动重启');
assert.deepEqual(calls, ['update.status', 'update.status']);
assert.match(textOf(renderer.toJSON()), /运行版本v3.0.8/);
assert.match(textOf(renderer.toJSON()), /页面版本v3.0.7/);
assert.match(textOf(renderer.toJSON()), /手动重启当前 Harness 或 Desktop/);
assert.doesNotMatch(textOf(renderer.toJSON()), /页面版本与运行版本不同|手动刷新页面/);
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
assert.equal(buttonNamed(renderer, '重新检查'), undefined);
const stalePage = await mount(t, async () => ok(available()), { clientVersion: '3.0.7' });
await click(stalePage, '更新至 v3.0.9');
assert.match(textOf(stalePage.toJSON()), /页面版本与运行版本不同/);
assert.match(textOf(stalePage.toJSON()), /手动刷新页面.*手动重启 Harness 或 Desktop/);
});
test('remounting the new client retains the old Host version and restores the restart requirement', async (t) => {
const calls = [];
let current = snapshot();
let headerVersion = '3.0.8';
const rpcCall = async (endpoint) => {
calls.push(endpoint);
return ok(current);
};
const onStatus = (value) => { headerVersion = value.runningVersion; };
const initial = await mount(t, rpcCall, { onStatus });
await act(async () => { initial.unmount(); });
current = snapshot({ installedVersion: '3.0.9', blockedReason: 'pending-restart' });
headerVersion = '3.0.9';
const remounted = await mount(t, rpcCall, { clientVersion: '3.0.9', onStatus });
assert.equal(headerVersion, '3.0.8');
await click(remounted, '待手动重启');
assert.match(textOf(remounted.toJSON()), /运行版本v3.0.8/);
assert.match(textOf(remounted.toJSON()), /已安装版本v3.0.9/);
assert.match(textOf(remounted.toJSON()), /页面版本v3.0.9/);
assert.match(textOf(remounted.toJSON()), /已安装,待手动重启/);
assert.doesNotMatch(textOf(remounted.toJSON()), /手动刷新页面/);
assert.equal(buttonNamed(remounted, '安装更新'), undefined);
assert.equal(buttonNamed(remounted, '重新检查'), undefined);
assert.deepEqual(calls, ['update.status', 'update.status', 'update.status']);
});
test('manual status refresh observes a restarted Host without remounting or checking npm', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
const headerVersions = [];
let current = snapshot({
runningVersion: '3.0.7', installedVersion: '3.0.8', blockedReason: 'pending-restart',
job: { id: 'restart_job', state: 'restart-required', targetVersion: '3.0.8', message: null },
});
const rpcCall = async (endpoint, payload) => {
calls.push({ endpoint, payload });
return ok(current);
};
const renderer = await mount(t, rpcCall, {
clientVersion: '3.0.7', onStatus: (value) => headerVersions.push(value.runningVersion),
});
const originalPanel = renderer.root.findByType(UpdatePanel);
await click(renderer, '待手动重启');
const refreshButton = buttonNamed(renderer, '刷新状态');
assert.ok(refreshButton);
await tick(t, 10_000);
assert.equal(calls.length, 2);
current = { ...current, runningVersion: '3.0.8', blockedReason: null,
job: { ...current.job, state: 'completed' } };
await click(renderer, '刷新状态');
assert.equal(renderer.root.findByType(UpdatePanel), originalPanel);
assert.equal(originalPanel.props.rpcCall, rpcCall);
assert.deepEqual(headerVersions, ['3.0.7', '3.0.7', '3.0.8']);
assert.match(textOf(renderer.toJSON()), /运行版本v3.0.8/);
assert.match(textOf(renderer.toJSON()), /更新已生效/);
assert.doesNotMatch(textOf(renderer.toJSON()), /待手动重启/);
assert.equal(buttonNamed(renderer, '刷新状态'), undefined);
assert.ok(buttonNamed(renderer, '重新检查') === refreshButton, 'The focused footer button must keep its node identity');
assert.deepEqual(calls, Array.from({ length: 3 }, () => ({ endpoint: 'update.status', payload: {} })));
await tick(t, 10_000);
assert.equal(calls.length, 3);
});
test('a late uncertainty poll cannot restore an old Host after an install retry and manual refresh', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const oldPoll = deferred();
const headers = [];
const pending = snapshot({
runningVersion: '3.0.7', installedVersion: '3.0.8', blockedReason: 'pending-restart',
job: { id: 'retry_job', state: 'restart-required', targetVersion: '3.0.8', message: null },
});
const completed = { ...pending, runningVersion: '3.0.8', blockedReason: null,
job: { ...pending.job, state: 'completed' } };
let reads = 0;
let installs = 0;
let oldSignal;
const renderer = await mount(t, async (endpoint, payload, signal) => {
if (endpoint === 'update.install') {
installs += 1;
if (installs === 1) throw new Error('Lost install response');
return ok(pending);
}
assert.equal(endpoint, 'update.status');
reads += 1;
if (reads === 1) return ok(available({
runningVersion: '3.0.7', installedVersion: '3.0.7', latestVersion: '3.0.8',
}));
if (reads === 2) {
oldSignal = signal;
return oldPoll.promise;
}
return ok(completed);
}, { clientVersion: '3.0.7', onStatus: (value) => headers.push(value.runningVersion) });
await click(renderer, '更新至 v3.0.8');
await click(renderer, '安装更新');
await tick(t);
await click(renderer, '安装更新');
assert.equal(oldSignal.aborted, true);
await click(renderer, '刷新状态');
await act(async () => {
oldPoll.resolve(ok(pending));
await flushMicrotasks();
});
assert.deepEqual(headers, ['3.0.7', '3.0.7', '3.0.8']);
assert.equal(oldSignal.aborted, true);
assert.match(textOf(renderer.toJSON()), /更新已生效/);
assert.doesNotMatch(textOf(renderer.toJSON()), /待手动重启/);
await tick(t, 10_000);
assert.equal(reads, 3);
});
test('cancelling an uncertainty poll still resumes polling an accepted active installation', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const oldPoll = deferred();
const states = [];
let reads = 0;
let installs = 0;
let oldSignal;
const renderer = await mount(t, async (endpoint, payload, signal) => {
if (endpoint === 'update.install') {
installs += 1;
if (installs === 1) throw new Error('Lost install response');
return ok(installing());
}
assert.equal(endpoint, 'update.status');
reads += 1;
if (reads === 1) return ok(available());
if (reads === 2) {
oldSignal = signal;
return oldPoll.promise;
}
return ok(installing('restart-required'));
}, { onStatus: (value) => states.push(value.job?.state ?? null) });
await click(renderer, '更新至 v3.0.9');
await click(renderer, '安装更新');
await tick(t);
await click(renderer, '安装更新');
assert.equal(oldSignal.aborted, true);
await act(async () => {
oldPoll.resolve(ok(available()));
await flushMicrotasks();
});
assert.equal(oldSignal.aborted, true);
assert.match(textOf(renderer.toJSON()), /正在安装/);
await tick(t);
assert.deepEqual(states, [null, 'installing', 'restart-required']);
assert.equal(reads, 3);
});
test('a failed local status refresh releases the button and can be retried', async (t) => {
const calls = [];
const pending = snapshot({
runningVersion: '3.0.7', installedVersion: '3.0.8', blockedReason: 'pending-restart',
});
const renderer = await mount(t, async (endpoint) => {
calls.push(endpoint);
if (calls.length === 1) return ok(pending);
if (calls.length === 2) throw new Error('Host is restarting');
return ok(snapshot({
job: { id: 'restart_job', state: 'completed', targetVersion: '3.0.8', message: null },
}));
}, { clientVersion: '3.0.7' });
await click(renderer, '待手动重启');
assert.match(textOf(renderer.toJSON()), /Host is restarting/);
assert.match(textOf(renderer.toJSON()), /待手动重启/);
assert.equal(buttonNamed(renderer, '刷新状态').props.disabled, false);
await click(renderer, '刷新状态');
assert.match(textOf(renderer.toJSON()), /更新已生效/);
assert.doesNotMatch(textOf(renderer.toJSON()), /Host is restarting|待手动重启/);
assert.deepEqual(calls, ['update.status', 'update.status', 'update.status']);
});
test('opening a stale restart notice reads the new Host status once', async (t) => {
const calls = [];
const headerVersions = [];
let current = snapshot({
runningVersion: '3.0.7', installedVersion: '3.0.8', blockedReason: 'pending-restart',
});
const rpcCall = async (endpoint) => {
calls.push(endpoint);
return ok(current);
};
const renderer = await mount(t, rpcCall, {
clientVersion: '3.0.7', onStatus: (value) => headerVersions.push(value.runningVersion),
});
current = snapshot({
job: { id: 'restart_job', state: 'completed', targetVersion: '3.0.8', message: null },
});
await click(renderer, '待手动重启');
assert.deepEqual(headerVersions, ['3.0.7', '3.0.8']);
assert.match(textOf(renderer.toJSON()), /更新已生效/);
assert.doesNotMatch(textOf(renderer.toJSON()), /待手动重启/);
assert.deepEqual(calls, ['update.status', 'update.status']);
});
test('unmount aborts a manual status refresh and ignores its late response', async (t) => {
const pending = deferred();
const headers = [];
const signals = [];
const current = snapshot({
runningVersion: '3.0.7', installedVersion: '3.0.8', blockedReason: 'pending-restart',
});
const renderer = await mount(t, async (endpoint, payload, signal) => {
assert.equal(endpoint, 'update.status');
signals.push(signal);
return signals.length < 3 ? ok(current) : pending.promise;
}, { onStatus: (value) => headers.push(value.runningVersion) });
await click(renderer, '待手动重启');
const refresh = buttonNamed(renderer, '刷新状态');
await act(async () => {
refresh.props.onClick();
refresh.props.onClick();
await flushMicrotasks();
});
assert.equal(signals.length, 3);
assert.equal(buttonNamed(renderer, '刷新状态').props.disabled, true);
await act(async () => { renderer.unmount(); });
assert.equal(signals[2].aborted, true);
await act(async () => { pending.resolve(ok(snapshot())); });
assert.deepEqual(headers, ['3.0.7', '3.0.7']);
});
test('disk changes and a persisted failure render actionable localized status', async (t) => {
const pendingRestart = await mount(t, async () => ok(snapshot({
installedVersion: '3.0.9', blockedReason: 'pending-restart',
})));
await click(pendingRestart, '待手动重启');
assert.match(textOf(pendingRestart.toJSON()), /已安装,待手动重启/);
assert.equal(buttonNamed(pendingRestart, '重新检查'), undefined);
const failed = await mount(t, async () => ok(snapshot({
job: { id: 'failed_job', state: 'failed', targetVersion: '3.0.9', message: 'install-failed' },
})));
await click(failed, '检查更新');
assert.match(textOf(failed.toJSON()), /目标版本v3.0.9/);
assert.match(textOf(failed.toJSON()), /安装失败,请检查当前安装状态后重试/);
assert.doesNotMatch(textOf(failed.toJSON()), /install-failed/);
const uncertain = await mount(t, async () => ok(snapshot({
job: { id: 'uncertain_job', state: 'interrupted', targetVersion: '3.0.9', message: 'state-unavailable' },
})));
await click(uncertain, '检查更新');
assert.match(textOf(uncertain.toJSON()), /无法安全保存更新状态,请先检查当前安装结果/);
assert.doesNotMatch(textOf(uncertain.toJSON()), /未开始安装/);
});
test('update confirmation, source protection and failures have English copy', async (t) => {
setImTranslator((key) => en[key] ?? key);
t.after(() => setImTranslator(null));
let current = available();
const renderer = await mount(t, async (endpoint) => {
if (endpoint === 'update.install') {
return { ok: false, error: { code: 'check-expired', message: 'expired' } };
}
return ok(current);
});
await click(renderer, 'Update to v3.0.9');
assert.match(textOf(renderer.toJSON()), /Install update/);
assert.doesNotMatch(textOf(renderer.toJSON()), /[\p{Script=Han}]/u);
await click(renderer, 'Install update');
assert.match(textOf(renderer.toJSON()), /version confirmation expired/);
assert.doesNotMatch(textOf(renderer.toJSON()), /[\p{Script=Han}]/u);
current = available({ canInstall: false, checkId: null, blockedReason: 'source-install' });
await click(renderer, 'Check again');
assert.match(textOf(renderer.toJSON()), /source or linked installation/);
assert.doesNotMatch(textOf(renderer.toJSON()), /[\p{Script=Han}]/u);
current = installing('completed');
current.runningVersion = '3.0.9';
current.installedVersion = '3.0.9';
await click(renderer, 'Check again');
assert.match(textOf(renderer.toJSON()), /Updated version is active/);
assert.doesNotMatch(textOf(renderer.toJSON()), /Update is running|[\p{Script=Han}]/u);
});
test('dialog actions retain focus before disabling the initiating button', async (t) => {
const scenarios = [
{
initial: snapshot({ installedVersion: '3.0.9', blockedReason: 'pending-restart' }),
open: '待手动重启', action: '刷新状态', endpoint: 'update.status',
},
{ initial: available(), open: '更新至 v3.0.9', action: '重新检查', endpoint: 'update.check' },
{ initial: available(), open: '更新至 v3.0.9', action: '安装更新', endpoint: 'update.install' },
];
for (const scenario of scenarios) {
const response = deferred();
const trace = [];
let submitting = false;
const renderer = await mount(t, async (endpoint) => {
if (submitting) {
trace.push(endpoint);
return response.promise;
}
return ok(scenario.initial);
});
await click(renderer, scenario.open);
const button = buttonNamed(renderer, scenario.action);
submitting = true;
await act(async () => {
button.props.onClick({
currentTarget: {
closest(selector) {
assert.equal(selector, '.dim-updateDialog');
return { focus() { trace.push('focus'); } };
},
},
});
await flushMicrotasks();
});
assert.deepEqual(trace, ['focus', scenario.endpoint], scenario.action);
assert.equal(button.props.disabled, true);
await act(async () => { response.resolve(ok(scenario.initial)); });
}
});
test('the confirmation supports keyboard focus, tab wrapping, Escape and focus restoration', async (t) => {
const original = Object.getOwnPropertyDescriptor(globalThis, 'document');
const previous = { isConnected: true, focus() { document.activeElement = previous; } };
const first = { focus() { document.activeElement = first; } };
const last = { focus() { document.activeElement = last; } };
const dialogNode = {
focus() { document.activeElement = dialogNode; },
querySelectorAll() { return [first, last]; },
};
Object.defineProperty(globalThis, 'document', {
configurable: true, value: { activeElement: previous },
});
t.after(() => {
if (original) Object.defineProperty(globalThis, 'document', original);
else delete globalThis.document;
});
const renderer = await mount(t, async () => ok(available()), {}, {
createNodeMock(element) {
return element.props?.className === 'dim-updateDialog' ? dialogNode : null;
},
});
await click(renderer, '更新至 v3.0.9');
assert.equal(document.activeElement, dialogNode);
const dialog = renderer.root.findByProps({ role: 'dialog' });
let prevented = 0;
await act(async () => {
dialog.props.onKeyDown({ key: 'Tab', shiftKey: false, preventDefault() { prevented += 1; } });
});
assert.equal(document.activeElement, first);
await act(async () => {
dialog.props.onKeyDown({ key: 'Tab', shiftKey: true, preventDefault() { prevented += 1; } });
});
assert.equal(document.activeElement, last);
await act(async () => {
dialog.props.onKeyDown({ key: 'Escape', preventDefault() {}, stopPropagation() {} });
});
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
assert.equal(document.activeElement, previous);
assert.equal(prevented, 2);
});