feat: ship skill and MCP workflows with signed updater for v0.0.7
This commit is contained in:
1 parent
1b52df0603
commit
a01085c656
136 files changed
+18646
-217
No files matched your search
@@ -0,0 +1,56 @@
|
||||
!include "nsProcess.nsh"
|
||||
|
||||
!macro customCheckAppRunning
|
||||
${GetParameters} $R0
|
||||
ClearErrors
|
||||
${GetOptions} $R0 "/YCODEWAITPID=" $R1
|
||||
${IfNot} ${Errors}
|
||||
${If} $R1 <= 0
|
||||
SetErrorLevel 2
|
||||
Quit
|
||||
${EndIf}
|
||||
; Updates wait for this instance only; never kill another installation.
|
||||
; ?e captures OpenProcess's native error in the same System call.
|
||||
System::Call 'kernel32::OpenProcess(i 0x100000, i 0, i R1) p .R2 ?e'
|
||||
Pop $R3
|
||||
${If} $R2 != 0
|
||||
System::Call 'kernel32::WaitForSingleObject(p R2, i 120000) i .R4'
|
||||
System::Call 'kernel32::CloseHandle(p R2)'
|
||||
${If} $R4 != 0
|
||||
SetErrorLevel 2
|
||||
Quit
|
||||
${EndIf}
|
||||
${ElseIf} $R3 != 87
|
||||
; ERROR_INVALID_PARAMETER means the watched PID already exited.
|
||||
SetErrorLevel 2
|
||||
Quit
|
||||
${EndIf}
|
||||
|
||||
; Renderer children can outlive the PID. Check both the normal wait path and
|
||||
; the ERROR_INVALID_PARAMETER path for this exact installed executable.
|
||||
System::Call 'kernel32::SetEnvironmentVariable(t "YCODE_UPDATE_TARGET_DIR", t "$INSTDIR\${APP_EXECUTABLE_FILENAME}") i .R4'
|
||||
${If} $R4 == 0
|
||||
SetErrorLevel 2
|
||||
Quit
|
||||
${EndIf}
|
||||
nsExec::ExecToStack `"$PowerShellPath" -NoLogo -NoProfile -NonInteractive -Command "trap { exit 2 }; $$ErrorActionPreference='Stop'; $$target=([IO.Path]::GetFullPath($$env:YCODE_UPDATE_TARGET_DIR)).TrimEnd([char]92); $$deadline=[DateTime]::UtcNow.AddSeconds(5); do { $$all=@(Get-CimInstance Win32_Process | Where-Object { $$_.Name -ieq '${APP_EXECUTABLE_FILENAME}' }); $$found=$$false; foreach ($$process in $$all) { if (-not $$process.ExecutablePath) { $$found=$$true; continue }; if ((([IO.Path]::GetFullPath($$process.ExecutablePath)).TrimEnd([char]92)) -ieq $$target) { $$found=$$true; break } }; if (-not $$found) { exit 0 }; Start-Sleep -Milliseconds 500 } while ([DateTime]::UtcNow -lt $$deadline); exit 1"`
|
||||
Pop $R4
|
||||
Pop $R5
|
||||
System::Call 'kernel32::SetEnvironmentVariable(t "YCODE_UPDATE_TARGET_DIR", p 0) i .R5'
|
||||
StrCmp $R4 "0" +3
|
||||
SetErrorLevel 2
|
||||
Quit
|
||||
${Else}
|
||||
; For manual setup/uninstall, refuse to proceed while YCode is open.
|
||||
!insertmacro nsProcess::FindProcess "${APP_EXECUTABLE_FILENAME}" $R0
|
||||
${If} $R0 == 0
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "Please close ${PRODUCT_NAME} before continuing." /SD IDOK
|
||||
SetErrorLevel 2
|
||||
Quit
|
||||
${EndIf}
|
||||
${If} $R0 != 603
|
||||
SetErrorLevel 2
|
||||
Quit
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
!macroend
|
||||
@@ -0,0 +1,122 @@
|
||||
# Claude 追加与压缩协议本地验证
|
||||
|
||||
日期:2026-10-20
|
||||
验证状态:SDK/CLI 协议探针和生产 ClaudeKernel 追加、压缩集成回归通过;Electron 主进程端到端结果另见本轮交接记录,不代表 WorkBuddy 1:1 验收。
|
||||
|
||||
## 1. 验证对象
|
||||
|
||||
- `@anthropic-ai/claude-agent-sdk`:`0.3.304`
|
||||
- SDK 内置 Claude Code:`2.1.304`
|
||||
- `node_modules/@anthropic-ai/claude-agent-sdk-win32-x64/claude.exe`
|
||||
- Claude Code SHA-256:`019E5174D5D954585EDB1A946FE280E44A973050B4897034ADF0E16306F85FDD`
|
||||
- 运行平台:Windows x64
|
||||
|
||||
本验证使用本机回环模拟的 Anthropic Messages SSE,不连接真实模型服务。HOME、USERPROFILE、
|
||||
`CLAUDE_CONFIG_DIR` 和 `CLAUDE_SECURESTORAGE_CONFIG_DIR` 均重定向到临时目录,仅使用 dummy key。
|
||||
因此本文件证明的是当前 SDK/CLI 的消息协议和帧归属行为,不是模型质量、真实供应商行为或
|
||||
WorkBuddy 原版行为。WorkBuddy 原版结论仍必须通过 REA 采集并记录其 Evidence ID。
|
||||
|
||||
## 2. 追加协议探针
|
||||
|
||||
探针文件:
|
||||
|
||||
- `scripts/claude-append-protocol-probe.ts`
|
||||
- `scripts/build-claude-append-probe.mjs`
|
||||
|
||||
探针通过 SDK 原始 `query()` API 创建一个保持打开的异步输入流,流程如下:
|
||||
|
||||
1. 发送带 UUID `INITIAL` 的 `PROBE_INITIAL`。
|
||||
2. 挂起首个模型请求的 SSE 响应。
|
||||
3. 在首个请求仍在途时,向同一输入流追加带 UUID 的 `PROBE_APPEND_A`、`PROBE_APPEND_B`。
|
||||
4. 不调用 `interrupt()`,不关闭输入流;释放首个响应后等待全部逻辑回合结束。
|
||||
5. 对照捕获的模型请求体、SDK 运行时帧和 `result` 帧。
|
||||
|
||||
本次生成报告:`.tmp/claude-append-protocol-probe.json`,顶层结果为 `PASS`。
|
||||
|
||||
### 请求侧结果
|
||||
|
||||
请求 1 只包含 `PROBE_INITIAL`,且正常完成,未被追加消息取消或改写。
|
||||
|
||||
请求 2 包含:
|
||||
|
||||
1. 初始用户消息 `PROBE_INITIAL`;
|
||||
2. 首轮 assistant 文本 `answer-1:PROBE_INITIAL`;
|
||||
3. 同一个 user message 中按顺序合并的 A、B 文本块。
|
||||
|
||||
本次 CLI 将 A 序列化为 `PROBE_APPEND_A\n`,B 保持 `PROBE_APPEND_B`。因此追加消息不会注入
|
||||
已经在途的 HTTP 请求,而是在首轮完成后成为下一逻辑回合的输入;相邻追加可合并为一个 user message。
|
||||
|
||||
### 结果与归属
|
||||
|
||||
本次观察到两个 `result`:
|
||||
|
||||
- `result[0]` 只归属 `INITIAL` UUID;
|
||||
- `result[1]` 同时归属 A、B 两个 UUID,其中 `user_message_uuid` 是最后一条 B 的 UUID。
|
||||
|
||||
两个 `assistant` 帧也按相同边界归属:首轮 assistant 不包含 A/B UUID,合并后的第二轮 assistant
|
||||
同时包含 A/B UUID。输入文本没有产生 SDK `user` echo 帧。
|
||||
|
||||
当前版本的 assistant/result 帧未观察到 `prompt_id`。可用的稳定归属键是
|
||||
`user_message_uuid` / `user_message_uuids`;在合并回合中,`user_message_uuid` 只是
|
||||
`user_message_uuids` 的最后一项,不能代表该 result 只覆盖 B。
|
||||
|
||||
关键结论:
|
||||
|
||||
- 一个 `result` 可能覆盖多个追加 UUID;不能用“收到一次 result”直接结算所有排队发送。
|
||||
- 在途请求和后续追加属于不同逻辑回合,当前观察到一个逻辑回合对应一个 `assistant` 和一个 `result`。
|
||||
- 该协议结论来自 SDK 原始异步输入流;生产 `ClaudeKernel` 的实现和验证单独记录在第 4 节。
|
||||
|
||||
## 3. 原生压缩边界复验
|
||||
|
||||
使用 `scripts/claude-kernel-integration.ts` 的 `compact` 分支,设置
|
||||
`CLAUDE_PROTOCOL_PROBE=1` 后运行真实 SDK + 内置 CLI + 回环假模型。观察到
|
||||
`system/compact_boundary`,后续收到成功 `result`,探针断言通过。
|
||||
|
||||
集成回归同时继续发送压缩后的请求,断言旧 ASK 内容不再出现在上游请求中。生产命令专用路径不附加普通聊天提醒,成功必须同时收到原生边界和成功结果;应用可见历史不随压缩删除。没有证明:
|
||||
|
||||
- 真实模型压缩后的内容质量或语义完整性;
|
||||
- 自动压缩的 token 阈值和触发条件;
|
||||
- 并发追加用户消息与压缩同时发生时的行为;
|
||||
- 压缩失败、重试和真实供应商错误分类。
|
||||
|
||||
## 4. 对 YCode 内核的影响
|
||||
|
||||
生产 `src/main/agent/claude/claude-kernel.ts` 保留单个活动根运行,新增 `appendInput()`,不放开并发 `runTurn()`:
|
||||
|
||||
1. 输入带独立 UUID 推入同一原生输入流,不取消当前请求,不声称改写已发出的上游请求。
|
||||
2. 按 `user_message_uuid(s)` 路由后续输入组;首个 result 不会提前结算仍待交付的追加项。
|
||||
3. 前一组完成后先保存旧 assistant;匹配到新组的原生身份帧后,由宿主原子保存完整用户消息及新 assistant,再确认对应队列项交付。A/B 可合并到同一原生组,但正式历史保留两条用户消息。
|
||||
4. 后续组沿用根运行的冻结模型、权限和工作区;新组保存期间工具/审批等待交接,避免挂到旧 assistant。
|
||||
5. 取消会关闭并排空原生生产者,再允许下一运行;推入前失败可明确重试,推入后未确认的消息标记 unknown,不自动重放。
|
||||
|
||||
`scripts/claude-kernel-integration.ts` 直接调用生产内核验证双追加、不打断首请求、身份归属、交接回调、空闲追加拒绝、原生压缩和压缩后请求。2026-10-20 重新构建后运行 `CLAUDE_PROTOCOL_PROBE=1`,输出 PASS(16 requests / 43 events / 9 turns)。这些数字属于确定性回环夹具,不是实际模型效果或完整 UI 验收。
|
||||
|
||||
## 5. 复验命令
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
npx tsc --ignoreConfig --noEmit --target ES2023 --module ESNext --moduleResolution Bundler --strict --skipLibCheck --types node --lib ES2023 scripts/claude-append-protocol-probe.ts
|
||||
node scripts/build-claude-append-probe.mjs
|
||||
node .tmp/claude-append-protocol-probe.mjs
|
||||
node scripts/build-claude-kernel-regression.mjs
|
||||
$env:CLAUDE_PROTOCOL_PROBE='1'
|
||||
node .tmp/claude-kernel-integration.mjs
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- 追加探针输出 `result: PASS`,报告写入 `.tmp/claude-append-protocol-probe.json`;
|
||||
- 压缩探针输出 `probe: compact`、`boundary: true`,随后 integration 输出 `result: PASS`;
|
||||
- `npm run typecheck` 通过。
|
||||
|
||||
`node .tmp/claude-append-protocol-probe.mjs` 使用随机回环端口和临时配置目录;报告中的端口、
|
||||
UUID 和消息 UUID 每次运行都会变化。稳定复验依据是版本、二进制哈希、帧结构和上述断言。
|
||||
|
||||
## 6. 限制与后续
|
||||
|
||||
- 探针只覆盖 Windows x64 和当前 SDK/CLI 版本,不能外推到其他平台或版本。
|
||||
- 假模型只返回固定文本,不覆盖真实长上下文、网络重试、限流和供应商差异。
|
||||
- 没有验证 WorkBuddy 原版是否采用相同的追加/压缩交互;该比较需另行通过 REA 完成并记录证据。
|
||||
- 原生追加与压缩混发被产品入口拒绝;真实网络故障、工具密集追加、多平台进程崩溃注入仍需要进一步验证。
|
||||
@@ -0,0 +1,42 @@
|
||||
# MCP、技能与统一输入区接入
|
||||
|
||||
日期:2026-10-20。执行来源:会话 `01a158f5-eea4-7e43-893c-21217cb00ed5` 最后确认计划。
|
||||
|
||||
## Dev 反馈补漏:管理页面
|
||||
|
||||
用户在 Dev 中发现技能/连接器页面仍显示维护:上一轮只接通了后端及输入区,没有恢复管理页,这是实施遗漏。现从本仓库已有版本恢复 SkillsPage / ConnectorsPage 的组件、图标和样式,不重新采集或绕过 REA;技能页补传 cwd、使用/创建/查找回调,ChatPane 恢复对应事件接收及返回能力管理页后的目录刷新。Dev 的 Vite HMR 日志已确认页面更新;专家页面不在本次开放范围内。
|
||||
|
||||
技能隔离 Electron 回归 `node scripts/test-skills-ui.mjs` 37 项通过,使用真实生产 skills 模块、隔离文件存储及受控文件选择器,覆盖导入/覆盖冲突、启停、详情、导出、卸载、回调与无效包。报告 `.tmp/skills-ui-3FPN3y/report.json`;已检查已安装技能截图。远程技能市场仍明确未连接,不伪造目录数据。
|
||||
|
||||
连接器页面隔离 Electron 回归 `node scripts/test-connectors-page.mjs` 26 项通过(最终报告 `.tmp/connectors-page-5zSU8J/report.json`):真实页面/样式,受控 MCP 宿主,覆盖列表、详情、管理弹窗、批准/启用、工具开关、重连、配置编辑入口、搜索、删除取消及弹窗内错误显示;不是外部 MCP 服务连接验收。主线程已检查卡片与弹窗错误截图;隐藏窗口截图已增加预绘制以避免捕获旧帧。类型检查、会话组件回归和最新完整构建通过;构建日志 `.tmp/capability-management-build.log`。
|
||||
|
||||
## 实现
|
||||
|
||||
- 继续使用 Claude Agent SDK / Claude Code,不切换内核。YCode 自有配置目录、模型、凭据和会话管理保留;停止发现项目 `.claude/skills`,保留受启停和校验约束的 `.agents/skills`。这不是 OS 文件系统隔离。
|
||||
- 技能管理和 MCP 管理 IPC 恢复真实实现。MCP 宿主维护唯一连接,Claude 通过四个小型桥接工具按需发现、调用 MCP、加载技能正文及相对资源;执行前重新校验启用、批准哈希、工具开关和连接身份。技能脚本仍需原生执行工具及权限流程。
|
||||
- 加号与斜杠共用能力目录、搜索和选择状态,消息级技能/MCP/压缩标签贯通草稿、排队、正式历史和重发。选择标签不代表能力已经运行。
|
||||
- 统一输入区决定交互、错误提示、队列与普通输入的优先级;接管时隐藏而不删除队列或草稿。权限位置为加号之后;Esc 不停止生成,明确停止按钮保留。
|
||||
- 生成中有效输入进入会话持久队列(最多 19 条);排序、删除、编辑取回、恢复与状态推送已接通。编辑取回保留磁盘暂存记录,直到实际提交确认,不先删除唯一副本。崩溃恢复或交付不明会暂停,不自动重试 unknown。
|
||||
- 立即发送走同一原生输入流,不取消重发。按 UUID 识别后续组,宿主提交历史后才从队列移除;允许 A/B 合并原生组,历史保留两条输入。首个结果不结算后续消息。当前运行配置冻结,普通队列实际派发时读取后续设置。
|
||||
- 模型/思考设置串行保存,生成期间只允许改下一请求设置,不能改工作区、模式或权限;首次建会话期间的选择也保留。当前消息的模型和上下文窗口不跟随下次选择改变。
|
||||
- 原生 `/compact` 专用路径必须得到压缩边界和成功结果,不用总结请求代替;禁止忙碌、队列未处理、无历史或混合附件/能力/正文时执行。保留可见历史。
|
||||
- 上下文采用真实流式用量及已有内核 summary 分类估计,不按输出字符计算,不把跨多请求的 result 聚合用量当作当前上下文。查询单会话单在途,结果绑定运行、模型和输入组。
|
||||
|
||||
## 已验证
|
||||
|
||||
- `npm run typecheck` 和完整 `npm run build`(最终日志 `.tmp/composer-final-build.log`)。
|
||||
- `npm run test:composer-capabilities`:队列常规/并发/故障恢复、输入区组合、能力选择器 30 项、请求设置 5 组、上下文、能力桥接 36 项(含根运行取消传播);对抗队列 16 组。
|
||||
- `npm run test:chat`、`npm run test:conversation`、`npm run test:sessions`、`npm run test:tool-approvals`、`npm run test:workbench-escape`、`npm run test:references`、`npm run test:persistence`。
|
||||
- 真实 SDK/CLI 回环协议和生产内核集成:无打断双追加、身份合并/归属、交接回调、取消后续聊、空闲追加拒绝、真实原生压缩边界和压缩后请求。`CLAUDE_PROTOCOL_PROBE=1` 输出 PASS(16 requests / 43 events / 9 turns)。环境和探针细节见 [Claude 追加与压缩协议验证](Claude追加与压缩协议验证-2026-10-20.md)。
|
||||
- `npm run test:claude-append:app`:加载真实生产 main/preload/renderer,隔离用户数据及配置,回环 SSE 挂起首响应,通过界面发送两条队列消息并连续点击立即发送,确认两项保持 delivering 直到原生身份交付;首请求未中断、A/B 合并后续原生组、两条稳定身份写入历史、正确分段 assistant、最后队列清空。26 项通过,0 renderer 错误,报告 `.tmp/claude-append-app-DFYbsq/report.json`;主线程已查看 `append-queue.png` 与 `append-final.png`,该次为浅色桌面截图。
|
||||
- 最终扩展回归:`.tmp/claude-append-app-s0u84t/report.json`,39 项通过、0 renderer 错误。在首个请求仍挂起时通过真实 IPC 选择 B 再 C,请求端点顺序为 A / A / C,助手元数据也分别为 A / A / C;下一独立请求完成后才截最终图。主线程已查看深色、720px 窄窗截图;窄窗发现长模型标签遮住权限入口,修为权限图标紧跟加号、模型名称截断,并加入权限按钮可见且中心命中不被遮挡的 DOM 检查。该响应式修正是 YCode 可用性处理,未宣称是原版同尺寸实测规则。
|
||||
|
||||
## 证据与未验证边界
|
||||
|
||||
- 原版静态依据:WorkBuddy 5.8.2 JS Evidence `ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb`;CSS Evidence `ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94`。队列最大高度 224px、顶部圆角及向下重叠沿用静态依据,不新增数量标题或折叠卡片。
|
||||
- REA 对 `http://127.0.0.1:9223` 返回 endpoint_unreachable / provider_unavailable,未绕过 REA 获取原版页面。实际激活调用链、图标精确差异、浮层尺寸和动态截图仍待原版可达后补证。
|
||||
- 任务组合策略已有独立测试,但当前 Claude 内核没有完整团队成员/后台任务事件来源,未伪造数据接线;这部分不能宣称完整任务功能或 1:1。
|
||||
- 自动能力桥接通过宿主注入测试;真实外部 MCP 服务故障/真实供应商长上下文/真实压缩质量/多平台进程崩溃与独立安全审查未完成。
|
||||
- 全部回环测试使用隔离配置及 dummy key,不证明真实模型效果。UI fixture 不等于真实后端,真实 Electron 后端回环也不等于供应商线上验收。
|
||||
- 能力选择器的键盘/IME 等通过组件回归,尚未完成全矩阵真实 Electron 键盘交互;图片/工具密集引导、实际上游断连与取消不在本次 Electron 文本回环范围内。原生 UUID 帧直接断言在内核协议测试中,Electron 测试通过请求合并、持久身份、分段历史与队列移除交叉确认。
|
||||
- 本轮不提交 Git、不发布、不安装、不重启用户安装版;保留工作区已有升级模块等修改。
|
||||
@@ -0,0 +1,283 @@
|
||||
# YCode 更新发布说明
|
||||
|
||||
> 2026-10-20 23:53(Asia/Shanghai)追加发布:线上 latest 已更新为 **0.0.7**。包含技能/MCP 输入引用、Archify 内置推荐与官方 GitHub API 固定包安装、仓库 owner 本地头像、统一详情与全局遮罩、原位安装及新任务“去使用”、斜杠原子消费和焦点恢复修正。以下旧版本条目保留为历史,不代表当前 latest。
|
||||
>
|
||||
> 安装包:`release/publish/0.0.7/YCode-0.0.7-Setup-x64.exe`,**237351255 字节**,SHA-256 **506768934c92979ba38c4ab922b58d2de98b590a125fd1a5dacd8dadd2ddaff3**。公开地址:`http://192.168.10.6/ycode/releases/versions/0.0.7/YCode-0.0.7-Setup-x64.exe`。
|
||||
>
|
||||
> 完成升级回归、发布流程17项自测、类型检查与完整构建。使用官方 checksums.json 校验过的 Electron 37.10.3 缓存 ZIP,通过命令行 electronDist 打包,未更改全局下载配置;包内版本、固定 GitHub 来源与 commit、新详情/遮罩/安装状态/Slate 删除接口、离线头像哈希和公钥一致性检查通过。先生成 Ed25519 签名清单,再以 `npm run release -- --version 0.0.7 --resume` 执行部署预检、SSH 发布与 HTTP 清单复核,历史目录不覆盖,暂存目录已清理。
|
||||
>
|
||||
> 正式 `0.0.7` 生产 EXE 使用隔离数据启动,真实 IPC 检查服务器返回 up-to-date;模拟旧版本通过正式更新后端实际完整下载,大小/SHA-256 一致、状态 ready,安装器适配器调用为0,真实安装文件/注册表状态未变化。报告:`.tmp/updates-live/2026-10-20T15-53-57-094Z/report.json`。已检查生产包启动截图。此验证没有执行 NSIS 安装/覆盖升级。
|
||||
>
|
||||
> 安装包 Authenticode 状态为 **NotSigned**,不能将 Ed25519 清单签名表述为 Windows 代码签名。日志:`.tmp/release-0.0.7-updates.log`、`.tmp/release-0.0.7-build.log`、`.tmp/release-0.0.7-package.log`、`.tmp/release-0.0.7-package-verification.json`、`.tmp/release-0.0.7-prepare.log`、`.tmp/release-0.0.7-publish.log`、`.tmp/release-0.0.7-live-verification.log`。本地版本与 lock 更新为0.0.7,未 Git 提交或打 tag,未安装或重启用户安装版。
|
||||
|
||||
> 2026-10-20 追加发布:线上 latest 已更新为 `0.0.6`,移除升级前的原生二次确认窗。
|
||||
> 安装包为 `release/publish/0.0.6/YCode-0.0.6-Setup-x64.exe`,大小 `237189173` 字节,
|
||||
> SHA-256 `7e672b4a76349fb799e6c509c2901a199e5e6bb4266dc4e78d6bffe978c33c5c`。
|
||||
> 更新测试、完整构建、打包内版本/公钥/旧弹窗移除/ASAR修复检查、SSH 发布、HTTP 签名清单和完整安装包哈希复核通过。
|
||||
> 首次一键执行在打包时访问 GitHub 超时;使用本机已有 Electron 37.10.3 缓存 ZIP(按 electron 包自带 checksums.json 验证 SHA-256)完成打包,
|
||||
> 生成签名清单后通过 `npm run release -- --version 0.0.6 --resume` 完成发布。未修改发布脚本或全局下载配置。
|
||||
> 日志:`.tmp/release-0.0.6.log`、`.tmp/release-0.0.6-package-retry.log`、`.tmp/release-0.0.6-resume.log`。
|
||||
> 未执行安装器、覆盖升级或重启用户应用;下方 `0.0.5` 状态保留为历史记录。
|
||||
|
||||
日期:2026-10-20
|
||||
状态:发布工具、服务端与推荐版本 `0.0.5` 已发布;隔离启动和真实签名下载已验收,
|
||||
尚未执行真实覆盖升级验收
|
||||
|
||||
## 1. 当前结果
|
||||
|
||||
- 更新服务器:`local001`,HTTP 基址 `http://192.168.10.6/ycode/releases/`。
|
||||
- 发布根目录:`/srv/ycode`;不可变版本目录:`/srv/ycode/releases/versions/`。
|
||||
- SSH 暂存目录:`/srv/ycode-staging`,不对外提供 HTTP 访问。
|
||||
- nginx 独立片段:`/etc/nginx/snippets/ycode-releases.conf`。
|
||||
- nginx 首次变更前备份:`/var/backups/nginx/ycode-before-20261020T091317Z-default`。
|
||||
- 下载页路由变更前备份:`/var/backups/nginx/ycode-before-20261020T092709Z-default`。
|
||||
- `/srv/baocode` 和原有 `/baocode/` location 未改动。
|
||||
- `/ycode/releases/latest.json` 当前返回 200、`Cache-Control: no-store`,版本为 `0.0.5`。
|
||||
- `/ycode/releases/` 当前返回 200,显示从签名清单生成的 `0.0.5` 下载页;页面无脚本。
|
||||
- 线上 `latest.json` 于 `2026-10-20T10:27:49.041Z` 发布,SHA-256 为
|
||||
`d985b7b94ed9e4661eec03489e801ba41aff295b4d9ea6be8a6930edf8247245`;下载页
|
||||
SHA-256 为 `42fb30160c2250ff04f23b3505a6fb0bb97aa3a1a91cb91ef4dd0cf0c314f285`。
|
||||
- 推荐线上安装包实测 `237106337` 字节,SHA-256 为
|
||||
`a3b8ece96077af81f9c8c676cb7d0d71f16a5d73332bc48b865c5cba7e92fd0d`。
|
||||
- `0.0.2`、`0.0.3`、`0.0.4` 安装包作为不可变历史版本保留,均已被 `0.0.5` 取代。
|
||||
- 发布完成后 `/srv/ycode-staging` 为空;所有访问和文件校验均已完成。
|
||||
- 公钥:`resources/updates/public-key.pem`;Electron `extraResources` 会把它打包为
|
||||
`resources/updates/public-key.pem`。
|
||||
- 公钥 SPKI SHA-256 指纹:`320a1f9016e9fbb7097074a68e1d3d15633fe05da5cb418f448372d37a8c3ea5`。
|
||||
- 私钥:`%USERPROFILE%\.ycode-release\update-signing.key`,仅当前 Windows 账户可访问,不在仓库内。
|
||||
|
||||
当前 `package.json` 为内部版本 `0.0.5`,安装包名为
|
||||
`YCode-${version}-Setup-${arch}.${ext}`。`0.0.5` 已构建、签名清单验证并发布到
|
||||
`local001`。本轮未执行安装包 Authenticode 复验,不能把该发布包声明为已做代码签名。
|
||||
|
||||
## 2. 清单契约
|
||||
|
||||
完整契约见 `resources/updates/manifest-contract.md`。
|
||||
|
||||
```json
|
||||
{
|
||||
"payload": {
|
||||
"schemaVersion": 1,
|
||||
"version": "0.0.5",
|
||||
"publishedAt": "2026-10-20T10:00:00.000Z",
|
||||
"notes": { "zh": "更新说明", "en": "Release notes" },
|
||||
"platforms": {
|
||||
"windows-x64": {
|
||||
"url": "http://192.168.10.6/ycode/releases/versions/0.0.5/YCode-0.0.5-Setup-x64.exe",
|
||||
"size": 123456789,
|
||||
"sha256": "64-lowercase-hex"
|
||||
}
|
||||
}
|
||||
},
|
||||
"signature": "base64-ed25519-signature"
|
||||
}
|
||||
```
|
||||
|
||||
- Ed25519 签名覆盖且仅覆盖 `Buffer.from(JSON.stringify(payload), "utf8")`。
|
||||
- payload 字段插入顺序属于签名契约,发布脚本固定为 `schemaVersion, version, publishedAt, notes, platforms`。
|
||||
- SHA-256 为小写十六进制;大小是实际安装包字节数。
|
||||
- 安装包 URL 必须是绝对 HTTP(S)、无查询、无片段、无凭据,并精确位于
|
||||
`<base>/versions/<version>/` 下。
|
||||
- 客户端只接受与清单同源的安装包 URL 和同源重定向。
|
||||
- `latest.json` 禁止缓存;版本目录和安装包内容不可覆盖。
|
||||
|
||||
## 3. 发布命令
|
||||
|
||||
### 一键打包发布(推荐)
|
||||
|
||||
在 Windows x64 发布机的项目目录运行:
|
||||
|
||||
```powershell
|
||||
npm run release
|
||||
```
|
||||
|
||||
按提示输入新的正式版本号,例如 `0.0.6`,随后自动执行预检、更新本地版本、升级回归测试、
|
||||
完整构建、NSIS 打包、签名清单生成、部署预检、SSH 发布与 HTTP 清单复核。默认发布到
|
||||
`local001`,继续使用现有用户目录中的私钥和仓库公钥,不需要手动复制安装包或清单。
|
||||
只输入版本号时会使用通用更新说明,也可以直接传参:
|
||||
|
||||
```powershell
|
||||
npm run release -- --version 0.0.6 --notes-zh '移除升级前的二次确认窗。' --notes-en 'Remove the upgrade confirmation dialog.'
|
||||
npm run release -- --help
|
||||
```
|
||||
|
||||
- 仅接受 `x.y.z` 正式版本;新发布版本必须高于服务器 latest,且不得低于仓库版本。
|
||||
- 安装包、签名清单保存在 `release/publish/<版本>/`;不会覆盖历史安装包,也不会自动安装或重启应用。
|
||||
- 同步更新 `package.json`、`package-lock.json` 的项目版本,不执行 Git 提交或打 tag。
|
||||
- 任一步失败即停止。本地目标版本和已有产物保留,不自动回滚服务器。
|
||||
- 已完成安装包和签名清单、但发布中断时,可复用原始产物重试;不会重新打包或重新签名:
|
||||
|
||||
```powershell
|
||||
npm run release -- --version 0.0.6 --resume
|
||||
```
|
||||
|
||||
复用模式仍校验版本、签名、大小、哈希和安装包身份;服务器同版本必须与本地清单完全一致,
|
||||
更高版本或同版本不同内容都会被拒绝。若失败时尚未生成完整签名清单,不能使用复用模式。
|
||||
若已留下不完整的版本输出目录,普通模式不会覆盖它;可使用更高版本重新发布,或确认该版本
|
||||
未发布后手动移走不完整产物,再重新运行同版本发布。
|
||||
脚本使用本地独占锁避免重复运行;异常终止留下锁时,应先确认没有发布进程运行,再处理报错指出的锁文件。
|
||||
`npm run test:release` 使用隔离的假执行器验证流程,不连接升级服务器、不实际打包或发布。
|
||||
|
||||
以下为手动分步命令,便于排障;通常无需执行。
|
||||
|
||||
先完成协调方构建,确认安装包已经包含 updater 并完成基础启动验收。不要发布当前
|
||||
`release/YCode-v0.01-Setup-x64.exe`:它的 ProductVersion 是 `0.0.1`,且没有更新能力。
|
||||
|
||||
### 3.1 准备签名清单
|
||||
|
||||
```powershell
|
||||
Set-Location 'C:\Users\Administrator\code\YCode'
|
||||
|
||||
node scripts/updates/prepare-release.mjs `
|
||||
--installer 'release\YCode-0.0.5-Setup-x64.exe' `
|
||||
--version '0.0.5' `
|
||||
--notes-zh '修复 Electron ASAR 安装标记误判,恢复重启升级。' `
|
||||
--notes-en 'Fix Electron ASAR installation-marker misclassification and restore restart-based upgrades.' `
|
||||
--output '.tmp\updates\0.0.5\latest.json'
|
||||
```
|
||||
|
||||
该命令在生成清单前强制检查:
|
||||
|
||||
1. 文件名安全且为 `.exe`。
|
||||
2. 实际 EXE ProductVersion 等于 `--version`。
|
||||
3. `package.json` 的版本等于 `--version`。
|
||||
4. 版本高于无 updater 的 `0.0.1` 基线。
|
||||
5. 安装包大小为 20 MiB 到 4 GiB,SHA-256 从实际文件计算。
|
||||
6. 签名能被仓库公钥验证。
|
||||
|
||||
### 3.2 只读预检
|
||||
|
||||
```powershell
|
||||
node scripts/updates/deploy.mjs `
|
||||
--manifest '.tmp\updates\0.0.5\latest.json' `
|
||||
--installer 'release\YCode-0.0.5-Setup-x64.exe' `
|
||||
--transport ssh `
|
||||
--ssh-host local001 `
|
||||
--dry-run
|
||||
```
|
||||
|
||||
预检会重新计算安装包哈希、验证清单签名、读取并验证服务器现有
|
||||
`latest.json`,然后检查版本单调性;不会修改服务器。
|
||||
|
||||
### 3.3 正式发布
|
||||
|
||||
```powershell
|
||||
node scripts/updates/deploy.mjs `
|
||||
--manifest '.tmp\updates\0.0.5\latest.json' `
|
||||
--installer 'release\YCode-0.0.5-Setup-x64.exe' `
|
||||
--transport ssh `
|
||||
--ssh-host local001
|
||||
```
|
||||
|
||||
发布顺序:
|
||||
|
||||
1. 安装包和清单先上传到 `/srv/ycode-staging/<随机 ID>/`。
|
||||
2. 服务端重新校验大小和 SHA-256。
|
||||
3. 新版本先以目录重命名方式进入不可变 `versions/<version>/`。
|
||||
4. 从已验签 payload 生成转义后的静态 `index.html`,包含版本、安装包链接、SHA-256 和中英文说明。
|
||||
5. 在文件锁下再次确认现有 `latest.json` 未在发布期间变化。
|
||||
6. 先原子替换 `index.html`,再原子替换 `latest.json`;最后一步失败时回滚旧页面。
|
||||
|
||||
任何一步失败,旧 `latest.json` 保持不变。已落盘但未成为 latest 的版本目录可以作为
|
||||
同版本重试的幂等产物;若同版本目录中的包哈希不同,发布会被拒绝。
|
||||
|
||||
### 3.4 发布后复核
|
||||
|
||||
```powershell
|
||||
node scripts/updates/deploy.mjs `
|
||||
--manifest '.tmp\updates\0.0.5\latest.json' `
|
||||
--installer 'release\YCode-0.0.5-Setup-x64.exe' `
|
||||
--transport ssh `
|
||||
--ssh-host local001 `
|
||||
--dry-run
|
||||
|
||||
Invoke-WebRequest 'http://192.168.10.6/ycode/releases/latest.json' -UseBasicParsing |
|
||||
Select-Object StatusCode,Headers,Content
|
||||
```
|
||||
|
||||
## 4. 验证命令
|
||||
|
||||
### 4.1 发布工具自测
|
||||
|
||||
```powershell
|
||||
node scripts/updates/selftest.mjs
|
||||
node scripts/updates/contract-selftest.mjs
|
||||
```
|
||||
|
||||
`selftest.mjs` 使用临时目录、临时密钥和本地 HTTP 服务,不上传 SSH,也不会写正式站点。
|
||||
当前覆盖:
|
||||
|
||||
- 密钥生成、公钥格式和 Windows ACL。
|
||||
- SemVer 优先级和 build metadata 规则。
|
||||
- 签名精确载荷、说明/哈希/签名篡改拒绝。
|
||||
- URL 查询、片段、凭据、跨版本路径、时间格式和小写哈希策略。
|
||||
- 本地假传输发布、HTTP 拉取、大小与 SHA-256 复核。
|
||||
- 幂等重发、同版本冲突、降级拒绝、失败前旧 latest 保持。
|
||||
- 下载页文本转义、无脚本、latest/页面同步切换和失败时页面回滚。
|
||||
|
||||
`contract-selftest.mjs` 用 esbuild 加载 `src/main/updates/manifest.ts`,验证发布端清单能被
|
||||
后端实际解析、验签和路径策略接受。
|
||||
|
||||
### 4.2 服务端验证
|
||||
|
||||
```powershell
|
||||
ssh local001 "nginx -t"
|
||||
|
||||
Invoke-WebRequest 'http://192.168.10.6/ycode/releases/latest.json' -Method Head -UseBasicParsing
|
||||
Invoke-WebRequest 'http://192.168.10.6/ycode/releases/' -UseBasicParsing
|
||||
Invoke-WebRequest 'http://192.168.10.6/baocode/' -Method Head -UseBasicParsing
|
||||
```
|
||||
|
||||
当前预期状态:YCode latest 为 200 且版本为 `0.0.5`,下载页为 200 并包含线上版本、链接和哈希,
|
||||
`/baocode/` 为 403;YCode 不能开启目录浏览。
|
||||
|
||||
`0.0.5` 的隔离启动和真实 LAN 清单/安装包下载验证已通过:
|
||||
|
||||
- 脚本:`node scripts/verify-updates-live.cjs --expected-version 0.0.5`
|
||||
- 结果:PASS;实际启动、签名清单校验、完整下载、大小和 SHA-256 均通过。
|
||||
- 报告:`.tmp/updates-live/2026-10-20T10-28-49-489Z/report.json`。
|
||||
- 产物验证:`app.asar` 内版本为 `0.0.5`,更新实现包含修复后的 ASAR 安装标记判定,打包公钥与仓库一致。
|
||||
- 边界:本轮没有启动 NSIS 安装器,没有重启或修改当前用户安装,因此这不等于真实覆盖升级验收。
|
||||
|
||||
## 5. 首次更新基线
|
||||
|
||||
现有 `0.0.1` 客户端没有 updater,不能通过更新服务器升级到 `0.0.5`。
|
||||
|
||||
正在运行的 `0.0.3`、`0.0.4` 客户端受旧版检查器的 ASAR 安装标记误判影响,应手工安装
|
||||
`0.0.5` 引导包,不能把“发现更新”视为该路径已经可用。`0.0.1` 同样需要手工安装
|
||||
`0.0.5`。其他旧版客户端的真实覆盖路径仍以隔离环境复测为准。引导到 `0.0.5` 后:
|
||||
|
||||
```text
|
||||
0.0.1(无 updater,手动安装 0.0.5)
|
||||
0.0.3 / 0.0.4(检查器存在已知问题,手动安装 0.0.5)
|
||||
→ 启动 0.0.5 并确认实际版本
|
||||
→ 后续版本由 0.0.5 检查、下载、签名校验
|
||||
→ 用户在应用中确认重启
|
||||
→ NSIS 覆盖安装
|
||||
→ 新版本启动后核对实际版本
|
||||
```
|
||||
|
||||
不能把“上传成功”“清单可下载”或“客户端显示发现更新”当作端到端升级验收。至少还需要一次
|
||||
隔离安装目录的真实 `0.0.4 → 0.0.5` 覆盖安装测试。
|
||||
|
||||
## 6. 密钥管理
|
||||
|
||||
- 私钥只允许存在于发布机用户目录或离线加密备份,不进入仓库、服务器、构建产物、聊天记录和日志。
|
||||
- 公钥随客户端分发并作为固定信任锚。
|
||||
- 当前私钥 ACL 仅授予 `WIN-JA8HHLU9216\Administrator`。
|
||||
- 发布前可运行 `init-key.mjs` 的默认路径检查;路径已存在时脚本会拒绝覆盖。
|
||||
- 私钥丢失或泄露时,不能用旧客户端认可的常规更新完成轮换。需要构建一个包含新公钥的
|
||||
临时安装包,手工分发并安装,同时提升版本;之后再由新密钥继续发布。
|
||||
- 不要为了“省事”在服务器上保留私钥,也不要将私钥复制到 `/srv/ycode`。
|
||||
|
||||
## 7. 已知限制
|
||||
|
||||
- 当前为 LAN HTTP,仅适用于能访问 `192.168.10.6` 的设备。没有 TLS、登录鉴权、限流和外部 CDN。
|
||||
- 签名可防止安装包和清单被替换,但不能防止同网段攻击者阻断下载或造成拒绝服务。
|
||||
- 尚未接入 HTTPS 域名、证书、灰度发布、多渠道、增量更新或正式 Authenticode 签名。
|
||||
- 服务端没有回滚命令;发布后需要回退时必须发布一个更高版本,不能覆盖已发布版本目录。
|
||||
- `resources/updates/public-key.pem` 已进入 Electron `extraResources`;正式包仍需做一次资源存在性验收。
|
||||
- 推荐 `0.0.5` 安装包的 Authenticode 状态本轮未复验;真实性目前以 Ed25519 签名清单、
|
||||
文件大小和 SHA-256 为已验证边界。
|
||||
- 本轮没有执行真实 `0.0.4 → 0.0.5` 覆盖升级,也没有在独立用户目录完成完整安装/卸载验收。
|
||||
- 专用 NSIS 运行时探针没有执行;安装器写入、覆盖、重启和卸载行为仍是测试缺口。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 升级模块落地(2026-10-20)
|
||||
|
||||
## 真实升级反馈:ASAR 标记误判
|
||||
|
||||
用户在 `0.0.3 → 0.0.4` 测试中实际遇到 `NSIS 安装标记不是文件`。物理安装文件正常;原实现使用 Electron patched `fs.stat`,将 `resources/app.asar` 当作虚拟目录。此前 Node 后端测试和未调用安装器的启动/下载验证没有覆盖这个语义差异。
|
||||
|
||||
修复通过 `physical-files.ts` 的 Electron `node:original-fs` 适配器执行物理 `stat/realpath`,由生产更新入口注入。没有全局关闭 ASAR,也没有移除 `isFile`、注册表或路径校验。真实 Electron 只读测试 `node scripts/verify-updates-installer-marker.cjs` 已通过:当前真实安装目录校验成功,真实目录 marker 和错误 registry 仍被拒绝;未启动安装器、未修改用户安装。报告 `.tmp/updates-installer-marker/2026-10-20T10-23-07-489Z/report.json`。
|
||||
|
||||
修正版为 `0.0.5`。旧版 `0.0.3/0.0.4` 的运行中检查器无法通过服务器目标包自我修复,需要手动覆盖安装一次修正版后,再测试后续自动升级。
|
||||
|
||||
`0.0.5` 已构建并发布,237106337 字节,SHA-256 `a3b8ece96077af81f9c8c676cb7d0d71f16a5d73332bc48b865c5cba7e92fd0d`。最终 ASAR 已核对包含物理文件适配器;生产包隔离启动与正式 IPC、服务器签名清单及完整下载校验通过,报告 `.tmp/updates-live/2026-10-20T10-28-49-489Z/report.json`。没有替用户执行安装或重启,真实覆盖安装仍待用户继续验证。
|
||||
|
||||
## 实现与边界
|
||||
|
||||
- Windows x64、Electron + NSIS 按用户安装;账号菜单、关于页与侧栏共享真实更新状态。
|
||||
- 启动 10 秒后检查,此后每 5 分钟检查;发现更新后后台下载,已忽略版本不自动下载。
|
||||
- 检查中、下载进度、就绪、失败重试、按版本忽略/折叠、手动下载及安装诊断日志已接通。
|
||||
- 原版参考为 WorkBuddy 5.8.3,ASAR SHA-256 `3e50fcb5b060deba4f1ea9a8c293cf955bf12d7e52d971985de914350afd9077`;主要 REA Evidence 为 `ev_6f16b9ef02d9c5fb142e7d686ea8caeeec79797b621d615aaa532b49b65d7ad9`(renderer)与 `ev_e41b1e44cb0c430be5d8caa14b4e3a4710a6925e0bdff870d42ffa73b5e1a5c8`(Windows 更新模块)。本轮复用历史静态 REA 结论,没有通过其他渠道查看原版。
|
||||
- 强制升级、商店/企业分支、启动 splash 与架构纠正仍在后续完整复刻清单中。本轮普通更新交互不是完整 1:1 验收;缺少原版全状态运行态布局/动效/像素证据。
|
||||
|
||||
## 安全与退出
|
||||
|
||||
- 完整 payload 使用独立 YCode Ed25519 密钥签名;客户端先验签再采用版本、说明、大小、下载地址与哈希。公钥随包分发;私钥仅在仓库外保存。
|
||||
- 同源、固定版本目录、严格 SemVer、响应体上限、超时、大小与 SHA-256 校验;断点续传检查 Content-Range;缓存和安装前再次校验。
|
||||
- 多窗口 IPC 仅接受主窗口/设置窗口的主 frame;渲染层不接收 Node 能力或安装路径参数。
|
||||
- 用户确认前不启动安装器。确认后暂停新任务/终端创建,停止调度并取消、等待当前任务落库;该准备阶段不销毁全局 Claude 内核/MCP。
|
||||
- 准备超时后,不会在旧 stop 尚未结束时恢复服务;安装标记写入或 spawn 失败后允许恢复。
|
||||
- 安装器成功 spawn 是不可回退提交点;后端拒绝重复安装/检查/忽略,不再恢复后台任务。随后有界清理资源并退出,避免已启动的安装器和恢复后的工作并存。
|
||||
- NSIS 等待指定 PID 后检查残留实例;不使用进程名批量结束其他实例。真实执行行为以专项安装 QA 结果为准。
|
||||
- 安装位置绑定当前用户注册表 GUID、当前 EXE、卸载器与 app.asar,支持用户选择的目录;便携解压/开发模式不执行覆盖安装。
|
||||
- 记录目标版本,下次启动仍低于目标则提示升级未完成。安装器执行失败不会被误报为已升级。
|
||||
|
||||
## 验证
|
||||
|
||||
- `npm run test:updates`:后端 14 组;运行时门禁与生命周期专项;发布工具 12 组;发布清单与真实后端验签/路径契约检查通过。
|
||||
- `npm run build`:完整主进程、preload、renderer 构建和 Node/Web 类型检查通过。
|
||||
- `npm run test:updates:ui`:真实 Electron 主/设置窗口、正式 preload、注入确定性 updates IPC,验证渲染交互和跨窗口状态;最终报告 `.tmp/updates-ui-ZnQVYZ/report.json`,无 renderer 错误。该验证不等价于真实下载或安装。
|
||||
- `npm run test:chat`:消息顺序、聊天状态、流式提交旧回归通过。
|
||||
- `0.0.2` 的真实生产包隔离启动与 HTTP 下载验证通过;报告 `.tmp/updates-live/2026-10-20T09-43-52-041Z/report.json`。前代版本号为注入模拟,安装器调用为零,不是实际版本升级。
|
||||
- 审查发现并修复安装失败后内核永久关闭、超时清理继续破坏恢复、确认期间并发忽略、spawn 后回滚、NSIS 错误码与残留进程检查问题;修正版内部版本递增为 `0.0.3`,不覆盖不可变 `0.0.2` 发布。
|
||||
- `0.0.3` 已构建并发布,安装包 `release/YCode-0.0.3-Setup-x64.exe`,237105798 字节,SHA-256 `6f7565fcc152a99899bf74a795c8e91417516980f143fb9fccb61bde2b490200`。
|
||||
- `0.0.3` 真实生产包隔离启动与正式 preload IPC 检查通过(idle → checking → up-to-date);真实服务器签名清单及全量安装包下载/哈希通过,模拟旧版本最终 ready/100。报告 `.tmp/updates-live/2026-10-20T10-02-05-040Z/report.json`。安装器/安装回调调用均为零;真实用户注册表与安装 EXE 指纹前后未变。
|
||||
- 完整 NSIS 真机覆盖安装、原生确认取消、安装后重启/版本回查,以及等待宏的不存在 PID/睡眠 PID/第二实例/拒绝访问动态探针仍未执行。独立 QA 安装方案仅完成设计,没有构建或安装 fixture;不能把单元、渲染或真实下载测试当作已完成安装验收。
|
||||
|
||||
## 发布与使用
|
||||
|
||||
服务器为 `local001`,客户端更新入口为 `http://192.168.10.6/ycode/releases/latest.json`,手动下载页为 `http://192.168.10.6/ycode/releases/`。SSH 仅用于发布维护,客户端不需要 SSH 凭据。HTTP 限内网使用;真实性依赖内置公钥,不能防止网络阻断/旧清单重放导致的更新延迟。
|
||||
|
||||
参见 [升级发布说明](升级发布说明-2026-10-20.md)。`0.0.1` 未包含 updater,必须先关闭旧 YCode 并手动安装首个安全验收完成的基线包,之后才有自动更新能力。安装包尚未 Authenticode 签名,系统可能显示 SmartScreen 警告。没有替换或重启当前用户安装版,也没有修改其配置/会话或提交 Git。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 技能详情、安装与使用链路修正(2026-10-20)
|
||||
|
||||
## 本轮实现
|
||||
|
||||
### 头像与详情
|
||||
|
||||
- 核对 GitHub 官方仓库 owner 为 `tt-a1i`(ID `53142663`),下载官方头像并随 renderer 打包,不在运行时请求 GitHub。来源、URL 和文件哈希见[头像来源](推荐技能头像来源-2026-10-20.md)。
|
||||
- 推荐卡片、详情和带推荐来源的已安装卡片/详情统一显示头像,普通同名本地导入不套用。图片损坏降级首字母;未修改技能包、原 ZIP 哈希或内容 digest。
|
||||
- `SkillDetailShell` 统一推荐与已安装详情:头像/标题/主操作、可展开简介、真实元信息及按内容收缩的正文。移除仅展示来源却沿用正文最小高度导致的大块空白;完整 commit、包路径和哈希折叠展示,不编造安装数、使用数或市场 ID。
|
||||
- 已安装技能说明仍读取真实本地 SKILL.md。未安装时只展示内置精选简介,不提前下载整个包。
|
||||
|
||||
### 全局弹层与安装
|
||||
|
||||
- 根因:能力中心 `isolation:isolate` 限制了内部 fixed 遮罩的层叠范围,而侧栏 sticky 标题拥有独立层级。
|
||||
- 详情、导入及卸载确认使用 body 级 `SkillModalPortal`,保留源主题变量和 CSS 作用域;背景设为 inert / aria-hidden 并在退出后按原值恢复。没有降低全部侧栏层级。
|
||||
- 共用焦点栈,Esc 只关闭顶层,Tab/ShiftTab 包含折叠区 summary 并跳过闭合 details 内的不可见按钮,关闭后恢复焦点。技能弹层存在时不响应背景的应用导航快捷键。
|
||||
- 推荐卡片和详情共用操作状态。首次安全确认在原详情内展示;下载、校验、提交、失败、取消和成功均原位呈现,提交阶段不可取消。关闭详情不取消已确认安装,重新打开继续显示同一操作。
|
||||
- 成功按钮切换为“去使用”,成功提示也提供相同入口;移除列表底部重复的永久完成面板。失败可明确重试,覆盖确认仍展示当前来源、本地修改及启停保留。
|
||||
- 沿用操作 ID / 单调时间戳、防重复与跨窗口互斥。GitHub 官方 API、固定提交及 ZIP 安全/事务校验保持不变。
|
||||
|
||||
### 新草稿与斜杠
|
||||
|
||||
- 管理页各“去使用”入口统一调用 App 的新任务 intent:继承工作空间和模型,清除原任务专家绑定;带目标 owner 的 intent 将技能加入新草稿并聚焦,不发送、不创建持久会话。已有会话草稿不被覆盖。
|
||||
- 无效或仅模型调用技能不提供可执行的使用动作;禁用技能显示“启用后使用”,等待启用和状态确认成功后再跳转。
|
||||
- 斜杠问题根因:只修改 `text`,旧 `userContent` 在切换至 Slate 时重建了斜杠;菜单因检测到旧文本重新出现。
|
||||
- `withCapabilitySelection` 将技能选择、纯文本和结构化草稿合并为同一次草稿更新。斜杠采用 add-once 语义,加号菜单仍为多选 toggle。
|
||||
- 已有 Slate 编辑器通过文本叶事务删除触发区间,PointRef 保留光标与引用的相对位置,不删除附件或技能标签;恢复原生焦点以处理移除 picker 后 Slate 焦点缓存仍为 true 的情况。选择后可以直接继续输入。
|
||||
- Esc 记录当前触发文本的关闭状态,只有用户编辑后才重新检测;中文输入法组合期间不弹菜单。
|
||||
|
||||
## 接口与文件边界
|
||||
|
||||
- SkillsApi 不变;`RecommendedSkill.avatarResource` 新增可选受限标识 `archify-owner`,不是任意本地路径或 URL。
|
||||
- 输入区内部回调增加可选 `CapabilitySelectionIntent`(slash/menu 来源及已消费的 text/userContent);ReferenceContentEditor 增加删除文本区间的宿主方法。
|
||||
- 新增公共详情/portal/头像及源文件选择 helper。未修改 WorkBuddy 安装文件,也未改变技能下载的主进程信任边界。
|
||||
|
||||
## 原版依据与限制
|
||||
|
||||
- 本轮按用户选择仅做原版只读源码调查,没有启动额外 WorkBuddy 实例或安装原版技能。
|
||||
- WorkBuddy 5.8.2 已保存 REA 证据 `ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb` 的恢复源码中,`ActionRow` 随安装状态切换安装/使用;`toastSkillInstallSuccess` 带使用动作;`useTrySkill` 普通分支进入新任务草稿。
|
||||
- 本次 REA 请求 `127.0.0.1:9223` 返回 endpoint_unreachable。当前安装包 SHA-256 为 `3e50fcb5b060deba4f1ea9a8c293cf955bf12d7e52d971985de914350afd9077`,与历史技能证据不同;REA inventory 记录 45 项完整性矛盾,历史文件路径不能直接选中。此次 inventory 仅作身份/限制记录,不据此宣称分析了当前版本的详情实现。父 Evidence:`ev_1908879a0d710764cd53bd08c36bd9d70d07a004c08ee8f711ca6f849a3b6244`。
|
||||
- 因此是“历史 REA 源码行为依据 + YCode 实现及动态验收”,不是当前原版同场景动态或完整 1:1 验收。安装完成也不意味着 Archify 的执行依赖和全部绘图能力已验证。
|
||||
|
||||
## 验证
|
||||
|
||||
- `npm run test:capability-slash:ui`:真实 ComposerControls / Slate / 生产选择 helper 的隔离 Electron 测试 **21 项通过**,`.tmp/capability-slash-3UD46Z/report.json`。覆盖鼠标、Enter、裸斜杠、句中光标及后文、已有与重复技能、继续输入、多行、附件、Esc、IME;零 renderer 错误。
|
||||
- `npm run test:recommended-skills:ui`:**48 项通过**,`.tmp/recommended-skills-ui-LGtBA4/report.json`。受控宿主覆盖确认与同一详情 DOM、成功入口、取消、关闭再打开保留操作、提交不能取消、禁用后启用使用、冲突替换、错误重试、外部操作、过期事件、Tab 焦点和深/浅色窄窗。不是实际网络安装。
|
||||
- `node scripts/test-skills-ui.mjs`:既有管理/真实隔离文件系统与受控 picker **37 项通过**,最终单独复跑 `.tmp/skills-ui-10SOjj/report.json`;并发运行曾发生一次坐标点击未切换批量态的测试失败,独立复跑通过,不将其隐藏为从未失败。
|
||||
- `npm run test:recommended-skills`:后端 **66 项通过**,包括离线缓存的 Archify 实包组;没有更改下载与导入校验策略。
|
||||
- `npm run test:composer-capabilities`、`npm run test:conversation`、`npm run test:persistence`、`npm run test:sessions` 均通过。输入区回归日志 `.tmp/skills-flow-composer-regression.log`。
|
||||
- 类型检查及完整构建通过,日志 `.tmp/skills-flow-build.log`。
|
||||
- 正式 main/preload/renderer 真实 GitHub 下载隔离验收 **20 项通过**,最终报告 `.tmp/recommended-app-RFA1cK/report.json`;包含 body 级遮罩、背景 inert、侧栏命中测试、全窗深色/720px 截图、离线头像、原位安装、新草稿引用、原会话草稿恢复及无持久会话新增。主线程查看同批实现的全窗/窄窗、安装成功和输入区截图。
|
||||
|
||||
所有技能安装、启停和草稿测试均使用 `.tmp` 隔离数据,未操作用户技能库、执行技能脚本或请求模型。保留既有修改,未提交、发布或重启用户安装版。
|
||||
@@ -1,5 +1,19 @@
|
||||
# YCode 接手状态(2026-10-15)
|
||||
|
||||
> 2026-10-20 23:53(Asia/Shanghai)0.0.7已发布:[发布记录与验证](升级发布说明-2026-10-20.md)。包含本轮推荐技能/GitHub安装、头像、详情遮罩、原位安装/去使用、新任务草稿、斜杠菜单及既有技能/MCP输入区接入。local001 latest从0.0.6升至0.0.7,历史版本保留;237351255字节,SHA-256 `506768934c92979ba38c4ab922b58d2de98b590a125fd1a5dacd8dadd2ddaff3`。升级回归、发布17项、完整构建、包内内容、公钥、签名清单、SSH/HTTP、正式生产EXE隔离启动与真实全量下载校验通过,报告 `.tmp/updates-live/2026-10-20T15-53-57-094Z/report.json`。安装包Authenticode为NotSigned;仅更新清单Ed25519签名。未执行安装器、覆盖升级或重启用户安装版,未Git提交。
|
||||
|
||||
> 2026-10-20 技能详情/安装/使用链路修正:[实现与验证](技能详情安装与使用链路修正-2026-10-20.md)。Archify 改用本地打包的 GitHub owner 头像;推荐和已安装共用紧凑详情,body portal + inert 修复侧栏 sticky 标题穿透。安装确认、进度及成功“去使用”原位衔接,成功提示也可用;使用进入新任务草稿、保留旧会话草稿、不自动发送。斜杠选择原子消费 text/userContent,Slate 文本事务保留引用及光标、修复原生焦点,Esc/IME/重复选择和加号多选语义区分。真实 Slate 21项、推荐UI48项、既有技能管理37项、后端66项、最终正式Electron真实GitHub安装/遮罩/草稿保留20项及类型检查、构建、输入区/对话/持久化/会话回归通过;最终报告 `.tmp/recommended-app-RFA1cK/report.json`。原版仅依据历史5.8.2 REA源码,当前runtime不可达,不是当前原版动态1:1验收。隔离验证,未改用户技能、执行包内脚本、请求模型、提交、发布或重启安装版。
|
||||
|
||||
> 2026-10-20 推荐技能 GitHub 下载修复:用户网络对 raw.githubusercontent.com 直链 ECONNRESET,改用 GitHub 官方 Contents API 的 raw 响应,仍固定完整 commit、原 ZIP 大小及 SHA-256,不引入镜像或修改代理。后端66项、官方API真实下载实包48项、正式Electron隔离安装与引用11项、类型检查及构建通过;重启仓库dev加载修复,未动安装版或用户技能。见[修复与证据](推荐技能GitHub安装-2026-10-20.md#github-直链连接失败修复)。
|
||||
|
||||
> 2026-10-20 内置推荐技能与 GitHub 安装:[实现与验证边界](推荐技能GitHub安装-2026-10-20.md)。技能页推荐接固定 Archify 3.2.0 包,主进程下载、哈希/元数据校验、取消、覆盖确认及原子回滚,来源同次登记,安装/启停/卸载实时刷新输入区目录。后端62项、实包48项、推荐UI37项(含深色/窄窗/外部操作/过期事件)、既有技能UI37项、最终生产Electron真实GitHub下载与输入框回填11项,以及类型检查、完整构建和相关回归通过;已看真实截图,零renderer错误。仅验证文件安装与引用,不宣称Archify全部运行能力或完整1:1。隔离验收,未改用户技能、执行包内脚本、提交、发布或重启用户安装版。
|
||||
|
||||
> 2026-10-20 MCP/技能与对话输入区接入:[实现与验证边界](MCP技能与输入区接入-2026-10-20.md)、[原生追加/压缩协议](Claude追加与压缩协议验证-2026-10-20.md)。启用宿主管理能力桥接、加号/斜杠引用、统一输入区、持久追加队列与不打断的原生立即发送;支持下一请求模型设置、真实上下文统计和原生压缩。完整构建、相关回归及真实 Electron 主进程/预加载/渲染器+本地 Claude SDK/CLI 回环追加验证通过,主线程已检查队列和最终历史截图。原版 REA 运行时不可达,团队/后台任务来源未完整接线,不能宣称完整 1:1;真实外部服务和故障注入仍有边界。保留升级模块等既有修改,未提交、发布、安装或重启用户安装版。
|
||||
|
||||
> 2026-10-20 真实升级 ASAR 标记误判修复:用户 `0.0.3 → 0.0.4` 报“NSIS安装标记不是文件”,已确认 Electron patched fs 将正常物理 `app.asar` 显示为虚拟目录。改为由生产入口注入 `node:original-fs` 的物理 stat/realpath,保留全部安装身份检查。真实Electron只读 marker 回归、后端14组、发布与契约、完整构建通过;`0.0.5` 已构建发布,生产包隔离启动及真实全量下载校验通过,未执行安装。旧3/4检查器不能靠服务器目标包修复,须手动覆盖安装一次5;详细记录见[升级模块落地](升级模块落地-2026-10-20.md#真实升级反馈asar-标记误判)。
|
||||
|
||||
> 2026-10-20 普通升级模块落地:[实现与验证边界](升级模块落地-2026-10-20.md)、[发布与密钥说明](升级发布说明-2026-10-20.md)。账号菜单/关于页/侧栏接共享更新状态,签名清单、后台下载/续传、大小与哈希校验、退出确认、任务收尾、NSIS 定点等待和安装失败回查已实现;更新源为 local001 的独立 `/ycode/`,最终修正版 `0.0.3` 已构建并发布,不覆盖不可变 `0.0.2`。后端14组、门禁/生命周期、发布12组、真实后端契约、NSIS静态安全检查、真实Electron UI21项(0renderer错误)、聊天回归及完整构建通过。完整真实NSIS覆盖安装/重启尚未验收,不是完整1:1;强制升级/商店/企业/启动splash继续待补。未替换当前用户安装版;原0.0.1无更新器,需先手动安装基线。未提交Git。
|
||||
|
||||
> 2026-10-20 文件所有权租约移除:[范围与验证](文件所有权租约移除记录-2026-10-20.md)。按用户要求删除子智能体调度器的 `leases`、`rootIntents`、`rootLeases` 和 root 等待队列;主智能体工具、子智能体工具以及父子同文件写入不再因所有权被拒绝或串行。保留 `spawn_agent` 的声明文件列表校验,`write` 模式仍只能写自己声明的路径。`npm run typecheck`、`npm run test:subagents`(31 项)和完整 `npm run build` 通过。移除后并发写同一文件按工具实际执行顺序生效,无跨工具锁;需要原子一致性时由调用方或更高层流程处理。
|
||||
|
||||
> 2026-10-20 自建 AppContainer 沙盒移除:[范围与验证](沙盒移除记录-2026-10-20.md)。按用户要求删除 `src/main/sandbox`、`native/sandbox`、`resources/sandbox`、`sandbox_workspace`/`sandbox_git` 工具、相关构建/测试/维护脚本、打包 extraResources 和忽略规则;保留 Electron `webPreferences.sandbox` 渲染进程隔离及“默认权限/完全访问”权限界面。`npm run typecheck` 与 `npm run build` 通过,`out/main/index.mjs` 不再包含 `windows-appcontainer`、`sandbox_workspace`、`sandbox_git`、`ycode-sandbox`;`release/win-unpacked/resources/sandbox` 已清理。本仓库不再提供自建 OS 级沙盒;此前所有沙盒验收结果只作为历史记录,不代表当前代码能力。
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 内置推荐技能与 GitHub 安装(2026-10-20)
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 技能页“推荐”改用随应用发布的精选目录;首发 Archify。SkillHub、套件仍明确未接入,不伪装联网市场。
|
||||
- 复用现有技能卡片、详情及主题;提供安装确认、下载/校验/安装进度、取消、错误重试、查看已安装和使用技能。替换确认展示当前来源、本地修改及启停状态。
|
||||
- 主进程只接受推荐 ID 和操作 ID/覆盖确认,不接受渲染器提供下载地址、哈希或安装目录。
|
||||
- 固定 HTTPS GitHub 官方来源,最多三次白名单域名重定向,20 MB 下载上限、120 秒下载超时;校验 SHA-256、名称、版本、许可证,再复用 ZIP 安全检查及暂存/备份/回滚事务。
|
||||
- 全进程最多一个推荐安装,不排队、不自动重试。下载/校验可取消,原子提交不可取消;停服或退出中止尚未提交的操作。启动清理可确认所属进程已退出的遗留临时目录,不续装。
|
||||
- 来源元数据和技能登记同次提交;旧登记兼容。同名导入、不同版本或修改后的技能须明确替换,保留启停状态;同来源同版本且完整的安装直接返回现有记录。手动导入覆盖会清除推荐来源。
|
||||
- 安装、卸载、启停广播变更,刷新推荐、已安装列表及输入框能力目录,无需重启。
|
||||
|
||||
## 固定包
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| 推荐 ID / 名称 | archify |
|
||||
| 仓库 | tt-a1i/archify |
|
||||
| commit | 25cf37080fc8b1dbe758a4efc3935b55f3327083 |
|
||||
| 包路径 | archify.zip |
|
||||
| SHA-256 | 70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5 |
|
||||
| 下载字节数 | 5,271,959 |
|
||||
| ZIP 文件数 / 展开字节数 | 200 / 17,812,933 |
|
||||
| 许可证 / 显示版本 | MIT / 3.2.0 |
|
||||
|
||||
实包 `SKILL.md` 中的 `metadata.version` 为 `3.2`,`package.json` 为 `3.2.0`;仅将数字形式的 major.minor 与 major.minor.patch 归一比较,未改用最新分支。包含 101 个 JS/MJS 脚本;安装不执行包内脚本,也不读取其更新地址。
|
||||
|
||||
## 代码入口
|
||||
|
||||
- `src/main/capabilities/skill-recommendations.ts`:版本化精选目录。
|
||||
- `src/main/capabilities/recommended-skills.ts`:下载、安全边界、进度和生命周期;注入端口仅作测试隔离。
|
||||
- `src/main/capabilities/skills.ts`:来源保存、嵌套版本解析、现有导入器复用及提交前冲突检查。
|
||||
- `src/shared/capabilities.ts`、`src/main/ipc.ts`、`src/preload/index.ts`:目录、安装、取消、当前操作快照、进度及变更事件。
|
||||
- `src/renderer/src/capabilities/RecommendedSkills.tsx`:卡片与安装 UI,使用操作 ID 和单调时间戳抵御旧状态覆盖。
|
||||
- `src/renderer/src/workbuddy/composer/useCapabilityCatalog.ts`:变更后刷新输入框目录。
|
||||
|
||||
## 已验证
|
||||
|
||||
- `npm run typecheck` 和完整 `npm run build` 通过;构建日志 `.tmp/recommended-skills-build.log`。
|
||||
- `npm run test:recommended-skills`:62 项通过;包含模拟网络错误/重定向/超限/超时/断连/取消,真实隔离技能库中的同名覆盖、禁用保留、来源清除、安装中被修改、登记写入失败回滚与停服恢复。实包组使用本地缓存,没有缓存会明确 SKIP。
|
||||
- `npm run test:recommended-skills:ui`:最终 37 项通过,`.tmp/recommended-skills-ui-ED7GR0/report.json`;使用隔离 Electron 和受控宿主,覆盖其他窗口操作互斥、过期事件不回退终态、深色主题及 720px 窄窗无横向溢出。主线程检查深色/窄窗截图;不冒充真实网络安装或完整应用壳的深色验收。
|
||||
- `node scripts/test-skills-ui.mjs`:既有技能管理 37 项通过,`.tmp/skills-ui-NeLZZW/report.json`。
|
||||
- `npm run test:recommended-skills:package`:实包隔离审计 48 项通过,最终复跑 `.tmp/archify-package-ZtYztG/report.json`(使用已下载固定 ZIP)。
|
||||
- 最终构建执行 `npm run test:recommended-skills:app`:11 项通过,`.tmp/recommended-app-G08DdX/report.json`。使用正式 main/preload/renderer、真实固定 GitHub 下载与隔离目录,确认阶段事件、正文/CLI 资源、来源、幂等、禁用保留,以及“使用技能”回填真实输入框;零 renderer 错误。主线程检查已安装及输入框截图。
|
||||
- `npm run test:composer-capabilities`、`npm run test:persistence`、`npm run test:sessions`、`npm run test:conversation` 通过。
|
||||
|
||||
## 明确边界
|
||||
|
||||
- 固定哈希说明下载内容一致,不构成技能代码安全审计;本次没有执行 Archify 命令、准备其运行依赖或验证全部绘图能力。
|
||||
- 不支持任意地址、私有仓库、自动更新或后台安装队列;后续推荐通过目录条目随 YCode 发布。
|
||||
- 所有验收使用隔离技能库,没有改动用户现有技能,也没有请求模型 API。未提交 Git、发布安装包或重启用户安装版;保留原有 MCP、输入区和升级模块等工作区修改。
|
||||
- 本次不新增 WorkBuddy 原版采集或完整 1:1 结论。
|
||||
|
||||
## GitHub 直链连接失败修复
|
||||
|
||||
用户实际 dev 安装报 `fetch failed`;同机重现 `raw.githubusercontent.com` 返回 `ECONNRESET`。改用 GitHub 官方 Contents API,完整 commit 放在 `ref` 参数,通过 `Accept: application/vnd.github.raw+json` 获取 ZIP 原始字节。实测 5,271,959 字节及登记哈希完全一致,不是 JSON 元数据,也未切换分支或第三方代理镜像。
|
||||
|
||||
下载仅放行 `api.github.com` 和 `raw.githubusercontent.com` 的 HTTPS 请求/跳转,原有大小、哈希、ZIP 安全、取消和事务保护保持不变。不自动重试或改变 Windows 代理配置。连接失败提供网络/代理提示,API 限流提供稍后重试提示;未加入 token 或私有仓库认证。
|
||||
|
||||
后端新增官方 API、固定提交、raw Accept 和限流回归,共 66 项通过。类型检查及完整构建通过(`.tmp/recommended-github-api-build.log`)。实包审计通过官方 API 真实下载 48 项通过:`.tmp/archify-package-kTQcPp/report.json`,未使用本地缓存。
|
||||
|
||||
最终正式 main/preload/renderer 隔离验收 11 项通过(`.tmp/recommended-app-kwzDyE/report.json`),实际经新 API 完成安装及输入框引用回填,零 renderer 错误。重启了本仓库的 dev 进程以加载修复,未重启安装版、修改系统代理或改动用户技能。
|
||||
@@ -0,0 +1,10 @@
|
||||
# Archify 推荐头像来源
|
||||
|
||||
- 核对接口:GitHub 官方 `GET https://api.github.com/repos/tt-a1i/archify`。
|
||||
- 仓库 owner:`tt-a1i`,GitHub 用户 ID `53142663`,类型 `User`。使用仓库所有者头像,不推断所有 contributors 都是维护者。
|
||||
- 头像 URL:`https://avatars.githubusercontent.com/u/53142663?v=4&s=192`。
|
||||
- 响应类型 `image/jpeg`,9,461 字节;本地文件 `src/renderer/src/capabilities/assets/archify-owner-avatar.jpg`。
|
||||
- SHA-256:`72e7c333a86d7954786fbb6782a6090ff1524964db50e7f293c6ba77f46ab85e`。
|
||||
- 随应用打包,页面不实时请求 GitHub;加载失败降级为首字母。推荐及带 Archify 推荐来源的已安装技能使用此图,普通同名本地技能不自动套用。
|
||||
- 图片只用于标识公开仓库所有者,不作为 YCode 自有商标或已获头像许可的声明;后续若收到权利人请求应更换或移除。
|
||||
- 未注入技能 ZIP、未改动包哈希或安装内容 digest。推荐记录的可选 `avatarResource` 为受限资源标识,不接受任意本地路径。
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ycode",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ycode",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.7",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.304",
|
||||
"@anthropic-ai/sdk": "^0.135.0",
|
||||
|
||||
+22
-2
@@ -1,11 +1,19 @@
|
||||
{
|
||||
"name": "ycode",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.7",
|
||||
"private": true,
|
||||
"description": "YCode - 个人 AI Agent 桌面应用(Electron)",
|
||||
"author": "YCode",
|
||||
"main": "./out/main/index.mjs",
|
||||
"scripts": {
|
||||
"test:capability-slash:ui": "node scripts/test-capability-slash-ui.mjs",
|
||||
"test:recommended-skills": "esbuild scripts/recommended-skills-backend-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/recommended-skills-backend-regression.mjs --log-level=warning && node .tmp/recommended-skills-backend-regression.mjs",
|
||||
"test:recommended-skills:ui": "node scripts/test-recommended-skills-ui.mjs",
|
||||
"test:recommended-skills:app": "node scripts/verify-recommended-skills-app.cjs",
|
||||
"test:recommended-skills:package": "node scripts/test-recommended-archify-package.mjs",
|
||||
"test:composer-capabilities": "node scripts/run-composer-capabilities-regression.mjs",
|
||||
"test:claude-append": "node scripts/build-claude-append-probe.mjs && node .tmp/claude-append-protocol-probe.mjs",
|
||||
"test:claude-append:app": "node scripts/verify-claude-append-app.cjs",
|
||||
"dev": "electron-vite dev",
|
||||
"dev:renderer": "electron-vite dev --rendererOnly",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
@@ -28,6 +36,13 @@
|
||||
"icon:build": "node scripts/gen-icon.mjs",
|
||||
"dist": "npm run build && electron-builder --win dir",
|
||||
"dist:installer": "npm run build && electron-builder --win nsis",
|
||||
"release": "node scripts/updates/release.mjs",
|
||||
"test:release": "node scripts/updates/release-selftest.mjs",
|
||||
"test:updates": "esbuild scripts/update-backend-regression.ts scripts/update-maintenance-regression.ts --bundle --platform=node --format=esm --out-extension:.js=.mjs --outdir=.tmp/update-tests --log-level=warning && node .tmp/update-tests/update-backend-regression.mjs && node .tmp/update-tests/update-maintenance-regression.mjs && node scripts/updates/selftest.mjs && node scripts/updates/contract-selftest.mjs && node scripts/updates/nsis-safety-regression.mjs",
|
||||
"test:updates:ui": "node scripts/verify-updates-ui.cjs",
|
||||
"updates:key": "node scripts/updates/init-key.mjs",
|
||||
"updates:prepare": "node scripts/updates/prepare-release.mjs",
|
||||
"updates:deploy": "node scripts/updates/deploy.mjs",
|
||||
"verify:ui": "node scripts/verify-ui.mjs",
|
||||
"wb:capture": "node scripts/wb-capture.mjs 9223 .tmp/wb-capture",
|
||||
"test:media": "esbuild scripts/mermaid-regression.ts --bundle --platform=node --format=esm --packages=external --jsx=automatic --loader:.css=empty --outfile=.tmp/media-tests/mermaid-regression.mjs --log-level=warning && node .tmp/media-tests/mermaid-regression.mjs && esbuild scripts/image-renderer-regression.tsx scripts/path-renderer-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --out-extension:.js=.cjs --outdir=.tmp/media-tests --log-level=warning && node .tmp/media-tests/image-renderer-regression.cjs && node .tmp/media-tests/path-renderer-regression.cjs",
|
||||
@@ -140,6 +155,10 @@
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/**"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "resources/updates/public-key.pem",
|
||||
"to": "updates/public-key.pem"
|
||||
},
|
||||
{
|
||||
"from": "resources/model-metadata",
|
||||
"to": "model-metadata",
|
||||
@@ -170,12 +189,13 @@
|
||||
],
|
||||
"win": {
|
||||
"icon": "build/icon.ico",
|
||||
"artifactName": "YCode-v0.01-Setup-${arch}.${ext}",
|
||||
"artifactName": "YCode-${version}-Setup-${arch}.${ext}",
|
||||
"target": [
|
||||
"dir"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"include": "build/installer.nsh",
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# YCode Update Manifest Contract v1
|
||||
|
||||
The release tooling and updater backend use this exact document:
|
||||
|
||||
```json
|
||||
{
|
||||
"payload": {
|
||||
"schemaVersion": 1,
|
||||
"version": "0.0.2",
|
||||
"publishedAt": "2026-10-20T10:00:00.000Z",
|
||||
"notes": {
|
||||
"zh": "更新说明",
|
||||
"en": "Release notes"
|
||||
},
|
||||
"platforms": {
|
||||
"windows-x64": {
|
||||
"url": "http://192.168.10.6/ycode/releases/versions/0.0.2/YCode-0.0.2-Setup-x64.exe",
|
||||
"size": 123456789,
|
||||
"sha256": "64-lowercase-hex-characters"
|
||||
}
|
||||
}
|
||||
},
|
||||
"signature": "base64-encoded-64-byte-ed25519-signature"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- The Ed25519 signature covers exactly `Buffer.from(JSON.stringify(payload), "utf8")`.
|
||||
- Payload property insertion order is part of the contract and must be preserved.
|
||||
- `publishedAt` is canonical UTC ISO-8601 with millisecond precision.
|
||||
- `version` is strict SemVer. Build metadata does not establish newer precedence.
|
||||
- `sha256` is lowercase hexadecimal; `size` is the exact installer byte length.
|
||||
- The artifact URL is absolute HTTP(S), has no query, fragment or credentials, and is exactly
|
||||
`<base>/versions/<version>/<safe-installer-name>.exe`.
|
||||
- The client and release tooling require the artifact origin to match the manifest origin.
|
||||
- `latest.json` is served at `<base>/latest.json`; artifacts are immutable version directories.
|
||||
- `resources/updates/public-key.pem` is the SPKI PEM trust anchor. Private keys never enter the
|
||||
repository, the server, build artifacts or logs.
|
||||
|
||||
Default base URL: `http://192.168.10.6/ycode/releases/`
|
||||
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAxUeHhwDpkjDtIDI9EliFgdmMjahN5Mgq/2cX2+gLkKY=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,11 @@
|
||||
import { build } from 'esbuild'
|
||||
|
||||
await build({
|
||||
entryPoints: ['scripts/claude-append-protocol-probe.ts'],
|
||||
outfile: '.tmp/claude-append-protocol-probe.mjs',
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
external: ['@anthropic-ai/claude-agent-sdk'],
|
||||
logLevel: 'warning'
|
||||
})
|
||||
@@ -8,6 +8,9 @@ await build({
|
||||
format: 'esm',
|
||||
external: ['@anthropic-ai/claude-agent-sdk'],
|
||||
logLevel: 'warning',
|
||||
banner: {
|
||||
js: `import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);`
|
||||
},
|
||||
plugins: [{
|
||||
name: 'claude-kernel-electron-stub',
|
||||
setup(plugin) {
|
||||
@@ -19,6 +22,7 @@ await build({
|
||||
getPath: (name) => name === 'userData' ? process.env.CLAUDE_TEST_USER_DATA : process.cwd(),
|
||||
getAppPath: () => process.cwd()
|
||||
};
|
||||
export const safeStorage = { isEncryptionAvailable: () => false };
|
||||
`
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { build } from 'esbuild'
|
||||
|
||||
await build({
|
||||
entryPoints: ['scripts/request-settings-regression.ts'],
|
||||
outfile: '.tmp/request-settings-regression.mjs',
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
logLevel: 'warning'
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import {
|
||||
CapabilityPicker,
|
||||
capabilitySelectionKey,
|
||||
detectCapabilitySlashTrigger,
|
||||
filterCapabilityItems,
|
||||
resolveCapabilityPickerKey,
|
||||
type CapabilityPickerItem
|
||||
} from '../src/renderer/src/workbuddy/composer/CapabilityPicker'
|
||||
|
||||
const items: CapabilityPickerItem[] = [
|
||||
{ id: 'skill:review', name: '代码审查', description: '检查实现缺陷与风险', kind: 'skill' },
|
||||
{ id: 'mcp-server', name: 'postgres', description: '查询本地数据库', kind: 'mcp' },
|
||||
{ id: 'compact', name: 'compact', description: '压缩当前上下文', kind: 'command' }
|
||||
]
|
||||
const checks: string[] = []
|
||||
const check = (name: string, actual: unknown, expected: unknown): void => {
|
||||
assert.deepEqual(actual, expected, name)
|
||||
checks.push(name)
|
||||
}
|
||||
|
||||
check('empty query preserves registration order', filterCapabilityItems(items, ''), items)
|
||||
check('filter matches names and IDs case-insensitively',
|
||||
filterCapabilityItems(items, '/POST').map(item => item.id), ['mcp-server'])
|
||||
check('filter matches descriptions', filterCapabilityItems(items, '上下文').map(item => item.id), ['compact'])
|
||||
check('filter matches kind labels', filterCapabilityItems(items, '命令').map(item => item.id), ['compact'])
|
||||
check('multiple terms must all match', filterCapabilityItems(items, '数据库 本地').map(item => item.id), ['mcp-server'])
|
||||
check('selection identity includes kind', capabilitySelectionKey({ id: 'compact', kind: 'command' }), 'command:compact')
|
||||
|
||||
check('slash at input start is detected',
|
||||
detectCapabilitySlashTrigger('/comp', 5, items), { start: 0, end: 5, query: 'comp' })
|
||||
const independent = '请使用 /mcp'
|
||||
check('independent slash token is detected',
|
||||
detectCapabilitySlashTrigger(independent, independent.length, items), { start: 4, end: independent.length, query: 'mcp' })
|
||||
check('plain slash opens the catalog when items exist',
|
||||
detectCapabilitySlashTrigger('/', 1, items), { start: 0, end: 1, query: '' })
|
||||
for (const value of ['https://example.com/a', 'src/renderer/file.ts', 'C:\\work\\file.ts', '/Users/me/file.txt', 'file:///tmp/a']) {
|
||||
check('path or URL does not trigger: ' + value, detectCapabilitySlashTrigger(value, value.length, items), null)
|
||||
}
|
||||
check('unknown leading token is rejected when catalog is available',
|
||||
detectCapabilitySlashTrigger('/Users', 6, items), null)
|
||||
check('slash inside ordinary text is not independent',
|
||||
detectCapabilitySlashTrigger('path/compact', 12, items), null)
|
||||
|
||||
check('IME composition suppresses Enter',
|
||||
resolveCapabilityPickerKey({ key: 'Enter', isComposing: true, itemCount: 3, activeIndex: 1 }), { type: 'none' })
|
||||
check('IME keyCode 229 suppresses selection',
|
||||
resolveCapabilityPickerKey({ key: 'Enter', keyCode: 229, itemCount: 3, activeIndex: 1 }), { type: 'none' })
|
||||
check('ArrowDown wraps', resolveCapabilityPickerKey({ key: 'ArrowDown', itemCount: 3, activeIndex: 2 }),
|
||||
{ type: 'move', index: 0 })
|
||||
check('ArrowUp wraps', resolveCapabilityPickerKey({ key: 'ArrowUp', itemCount: 3, activeIndex: 0 }),
|
||||
{ type: 'move', index: 2 })
|
||||
check('Home and End select boundaries',
|
||||
[resolveCapabilityPickerKey({ key: 'Home', itemCount: 3, activeIndex: 2 }),
|
||||
resolveCapabilityPickerKey({ key: 'End', itemCount: 3, activeIndex: 0 })],
|
||||
[{ type: 'move', index: 0 }, { type: 'move', index: 2 }])
|
||||
check('Enter selects the active item',
|
||||
resolveCapabilityPickerKey({ key: 'Enter', itemCount: 3, activeIndex: 1 }), { type: 'select', index: 1 })
|
||||
check('Escape closes', resolveCapabilityPickerKey({ key: 'Escape', itemCount: 3, activeIndex: 1 }), { type: 'close' })
|
||||
check('Enter is ignored on an empty result', resolveCapabilityPickerKey({ key: 'Enter', itemCount: 0, activeIndex: 0 }),
|
||||
{ type: 'none' })
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<CapabilityPicker items={items} selected={[{ id: 'skill:review', kind: 'skill' }]}
|
||||
onSelect={() => undefined} onClose={() => undefined} />
|
||||
)
|
||||
check('SSR renders combobox and listbox', [html.includes('role="combobox"'), html.includes('role="listbox"')], [true, true])
|
||||
check('SSR exposes type labels', ['技能', 'MCP', '命令'].every(label => html.includes(label)), true)
|
||||
check('SSR marks selected identity', html.includes('aria-selected="true"'), true)
|
||||
check('SSR renders descriptions', html.includes('检查实现缺陷与风险'), true)
|
||||
check('SSR seeds external query',
|
||||
renderToStaticMarkup(<CapabilityPicker items={items} selected={[]} onSelect={() => undefined}
|
||||
onClose={() => undefined} query="compact" />).includes('data-kind="command"'), true)
|
||||
check('SSR renders explicit empty state',
|
||||
renderToStaticMarkup(<CapabilityPicker items={items} selected={[]} onSelect={() => undefined}
|
||||
onClose={() => undefined} query="missing" />).includes('没有匹配的能力'), true)
|
||||
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length }))
|
||||
@@ -124,7 +124,8 @@ async function main(): Promise<void> {
|
||||
export const ipcMain = {
|
||||
handle: (name, handler) => state.handlers.set(name, handler)
|
||||
};
|
||||
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
|
||||
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }), showSaveDialog: async () => ({ canceled: true }) };
|
||||
export const webContents = { getAllWebContents: () => [], fromId: () => undefined };
|
||||
export const shell = { openPath: async () => '' };
|
||||
export const powerSaveBlocker = { start: () => 1, stop: () => true, isStarted: () => false };
|
||||
export class BrowserWindow {}
|
||||
|
||||
@@ -92,7 +92,7 @@ function resetStore(): void {
|
||||
})
|
||||
}
|
||||
|
||||
function installFakeWindow(send: (payload: { sessionId: string; text: string }) => Promise<SendResult>): void {
|
||||
function installFakeWindow(send: (payload: Parameters<typeof window.ycode.chat.send>[0]) => Promise<SendResult>): void {
|
||||
const api = {
|
||||
session: {
|
||||
list: async () => [session],
|
||||
@@ -718,7 +718,7 @@ async function testEarlyOrderedEventsAndDuplicateGuard(): Promise<void> {
|
||||
const firstSend = useChatStore.getState().send('question')
|
||||
assert.equal(useChatStore.getState().sending, true, 'sending should cover the pending IPC request')
|
||||
assert.equal(useChatStore.getState().messages.length, 0, 'early events wait for the send result placeholder')
|
||||
await useChatStore.getState().send('duplicate')
|
||||
await assert.rejects(useChatStore.getState().send('duplicate'), /提交/)
|
||||
assert.equal(sendCalls, 1, 'a second send must be ignored while the first send is pending')
|
||||
|
||||
response.resolve(result)
|
||||
@@ -929,7 +929,32 @@ async function testParallelSessionsAndStaleLoads(): Promise<void> {
|
||||
assert.equal(useChatStore.getState().messages[0].content, 'fast', 'stale session fetch cannot replace a newer selection')
|
||||
}
|
||||
|
||||
async function testNativeSegmentBoundaryBeforeSendResponse(): Promise<void> {
|
||||
resetStore()
|
||||
const gate = deferred<SendResult>()
|
||||
installFakeWindow(async () => gate.promise)
|
||||
const sending = useChatStore.getState().send('root')
|
||||
const runId = 'native-segment'
|
||||
const root = sendResult(runId)
|
||||
useChatStore.getState().applyEvent({ runId, sessionId: session.id, sequence: 1, type: 'text', delta: 'root answer' })
|
||||
const guide: ChatMessage = { id: 'guide-user', role: 'user', content: 'guidance', createdAt: 12, status: 'done' }
|
||||
const answer: ChatMessage = { id: 'guide-assistant', role: 'assistant', content: '', createdAt: 13, status: 'streaming', requestId: runId }
|
||||
useChatStore.getState().observeRun({ ...root, sessionId: session.id, sequence: 2, segmentBoundary: true,
|
||||
assistantMessageId: answer.id, userMessage: guide, messages: [root.userMessage,
|
||||
{ id: root.assistantMessageId, role: 'assistant', content: 'root answer', createdAt: 11, status: 'done' }, guide, answer] })
|
||||
useChatStore.getState().applyEvent({ runId, sessionId: session.id, sequence: 3, type: 'text', delta: 'guide answer' })
|
||||
gate.resolve(root)
|
||||
await sending
|
||||
useChatStore.getState().applyEvent({ runId, sessionId: session.id, sequence: 4, type: 'done', content: 'guide answer', tools: [] })
|
||||
const messages = useChatStore.getState().messages
|
||||
assert.equal(messages.find(message => message.id === root.assistantMessageId)?.content, 'root answer')
|
||||
assert.equal(messages.find(message => message.id === answer.id)?.content, 'guide answer')
|
||||
assert.equal(messages.find(message => message.id === answer.id)?.status, 'done')
|
||||
assert.equal(useChatStore.getState().running, null)
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await testNativeSegmentBoundaryBeforeSendResponse()
|
||||
resetStore()
|
||||
let inlinePayload: Parameters<typeof window.ycode.chat.send>[0] | undefined
|
||||
installFakeWindow(async payload => { inlinePayload = payload; return sendResult('inline-layout') })
|
||||
@@ -947,6 +972,26 @@ async function main(): Promise<void> {
|
||||
installFakeWindow(async () => { referenceOnlySends++; return sendResult('skill-only') })
|
||||
await useChatStore.getState().send('', { skillIds: ['skill'] })
|
||||
assert.equal(referenceOnlySends, 2, 'a skill-only draft must reach IPC')
|
||||
let capabilityPayload: Parameters<typeof window.ycode.chat.send>[0] | undefined
|
||||
resetStore()
|
||||
installFakeWindow(async payload => { capabilityPayload = payload; return sendResult('mcp-only') })
|
||||
await useChatStore.getState().send('', { mcpServerIds: ['server'] })
|
||||
assert.deepEqual(capabilityPayload?.mcpServerIds, ['server'], 'an MCP-only draft must reach IPC with its selected server')
|
||||
resetStore()
|
||||
installFakeWindow(async payload => { capabilityPayload = payload; return sendResult('compact-only') })
|
||||
await useChatStore.getState().send('/compact', { command: 'compact' })
|
||||
assert.equal(capabilityPayload?.command, 'compact', 'a command-only draft must reach IPC as a native command')
|
||||
|
||||
resetStore()
|
||||
useChatStore.setState({ currentSessionId: null, messages: [], draftContext: {}, draftId: 7 })
|
||||
const deferredResult = deferred<SendResult>()
|
||||
installFakeWindow(async () => deferredResult.promise)
|
||||
const optimisticSend = useChatStore.getState().send('', { skillIds: ['optimistic-skill'], mcpServerIds: ['optimistic-server'] })
|
||||
const optimisticUser = useChatStore.getState().messages.find(message => message.role === 'user')
|
||||
assert.deepEqual(optimisticUser?.skills?.map(item => item.id), ['optimistic-skill'], 'the optimistic user message must retain selected skill metadata')
|
||||
assert.deepEqual(optimisticUser?.mcpServerIds, ['optimistic-server'], 'the optimistic user message must retain MCP selection metadata')
|
||||
deferredResult.resolve(sendResult('optimistic-mcp'))
|
||||
await optimisticSend
|
||||
await testFailedCreatedSessionIsRemoved()
|
||||
await testFailedCreatedSessionWithMessagesIsRetained()
|
||||
await testUnsafeCleanupDoesNotMasqueradeAsHome()
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { createServer, type ServerResponse } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||
|
||||
interface CapturedRequest {
|
||||
index: number
|
||||
url: string
|
||||
headers: Record<string, string | string[] | undefined>
|
||||
body: Record<string, unknown>
|
||||
userTexts: string[]
|
||||
latestText: string
|
||||
completed: boolean
|
||||
aborted: boolean
|
||||
responseText?: string
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve(value: T): void
|
||||
}
|
||||
|
||||
class AsyncQueue<T> implements AsyncIterable<T> {
|
||||
private readonly values: T[] = []
|
||||
private readonly waiters: Array<(result: IteratorResult<T>) => void> = []
|
||||
private closed = false
|
||||
|
||||
push(value: T): void {
|
||||
if (this.closed) throw new Error('input queue is closed')
|
||||
const waiter = this.waiters.shift()
|
||||
if (waiter) waiter({ value, done: false })
|
||||
else this.values.push(value)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
for (const waiter of this.waiters.splice(0)) waiter({ value: undefined, done: true })
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<T> {
|
||||
return {
|
||||
next: () => {
|
||||
const value = this.values.shift()
|
||||
if (value !== undefined) return Promise.resolve({ value, done: false })
|
||||
if (this.closed) return Promise.resolve({ value: undefined, done: true })
|
||||
return new Promise<IteratorResult<T>>((resolve) => this.waiters.push(resolve))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function sse(res: ServerResponse, event: string, data: unknown): void {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
|
||||
function messageStart(res: ServerResponse, id: string): void {
|
||||
sse(res, 'message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-append-probe',
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 8, output_tokens: 1 }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function textBlock(res: ServerResponse, text: string): void {
|
||||
sse(res, 'content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'text', text: '' }
|
||||
})
|
||||
sse(res, 'content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text }
|
||||
})
|
||||
sse(res, 'content_block_stop', { type: 'content_block_stop', index: 0 })
|
||||
}
|
||||
|
||||
function messageEnd(res: ServerResponse, stopReason = 'end_turn'): void {
|
||||
sse(res, 'message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
||||
usage: { output_tokens: 4 }
|
||||
})
|
||||
sse(res, 'message_stop', { type: 'message_stop' })
|
||||
res.end()
|
||||
}
|
||||
|
||||
function plainText(res: ServerResponse, id: string, text: string): void {
|
||||
messageStart(res, id)
|
||||
textBlock(res, text)
|
||||
messageEnd(res)
|
||||
}
|
||||
|
||||
function contentTexts(body: Record<string, unknown>): string[] {
|
||||
const messages = Array.isArray(body.messages) ? body.messages : []
|
||||
const texts: string[] = []
|
||||
for (const rawMessage of messages) {
|
||||
if (!rawMessage || typeof rawMessage !== 'object') continue
|
||||
const message = rawMessage as Record<string, unknown>
|
||||
if (message.role !== 'user') continue
|
||||
const content = message.content
|
||||
if (typeof content === 'string') {
|
||||
texts.push(content)
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(content)) continue
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const item = block as Record<string, unknown>
|
||||
if (item.type === 'text' && typeof item.text === 'string') texts.push(item.text)
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
function requestTrace(body: Record<string, unknown>): Array<Record<string, unknown>> {
|
||||
const messages = Array.isArray(body.messages) ? body.messages : []
|
||||
return messages.flatMap((rawMessage) => {
|
||||
if (!rawMessage || typeof rawMessage !== 'object') return []
|
||||
const message = rawMessage as Record<string, unknown>
|
||||
const content = message.content
|
||||
const parts = typeof content === 'string'
|
||||
? [{ type: 'text', text: content }]
|
||||
: Array.isArray(content)
|
||||
? content.flatMap((block) => {
|
||||
if (!block || typeof block !== 'object') return []
|
||||
const item = block as Record<string, unknown>
|
||||
return item.type === 'text' && typeof item.text === 'string'
|
||||
? [{ type: 'text', text: item.text }]
|
||||
: [{ type: String(item.type) }]
|
||||
})
|
||||
: []
|
||||
const text = parts.flatMap((part) => 'text' in part ? [part.text] : []).join('')
|
||||
if (!text.includes('PROBE_') && !text.includes('answer-')) return []
|
||||
return [{ role: message.role, content: parts }]
|
||||
})
|
||||
}
|
||||
|
||||
function compactFrame(message: SDKMessage): Record<string, unknown> {
|
||||
const value = message as unknown as Record<string, unknown>
|
||||
const inner = value.message && typeof value.message === 'object'
|
||||
? value.message as Record<string, unknown>
|
||||
: undefined
|
||||
return {
|
||||
type: value.type,
|
||||
subtype: value.subtype,
|
||||
uuid: value.uuid,
|
||||
prompt_id: value.prompt_id,
|
||||
user_message_uuid: value.user_message_uuid,
|
||||
user_message_uuids: value.user_message_uuids,
|
||||
result_index: value.result_index,
|
||||
queued_turn_count: value.queued_turn_count,
|
||||
is_error: value.is_error,
|
||||
result: value.result,
|
||||
parent_tool_use_id: value.parent_tool_use_id,
|
||||
message_id: inner?.id,
|
||||
message_role: inner?.role,
|
||||
message_stop_reason: inner?.stop_reason,
|
||||
content_types: Array.isArray(inner?.content)
|
||||
? inner.content.map((block) => block && typeof block === 'object'
|
||||
? (block as Record<string, unknown>).type
|
||||
: typeof block)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function inputMessage(sessionId: string, uuid: string, text: string): SDKUserMessage {
|
||||
return {
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text }] },
|
||||
parent_tool_use_id: null,
|
||||
uuid: uuid as SDKUserMessage['uuid'],
|
||||
session_id: sessionId
|
||||
}
|
||||
}
|
||||
|
||||
function executablePath(): string {
|
||||
const executable = process.platform === 'win32' ? 'claude.exe' : 'claude'
|
||||
const packageName = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`
|
||||
const candidates = [
|
||||
join(process.cwd(), 'node_modules', ...packageName.split('/'), executable),
|
||||
join(process.cwd(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'vendor', executable)
|
||||
]
|
||||
const found = candidates.find((candidate) => existsSync(candidate))
|
||||
if (!found) throw new Error(`Claude executable not found: ${candidates.join(', ')}`)
|
||||
return found
|
||||
}
|
||||
|
||||
function isolatedEnv(tempRoot: string, baseUrl: string): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
HOME: join(tempRoot, 'home'),
|
||||
USERPROFILE: join(tempRoot, 'home'),
|
||||
CLAUDE_CONFIG_DIR: join(tempRoot, 'claude-config'),
|
||||
CLAUDE_SECURESTORAGE_CONFIG_DIR: join(tempRoot, 'claude-config'),
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_API_KEY: 'ycode-loopback-probe-key',
|
||||
ANTHROPIC_MODEL: 'claude-append-probe',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-append-probe',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-append-probe',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-append-probe',
|
||||
ANTHROPIC_SMALL_FAST_MODEL: 'claude-append-probe',
|
||||
CLAUDE_CODE_MAX_CONTEXT_TOKENS: '200000',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
DISABLE_TELEMETRY: '1'
|
||||
}
|
||||
for (const name of [
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
'CLAUDE_CODE_USE_ANTHROPIC_AWS',
|
||||
'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
|
||||
'CLAUDE_CODE_USE_MANTLE',
|
||||
'CLAUDE_CODE_USE_GATEWAY'
|
||||
]) delete env[name]
|
||||
return env
|
||||
}
|
||||
|
||||
async function waitFor<T>(description: string, predicate: () => T | undefined | false, timeoutMs = 20_000): Promise<T> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const value = predicate()
|
||||
if (value) return value
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`timed out waiting for ${description}`)
|
||||
}
|
||||
|
||||
const sdkPackage = JSON.parse(await readFile(
|
||||
join(process.cwd(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'package.json'),
|
||||
'utf8'
|
||||
)) as { version?: string; claudeCodeVersion?: string }
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-claude-append-probe-'))
|
||||
const workspace = join(tempRoot, 'workspace')
|
||||
const outputPath = join(process.cwd(), '.tmp', 'claude-append-protocol-probe.json')
|
||||
await mkdir(workspace, { recursive: true })
|
||||
await mkdir(join(tempRoot, 'home'), { recursive: true })
|
||||
await mkdir(join(tempRoot, 'claude-config'), { recursive: true })
|
||||
await mkdir(join(process.cwd(), '.tmp'), { recursive: true })
|
||||
await writeFile(join(workspace, 'README.md'), 'append protocol probe\n', 'utf8')
|
||||
|
||||
const requests: CapturedRequest[] = []
|
||||
const runtimeMessages: SDKMessage[] = []
|
||||
const stderr: string[] = []
|
||||
const firstResponseGate = deferred<void>()
|
||||
const processAbort = new AbortController()
|
||||
const server = createServer((req, res) => {
|
||||
let raw = ''
|
||||
req.on('data', (chunk) => { raw += chunk })
|
||||
req.on('end', async () => {
|
||||
let body: Record<string, unknown> = {}
|
||||
try { body = JSON.parse(raw || '{}') as Record<string, unknown> } catch { body = { raw } }
|
||||
if (!req.url?.includes('/messages')) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end('{}')
|
||||
return
|
||||
}
|
||||
|
||||
const userTexts = contentTexts(body)
|
||||
const captured: CapturedRequest = {
|
||||
index: requests.length + 1,
|
||||
url: req.url ?? '',
|
||||
headers: req.headers,
|
||||
body,
|
||||
userTexts,
|
||||
latestText: userTexts.at(-1) ?? '',
|
||||
completed: false,
|
||||
aborted: false
|
||||
}
|
||||
requests.push(captured)
|
||||
res.on('close', () => {
|
||||
if (!captured.completed) captured.aborted = true
|
||||
})
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
const responseText = `answer-${captured.index}:${captured.userTexts.join('|')}`
|
||||
captured.responseText = responseText
|
||||
|
||||
if (captured.index === 1) {
|
||||
await firstResponseGate.promise
|
||||
}
|
||||
if (!res.destroyed) {
|
||||
captured.completed = true
|
||||
plainText(res, `msg_append_${captured.index}`, responseText)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('loopback model did not bind')
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`
|
||||
|
||||
const sessionId = randomUUID()
|
||||
const uuids = {
|
||||
initial: randomUUID(),
|
||||
appendA: randomUUID(),
|
||||
appendB: randomUUID()
|
||||
}
|
||||
const input = new AsyncQueue<SDKUserMessage>()
|
||||
let instance: ReturnType<typeof query> | null = null
|
||||
let pumpError: unknown
|
||||
let pump = Promise.resolve()
|
||||
let appendsQueuedBeforeFirstResponse = false
|
||||
|
||||
try {
|
||||
instance = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
cwd: workspace,
|
||||
model: 'claude-append-probe',
|
||||
pathToClaudeCodeExecutable: executablePath(),
|
||||
env: isolatedEnv(tempRoot, baseUrl),
|
||||
includePartialMessages: true,
|
||||
abortController: processAbort,
|
||||
permissionMode: 'default',
|
||||
settingSources: [],
|
||||
strictMcpConfig: true,
|
||||
tools: [],
|
||||
allowedTools: [],
|
||||
plugins: [],
|
||||
agents: {},
|
||||
skills: [],
|
||||
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
||||
sessionId,
|
||||
maxTurns: 4,
|
||||
stderr: (data) => {
|
||||
stderr.push(data)
|
||||
if (process.env.DEBUG_CLAUDE_APPEND_PROBE === '1') process.stderr.write(`[claude-probe] ${data}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const currentInstance = instance
|
||||
pump = (async () => {
|
||||
try {
|
||||
for await (const message of currentInstance) runtimeMessages.push(message)
|
||||
} catch (error) {
|
||||
pumpError = error
|
||||
}
|
||||
})()
|
||||
|
||||
input.push(inputMessage(sessionId, uuids.initial, 'PROBE_INITIAL'))
|
||||
await waitFor('first model request', () => requests[0])
|
||||
// Hold request 1 so both appends are submitted while the turn is active.
|
||||
|
||||
input.push(inputMessage(sessionId, uuids.appendA, 'PROBE_APPEND_A'))
|
||||
input.push(inputMessage(sessionId, uuids.appendB, 'PROBE_APPEND_B'))
|
||||
appendsQueuedBeforeFirstResponse = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
firstResponseGate.resolve()
|
||||
|
||||
await waitFor('all queued turns to settle', () => {
|
||||
const results = runtimeMessages.filter((message) => message.type === 'result')
|
||||
const last = results.at(-1) as unknown as Record<string, unknown> | undefined
|
||||
return results.length >= 2 && last?.queued_turn_count === 0
|
||||
}, 30_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
const results = runtimeMessages
|
||||
.filter((message) => message.type === 'result')
|
||||
.map((message) => message as unknown as Record<string, unknown>)
|
||||
const assistants = runtimeMessages
|
||||
.filter((message) => message.type === 'assistant')
|
||||
.map((message) => message as unknown as Record<string, unknown>)
|
||||
const userFrames = runtimeMessages
|
||||
.filter((message) => message.type === 'user')
|
||||
.map((message) => message as unknown as Record<string, unknown>)
|
||||
|
||||
const firstResult = results[0]
|
||||
const finalResult = results.at(-1)
|
||||
const firstResultUuids = Array.isArray(firstResult?.user_message_uuids)
|
||||
? firstResult.user_message_uuids as string[]
|
||||
: []
|
||||
const finalResultUuids = Array.isArray(finalResult?.user_message_uuids)
|
||||
? finalResult.user_message_uuids as string[]
|
||||
: []
|
||||
|
||||
assert.equal(requests[0]?.aborted, false, 'the first model request must complete without cancellation')
|
||||
assert.ok(requests.length >= 2, 'queued appends must produce another model request')
|
||||
assert.ok(firstResult, 'the initial turn must emit a result')
|
||||
assert.ok(finalResult, 'the queued append turn must emit a result')
|
||||
assert.ok(firstResultUuids.includes(uuids.initial), 'the first result must be attributed to the initial UUID')
|
||||
assert.ok(!firstResultUuids.includes(uuids.appendA), 'the first result must not claim append A')
|
||||
assert.ok(!firstResultUuids.includes(uuids.appendB), 'the first result must not claim append B')
|
||||
assert.ok(
|
||||
finalResultUuids.includes(uuids.appendA) && finalResultUuids.includes(uuids.appendB),
|
||||
'a later result must cover both queued appends'
|
||||
)
|
||||
assert.ok(results.length >= 2, 'one result must not stand for every queued send')
|
||||
assert.equal(userFrames.length, 0, 'text sends must not be replayed as SDK user frames')
|
||||
assert.equal(assistants.length, 2, 'each logical turn must emit one complete assistant frame')
|
||||
const assistantUuidSets = assistants.map((assistant) => Array.isArray(assistant.user_message_uuids)
|
||||
? assistant.user_message_uuids as string[]
|
||||
: [])
|
||||
assert.ok(!assistantUuidSets[0]?.includes(uuids.appendA), 'the first assistant must not claim append A')
|
||||
assert.ok(
|
||||
assistantUuidSets[1]?.includes(uuids.appendA) && assistantUuidSets[1]?.includes(uuids.appendB),
|
||||
'the second assistant must cover both coalesced appends'
|
||||
)
|
||||
for (const [index, frame] of [...assistants, ...results].entries()) {
|
||||
const frameUuids = Array.isArray(frame.user_message_uuids) ? frame.user_message_uuids as string[] : []
|
||||
assert.equal(frameUuids.at(-1), frame.user_message_uuid, `frame ${index} must use the last member as user_message_uuid`)
|
||||
}
|
||||
assert.equal(pumpError, undefined, 'the SDK iterator must not fail during the probe')
|
||||
|
||||
const report = {
|
||||
result: 'PASS',
|
||||
sdkVersion: sdkPackage.version,
|
||||
claudeCodeVersion: sdkPackage.claudeCodeVersion,
|
||||
loopback: baseUrl,
|
||||
activeAppend: {
|
||||
appendsQueuedBeforeFirstResponse,
|
||||
interruptCalled: false,
|
||||
inputClosedBeforeResults: false
|
||||
},
|
||||
uuids,
|
||||
requests: requests.map((request) => ({
|
||||
index: request.index,
|
||||
url: request.url,
|
||||
completed: request.completed,
|
||||
aborted: request.aborted,
|
||||
latestText: request.latestText,
|
||||
userTexts: request.userTexts
|
||||
.filter((text) => text.includes('PROBE_') || text.includes('answer-'))
|
||||
.slice(-8),
|
||||
messages: requestTrace(request.body)
|
||||
})),
|
||||
results: results.map((result) => ({
|
||||
subtype: result.subtype,
|
||||
is_error: result.is_error,
|
||||
result_index: result.result_index,
|
||||
queued_turn_count: result.queued_turn_count,
|
||||
user_message_uuid: result.user_message_uuid,
|
||||
user_message_uuids: result.user_message_uuids,
|
||||
prompt_id: result.prompt_id,
|
||||
result: result.result
|
||||
})),
|
||||
assistantKeys: assistants.map((assistant) => Object.keys(assistant).sort()),
|
||||
resultKeys: results.map((result) => Object.keys(result).sort()),
|
||||
assistants: assistants.map((assistant) => ({
|
||||
uuid: assistant.uuid,
|
||||
user_message_uuid: assistant.user_message_uuid,
|
||||
user_message_uuids: assistant.user_message_uuids,
|
||||
prompt_id: assistant.prompt_id,
|
||||
parent_tool_use_id: assistant.parent_tool_use_id,
|
||||
message_id: (assistant.message as Record<string, unknown> | undefined)?.id
|
||||
})),
|
||||
userFrameCount: userFrames.length,
|
||||
userFrames: userFrames.map((frame) => ({
|
||||
uuid: frame.uuid,
|
||||
user_message_uuid: frame.user_message_uuid,
|
||||
user_message_uuids: frame.user_message_uuids,
|
||||
prompt_id: frame.prompt_id,
|
||||
message: frame.message
|
||||
})),
|
||||
promptIdObserved: runtimeMessages.some((message) => {
|
||||
const value = message as unknown as Record<string, unknown>
|
||||
return typeof value.prompt_id === 'string'
|
||||
}),
|
||||
assistantPromptIdObserved: assistants.some((assistant) => typeof assistant.prompt_id === 'string'),
|
||||
frames: runtimeMessages.map(compactFrame),
|
||||
stderr: stderr.slice(-20)
|
||||
}
|
||||
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
||||
console.log(JSON.stringify({
|
||||
result: report.result,
|
||||
outputPath,
|
||||
uuids,
|
||||
activeAppend: report.activeAppend,
|
||||
requests: report.requests,
|
||||
results: report.results,
|
||||
assistants: report.assistants,
|
||||
userFrameCount: report.userFrameCount,
|
||||
userFrames: report.userFrames,
|
||||
assistantKeys: report.assistantKeys,
|
||||
resultKeys: report.resultKeys,
|
||||
promptIdObserved: report.promptIdObserved,
|
||||
assistantPromptIdObserved: report.assistantPromptIdObserved
|
||||
}, null, 2))
|
||||
} catch (error) {
|
||||
const diagnostic = {
|
||||
result: 'FAIL',
|
||||
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
|
||||
pumpError: pumpError instanceof Error ? `${pumpError.name}: ${pumpError.message}` : pumpError,
|
||||
uuids,
|
||||
requests: requests.map((request) => ({
|
||||
index: request.index,
|
||||
completed: request.completed,
|
||||
aborted: request.aborted,
|
||||
userTexts: request.userTexts
|
||||
})),
|
||||
frames: runtimeMessages.map(compactFrame),
|
||||
stderr: stderr.slice(-40)
|
||||
}
|
||||
await writeFile(outputPath, `${JSON.stringify(diagnostic, null, 2)}\n`, 'utf8')
|
||||
console.error(JSON.stringify(diagnostic, null, 2))
|
||||
throw error
|
||||
} finally {
|
||||
input.close()
|
||||
processAbort.abort()
|
||||
instance?.close()
|
||||
await Promise.race([
|
||||
pump,
|
||||
new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
])
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
await rm(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }).catch(() => undefined)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const testRoot = await fs.mkdtemp(path.resolve('.tmp/claude-capabilities-'))
|
||||
const userData = path.join(testRoot, 'data')
|
||||
const workspace = path.join(testRoot, 'workspace')
|
||||
await Promise.all([userData, workspace].map(folder => fs.mkdir(folder, { recursive: true })))
|
||||
const checks: string[] = []
|
||||
const check = (name: string, actual: unknown, expected: unknown): void => {
|
||||
assert.deepEqual(actual, expected, name)
|
||||
checks.push(name)
|
||||
}
|
||||
|
||||
function document(name: string, extra = ''): string {
|
||||
return `---\nname: ${name}\ndescription: Controlled ${name}\n${extra}---\nInstructions for ${name}. Read references/guide.txt when needed.\n`
|
||||
}
|
||||
|
||||
async function skill(scope: '.agents/skills' | '.claude/skills', folder: string, name: string, extra = ''): Promise<string> {
|
||||
const root = path.join(workspace, scope, folder)
|
||||
await fs.mkdir(path.join(root, 'references'), { recursive: true })
|
||||
await fs.writeFile(path.join(root, 'SKILL.md'), document(name, extra))
|
||||
await fs.writeFile(path.join(root, 'references', 'guide.txt'), `RESOURCE_${name}`)
|
||||
return root
|
||||
}
|
||||
|
||||
await skill('.agents/skills', 'automatic', 'automatic')
|
||||
await skill('.agents/skills', 'manual', 'manual', 'disable-model-invocation: true\n')
|
||||
await skill('.agents/skills', 'selected-only', 'selected-only', 'user-invocable: false\n')
|
||||
await skill('.agents/skills', 'disabled', 'disabled')
|
||||
await skill('.claude/skills', 'legacy', 'legacy')
|
||||
|
||||
const electronStub = {
|
||||
name: 'isolated-electron-path',
|
||||
setup(context: import('esbuild').PluginBuild) {
|
||||
context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'capability-test' }))
|
||||
context.onLoad({ filter: /.*/, namespace: 'capability-test' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return ${JSON.stringify(userData)}}};export const safeStorage={isEncryptionAvailable:()=>false,encryptString:()=>{throw Error('unused')},decryptString:()=>{throw Error('unused')}};`
|
||||
}))
|
||||
}
|
||||
}
|
||||
async function compile(entry: string, file: string): Promise<string> {
|
||||
const outfile = path.join(testRoot, file)
|
||||
await build({ entryPoints: [path.resolve(entry)], outfile, bundle: true, platform: 'node', format: 'esm',
|
||||
packages: 'external', logLevel: 'warning', plugins: [electronStub] })
|
||||
return outfile
|
||||
}
|
||||
const adapterPath = await compile('src/main/capabilities/claude-capabilities.ts', 'adapter.mjs')
|
||||
const skillsPath = await compile('src/main/capabilities/skills.ts', 'skills.mjs')
|
||||
const mcpPath = await compile('src/main/capabilities/mcp.ts', 'mcp.mjs')
|
||||
const adapter = await import(pathToFileURL(adapterPath).href)
|
||||
const skills = await import(pathToFileURL(skillsPath).href)
|
||||
const listed = await skills.listSkills(workspace)
|
||||
const byName = (name: string) => listed.find((item: { name: string }) => item.name === name)
|
||||
const automatic = byName('automatic')
|
||||
const manual = byName('manual')
|
||||
const selectedOnly = byName('selected-only')
|
||||
const disabled = byName('disabled')
|
||||
assert.ok(automatic && manual && selectedOnly && disabled)
|
||||
check('only .agents/skills is discovered', listed.some((item: { name: string }) => item.name === 'legacy'), false)
|
||||
await skills.setSkillEnabled(disabled.id, false, workspace)
|
||||
|
||||
let executeSignal: AbortSignal | undefined
|
||||
let discoverOptions: { serverId?: string; query?: string } | undefined
|
||||
const mcp = {
|
||||
async discover(options?: { serverId?: string; query?: string }) {
|
||||
discoverOptions = options
|
||||
return [
|
||||
{ id: 'ready', name: 'Ready server', status: 'connected', available: true,
|
||||
tools: [
|
||||
{ name: 'echo', description: 'Echo text', inputSchema: { type: 'object', properties: { text: { type: 'string' } } } },
|
||||
{ name: 'search-only', description: 'Find needles', inputSchema: { type: 'object', properties: {} } }
|
||||
] },
|
||||
{ id: 'untrusted', name: 'Unapproved server', status: 'disconnected', available: false,
|
||||
reason: '请先批准当前 MCP 配置', tools: [] }
|
||||
]
|
||||
},
|
||||
async execute(serverId: string, toolName: string, args: Record<string, unknown>, signal: AbortSignal) {
|
||||
executeSignal = signal
|
||||
if (serverId === 'untrusted') return { ok: false, content: '请先批准当前 MCP 配置' }
|
||||
if (serverId === 'disabled') return { ok: false, content: 'MCP 服务已禁用' }
|
||||
if (toolName === 'wait') return new Promise<never>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(Object.assign(new Error('MCP 调用已取消'), { name: 'AbortError' })), { once: true })
|
||||
})
|
||||
return { ok: true, content: `${serverId}:${String(args.text ?? '')}` }
|
||||
}
|
||||
}
|
||||
|
||||
const runtime = adapter.createClaudeCapabilityRuntime({ cwd: workspace, explicitSkillIds: [manual.id], selectedMcpServerIds: ['ready'] }, { mcp })
|
||||
const rootController = new AbortController()
|
||||
const rootBoundRuntime = adapter.createClaudeCapabilityRuntime({ cwd: workspace, requestSignal: () => rootController.signal }, { mcp })
|
||||
const rootBoundCall = rootBoundRuntime.callMcp('ready', 'wait', {}, new AbortController().signal)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
rootController.abort()
|
||||
await assert.rejects(rootBoundCall, /取消/)
|
||||
check('root cancellation reaches MCP independently of SDK callback signal', executeSignal?.aborted, true)
|
||||
const catalog = await runtime.list()
|
||||
check('automatic skill appears without selection', catalog.skills.some((item: { id: string; selected: boolean }) => item.id === automatic.id && !item.selected), true)
|
||||
check('explicit manual-only skill appears as selected', catalog.skills.some((item: { id: string; selected: boolean }) => item.id === manual.id && item.selected), true)
|
||||
check('user-invocable=false remains model-discoverable but unselected',
|
||||
catalog.skills.some((item: { id: string; selected: boolean }) => item.id === selectedOnly.id && !item.selected), true)
|
||||
check('disabled skill is omitted', catalog.skills.some((item: { id: string }) => item.id === disabled.id), false)
|
||||
check('MCP selection is message-scoped metadata', [catalog.mcp.find((item: { id: string }) => item.id === 'ready').selected,
|
||||
catalog.mcp.find((item: { id: string }) => item.id === 'untrusted').selected], [true, false])
|
||||
check('approval failure is reported truthfully', catalog.mcp.find((item: { id: string }) => item.id === 'untrusted').reason, '请先批准当前 MCP 配置')
|
||||
check('default discovery omits tool schemas',
|
||||
'inputSchema' in catalog.mcp.find((item: { id: string }) => item.id === 'ready').tools[0], false)
|
||||
check('default discovery sends summary-only options', discoverOptions, {})
|
||||
const toolSearch = await runtime.list('Echo')
|
||||
check('tool-only search keeps its parent server', toolSearch.mcp.map((item: { id: string }) => item.id), ['ready'])
|
||||
check('tool search returns only the matched tool with schema',
|
||||
toolSearch.mcp[0].tools, [{ name: 'echo', description: 'Echo text',
|
||||
inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }])
|
||||
check('tool search forwards query to MCP discovery', discoverOptions, { query: 'Echo' })
|
||||
const drilled = await runtime.list(undefined, 'ready')
|
||||
check('server drill-down returns all tool schemas',
|
||||
drilled.mcp[0].tools.every((item: { inputSchema?: unknown }) => item.inputSchema !== undefined), true)
|
||||
check('server drill-down forwards server identity', discoverOptions, { serverId: 'ready' })
|
||||
check('automatic skill body loads on demand', (await runtime.loadSkill(automatic.id)).includes('Instructions for automatic.'), true)
|
||||
check('explicit selected skill body loads', (await runtime.explicitSkillContext()).includes('Instructions for manual.'), true)
|
||||
check('relative skill resource resolves from package root',
|
||||
await runtime.skillResource(automatic.id, 'references/guide.txt'), 'RESOURCE_automatic')
|
||||
await assert.rejects(runtime.skillResource(automatic.id, '../outside.txt'), /路径/)
|
||||
checks.push('resource traversal is rejected')
|
||||
check('automatic-only skill remains available to the model',
|
||||
(await runtime.loadSkill(selectedOnly.id)).includes('Instructions for selected-only.'), true)
|
||||
check('automatic-only resource remains model-readable',
|
||||
await runtime.skillResource(selectedOnly.id, 'references/guide.txt'), 'RESOURCE_selected-only')
|
||||
const automaticRuntime = adapter.createClaudeCapabilityRuntime({ cwd: workspace }, { mcp })
|
||||
await assert.rejects(automaticRuntime.loadSkill(manual.id), /自动调用/)
|
||||
checks.push('manual-only skill remains unavailable without an explicit reference')
|
||||
await assert.rejects(automaticRuntime.loadSkill(disabled.id), /禁用/)
|
||||
checks.push('disabled skill body cannot load')
|
||||
|
||||
const explicitOnly = adapter.createClaudeCapabilityRuntime({ cwd: workspace, explicitSkillIds: [selectedOnly.id] }, { mcp })
|
||||
await assert.rejects(explicitOnly.loadSkill(selectedOnly.id), /手动调用/)
|
||||
checks.push('explicit selection cannot bypass user-invocable=false')
|
||||
runtime.setRequestSelection({ explicitSkillIds: [], selectedMcpServerIds: [] })
|
||||
const nextCatalog = await runtime.list()
|
||||
check('next request clears prior explicit skill and MCP selection',
|
||||
[nextCatalog.skills.some((item: { id: string }) => item.id === manual.id),
|
||||
nextCatalog.mcp.some((item: { id: string; selected: boolean }) => item.id === 'ready' && item.selected)], [false, false])
|
||||
await assert.rejects(runtime.loadSkill(manual.id), /自动调用/)
|
||||
checks.push('explicit skill permission does not leak into the next request')
|
||||
|
||||
check('real MCP call is passed through', await runtime.callMcp('ready', 'echo', { text: 'actual' }, new AbortController().signal),
|
||||
{ ok: true, content: 'ready:actual' })
|
||||
check('unapproved MCP call fails instead of pretending success',
|
||||
await runtime.callMcp('untrusted', 'echo', {}, new AbortController().signal),
|
||||
{ ok: false, content: '请先批准当前 MCP 配置' })
|
||||
check('disabled MCP call fails',
|
||||
await runtime.callMcp('disabled', 'echo', {}, new AbortController().signal),
|
||||
{ ok: false, content: 'MCP 服务已禁用' })
|
||||
const controller = new AbortController()
|
||||
const pending = runtime.callMcp('ready', 'wait', {}, controller.signal)
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
controller.abort()
|
||||
await assert.rejects(pending, (error: unknown) => (error as Error).name === 'AbortError')
|
||||
check('cancellation signal reaches MCP execution', executeSignal?.aborted, true)
|
||||
await skills.setSkillEnabled(disabled.id, false, workspace)
|
||||
await assert.rejects(runtime.loadSkill(disabled.id), /禁用/)
|
||||
checks.push('global disable takes effect on the next bridge call')
|
||||
await assert.rejects(runtime.skillResource(disabled.id, 'references/guide.txt'), /禁用/)
|
||||
checks.push('disabled skill resource cannot load')
|
||||
|
||||
const realMcp = await import(pathToFileURL(mcpPath).href)
|
||||
const mcpEvents = path.join(testRoot, 'mcp-events.jsonl')
|
||||
try {
|
||||
let fixture = await realMcp.saveMcpServer({
|
||||
name: 'Schema fixture',
|
||||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
args: [path.resolve('scripts/test-mcp-server.mjs'), 'stdio', mcpEvents],
|
||||
enabled: true
|
||||
})
|
||||
await realMcp.trustMcpServer(fixture.id, fixture.configHash)
|
||||
fixture = await realMcp.connectMcpServer(fixture.id)
|
||||
const summary = await realMcp.discoverMcpCapabilities()
|
||||
check('real MCP default discovery omits schemas',
|
||||
summary.find((item: { id: string }) => item.id === fixture.id).tools.every((item: object) => !('inputSchema' in item)), true)
|
||||
const drilled = await realMcp.discoverMcpCapabilities({ serverId: fixture.id })
|
||||
const echo = drilled[0].tools.find((item: { name: string }) => item.name === 'echo')
|
||||
check('real MCP server drill-down includes schemas',
|
||||
echo.inputSchema, { type: 'object', properties: { text: { type: 'string' } } })
|
||||
const searched = await realMcp.discoverMcpCapabilities({ query: 'echo' })
|
||||
check('real MCP tool search includes schema',
|
||||
searched.find((item: { id: string }) => item.id === fixture.id).tools,
|
||||
[{ name: 'echo', description: 'Controlled stdio echo',
|
||||
inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }])
|
||||
} finally {
|
||||
await realMcp.disposeMcpServers()
|
||||
}
|
||||
assert.ok(runtime.server, 'SDK MCP server should be constructed')
|
||||
checks.push('Claude SDK capability server constructed')
|
||||
await fs.writeFile(path.join(testRoot, 'report.json'), JSON.stringify({ result: 'PASS', checks }, null, 2))
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory: testRoot }))
|
||||
@@ -121,6 +121,26 @@ const server = createServer((req, res) => {
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
const id = `msg_kernel_${requests.length}`
|
||||
if (latestContent.includes('APPEND_ACTIVE_SMOKE')) {
|
||||
setTimeout(() => { if (!res.destroyed) plainText(res, id, 'Initial append turn.') }, 400)
|
||||
return
|
||||
}
|
||||
if (userText.includes('APPEND_GUIDE_A') || userText.includes('APPEND_GUIDE_B')) {
|
||||
plainText(res, id, 'Appended answer.')
|
||||
return
|
||||
}
|
||||
if (latestContent.includes('HOLD_SMOKE')) {
|
||||
setTimeout(() => { if (!res.destroyed) plainText(res, id, 'Held response.') }, 500)
|
||||
return
|
||||
}
|
||||
if (latestContent.includes('AFTER_CANCEL_SMOKE')) {
|
||||
plainText(res, id, 'After cancellation.')
|
||||
return
|
||||
}
|
||||
if (userText.includes('AFTER_COMPACT_SMOKE')) {
|
||||
plainText(res, id, 'After compaction.')
|
||||
return
|
||||
}
|
||||
if (userText.includes('Write the title in the predominant language')) {
|
||||
plainText(res, id, 'Plan Smoke')
|
||||
return
|
||||
@@ -300,11 +320,71 @@ try {
|
||||
assert.ok(resumedRequest)
|
||||
assert.ok(resumedRequest.userText.includes('ASK_SMOKE'))
|
||||
assert.ok(resumedRequest.userText.includes('PLAN_SMOKE'))
|
||||
const beforeSummary = requests.length
|
||||
const summary = await resumed.getContextSummary()
|
||||
assert.ok(summary && summary.categories.length > 0, 'native summary must return actual structured categories')
|
||||
assert.equal(requests.length, beforeSummary, 'summary must not call the model or per-category count API')
|
||||
|
||||
const controller = new AbortController()
|
||||
const held = resumed.runTurn(request('HOLD_SMOKE', randomUUID(), controller))
|
||||
const rejection = assert.rejects(held, /取消/)
|
||||
for (let index = 0; index < 500 && !requests.some(item => item.userText.includes('HOLD_SMOKE')); index++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
assert.ok(requests.some(item => item.userText.includes('HOLD_SMOKE')))
|
||||
controller.abort()
|
||||
await rejection
|
||||
const afterCancel = await resumed.runTurn(request('AFTER_CANCEL_SMOKE', randomUUID(), new AbortController()))
|
||||
assert.equal(afterCancel.content, 'After cancellation.', 'cancelled producer must not settle the next run')
|
||||
|
||||
const groups: string[][] = []
|
||||
const segmentResults: string[] = []
|
||||
const appendController = new AbortController()
|
||||
const appendRun = resumed.runTurn({ ...request('APPEND_ACTIVE_SMOKE', randomUUID(), appendController),
|
||||
onSegmentComplete: async result => { segmentResults.push(result.content) },
|
||||
onSegmentStart: async inputs => { groups.push(inputs.map(input => input.userMessageId)) }
|
||||
})
|
||||
void appendRun.catch(() => {})
|
||||
for (let index = 0; index < 500 && !requests.some(item => JSON.stringify(item.body.messages ?? []).includes('APPEND_ACTIVE_SMOKE')); index++) await new Promise(resolve => setTimeout(resolve, 10))
|
||||
assert.ok(requests.some(item => JSON.stringify(item.body.messages ?? []).includes('APPEND_ACTIVE_SMOKE')))
|
||||
const guideA = randomUUID(), guideB = randomUUID()
|
||||
let acknowledged = false
|
||||
const appended = Promise.all([
|
||||
resumed.appendInput({ userMessageId: guideA, prompt: 'APPEND_GUIDE_A' }),
|
||||
resumed.appendInput({ userMessageId: guideB, prompt: 'APPEND_GUIDE_B' })
|
||||
]).then(() => { acknowledged = true })
|
||||
assert.equal(acknowledged, false, 'pushing into the input stream is not delivery acknowledgement')
|
||||
const appendResult = await appendRun
|
||||
await appended
|
||||
assert.equal(appendResult.content, 'Appended answer.')
|
||||
assert.deepEqual(segmentResults, ['Initial append turn.'], 'first result must not settle appended inputs')
|
||||
assert.deepEqual(groups.flat(), [guideA, guideB], 'native merged UUIDs must be associated exactly once')
|
||||
assert.equal(appendController.signal.aborted, false, 'guidance must not cancel the active run')
|
||||
await assert.rejects(resumed.appendInput({ userMessageId: randomUUID(), prompt: 'idle' }), error => (error as { submitted?: boolean }).submitted === false)
|
||||
|
||||
if (process.env.CLAUDE_PROTOCOL_PROBE === '1') {
|
||||
const observed: Array<Record<string, unknown>> = []
|
||||
const target = resumed as unknown as { handleMessage(message: unknown): Promise<void> }
|
||||
const original = target.handleMessage.bind(target)
|
||||
target.handleMessage = message => { observed.push(message as Record<string, unknown>); return original(message) }
|
||||
const compact = await resumed.runTurn({ ...request('/compact', randomUUID(), new AbortController()), command: 'compact' })
|
||||
console.log(JSON.stringify({ probe: 'compact', status: compact.status,
|
||||
frames: observed.map(frame => ({ type: frame.type, subtype: frame.subtype, promptId: frame.prompt_id, ids: frame.user_message_uuids })),
|
||||
boundary: observed.some(frame => frame.subtype === 'compact_boundary') }))
|
||||
assert.ok(observed.some(frame => frame.subtype === 'compact_boundary'), 'native compaction must emit its boundary')
|
||||
assert.equal(compact.content, '上下文压缩完成')
|
||||
const postCompact = await resumed.runTurn(request('AFTER_COMPACT_SMOKE', randomUUID(), new AbortController()))
|
||||
if (postCompact.content !== 'After compaction.') console.error(JSON.stringify({ postCompact, requests: requests.slice(-3).map(item => ({ url: item.url, userText: item.userText.slice(-1200) })), frames: observed.map(frame => ({ type: frame.type, subtype: frame.subtype, ids: frame.user_message_uuids })) }))
|
||||
assert.equal(postCompact.content, 'After compaction.')
|
||||
const postRequest = requests.findLast(item => item.userText.includes('AFTER_COMPACT_SMOKE'))
|
||||
assert.ok(postRequest)
|
||||
assert.equal(postRequest.userText.includes('ASK_SMOKE'), false, 'native compaction must replace old full inputs on the next upstream request')
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
result: 'PASS',
|
||||
requests: requests.length,
|
||||
events: events.length,
|
||||
turns: 3,
|
||||
turns: process.env.CLAUDE_PROTOCOL_PROBE === '1' ? 9 : 7,
|
||||
basePath: requests.find((item) => item.url.includes('/v1/messages'))?.url
|
||||
}))
|
||||
} finally {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { buildComposerSendPayload, MAX_ATTACHMENT_BYTES, oversizedAttachmentName
|
||||
import { MessageActionToolbar } from '../src/renderer/src/workbuddy/message-actions/MessageActionToolbar'
|
||||
import { UserMessageToolbar } from '../src/renderer/src/workbuddy/message-actions/UserMessageToolbar'
|
||||
import { UserMessageEditor } from '../src/renderer/src/workbuddy/conversation/UserMessageEditor'
|
||||
import { messageCapabilityRefs } from '../src/renderer/src/workbuddy/conversation/message-capabilities'
|
||||
import { draftFingerprint, draftIsEmpty, draftPlainText, mergeFailedDraft } from '../src/renderer/src/workbuddy/composer/draft-fingerprint'
|
||||
import { buildCopyMessagePayload, buildUserMessageClipboard, getSortedRequestMessages, projectSafeMessage, safeStringify, USER_CONTENT_BLOCKS_MIME } from '../src/renderer/src/workbuddy/message-actions/project-safe-message'
|
||||
import type { ChatMessage } from '../src/shared/types'
|
||||
|
||||
@@ -41,6 +43,61 @@ const legacy: ChatMessage = {
|
||||
createdAt: 1
|
||||
}
|
||||
const userMessage: ChatMessage = { id: 'user-copy', role: 'user', content: 'plain user text', createdAt: 1 }
|
||||
assert.deepEqual(messageCapabilityRefs({ ...userMessage, skills: [{ id: 'skill-a', name: 'Skill A', uri: '/skill-a' }],
|
||||
mcpServerIds: ['mcp-a'], command: 'compact' }), { skillIds: ['skill-a'], mcpServerIds: ['mcp-a'], command: 'compact' },
|
||||
'editing and regenerating must preserve message-scoped capability references')
|
||||
const compactDraft = { text: '', command: 'compact' as const }
|
||||
assert.notEqual(draftFingerprint(compactDraft), draftFingerprint({ text: '/compact', command: 'compact' }),
|
||||
'compact draft identity must use the real empty editor snapshot, not the synthetic wire text')
|
||||
assert.equal(draftIsEmpty({ text: '', userContent: [{ type: 'resource', id: 'C:/work/a.ts' }] }), false,
|
||||
'rich-only drafts are not empty')
|
||||
assert.equal(draftIsEmpty({ text: '', userContent: [{ type: 'text', text: ' ' }] }), true,
|
||||
'empty rich text does not keep a draft alive')
|
||||
assert.equal(draftPlainText({ text: '', userContent: [{ type: 'text', text: 'rich-only text' }] }), 'rich-only text',
|
||||
'rich-only textual content reaches the send payload')
|
||||
const failedDraft = {
|
||||
text: 'old request',
|
||||
attachments: [{ id: 'attachment-a', name: 'a.txt', mimeType: 'text/plain', size: 1 }],
|
||||
resources: ['C:/work/a.ts', 'C:/work/shared.ts'],
|
||||
quotes: [{ id: 'quote-a', kind: 'table' as const, sourceId: 'source-a', markdown: 'a', preview: 'a' }],
|
||||
skills: [{ id: 'skill-a', name: 'Skill A' }],
|
||||
mcpServerIds: ['mcp-a']
|
||||
}
|
||||
const currentDraft = {
|
||||
text: 'new request',
|
||||
attachments: [
|
||||
{ id: 'attachment-b', name: 'b.txt', mimeType: 'text/plain', size: 1 },
|
||||
{ id: 'attachment-a', name: 'duplicate.txt', mimeType: 'text/plain', size: 1 }
|
||||
],
|
||||
resources: ['C:/work/b.ts', 'C:/work/shared.ts'],
|
||||
quotes: [{ id: 'quote-b', kind: 'table' as const, sourceId: 'source-b', markdown: 'b', preview: 'b' }],
|
||||
skills: [{ id: 'skill-b', name: 'Skill B' }, { id: 'skill-a', name: 'Duplicate' }],
|
||||
mcpServerIds: ['mcp-b', 'mcp-a']
|
||||
}
|
||||
const mergedDraft = mergeFailedDraft(currentDraft, failedDraft)
|
||||
assert.equal(mergedDraft.draft.text, 'old request\nnew request', 'failed content must stay before subsequent input')
|
||||
assert.deepEqual(mergedDraft.draft.attachments?.map(item => item.id), ['attachment-a', 'attachment-b'])
|
||||
assert.deepEqual(mergedDraft.draft.resources, ['C:/work/a.ts', 'C:/work/shared.ts', 'C:/work/b.ts'])
|
||||
assert.deepEqual(mergedDraft.draft.quotes?.map(item => item.id), ['quote-a', 'quote-b'])
|
||||
assert.deepEqual(mergedDraft.draft.skills?.map(item => item.id), ['skill-a', 'skill-b'])
|
||||
assert.deepEqual(mergedDraft.draft.mcpServerIds, ['mcp-a', 'mcp-b'])
|
||||
assert.equal(mergedDraft.commandConflict, false)
|
||||
const orderedFailure = mergeFailedDraft({ text: 'later' }, {
|
||||
text: 'beforeafter', resources: ['C:/work/inline.ts'],
|
||||
userContent: [{ type: 'text', text: 'before' }, { type: 'resource', id: 'C:/work/inline.ts' }, { type: 'text', text: 'after' }]
|
||||
})
|
||||
assert.deepEqual(orderedFailure.draft.userContent, [
|
||||
{ type: 'text', text: 'before' }, { type: 'resource', id: 'C:/work/inline.ts' }, { type: 'text', text: 'after\nlater' }
|
||||
], 'failed draft keeps inline reference order before subsequent input')
|
||||
const commandFailure = mergeFailedDraft({ text: 'next ordinary draft' }, { text: '', command: 'compact' })
|
||||
assert.equal(commandFailure.draft.command, 'compact', 'failed command tag remains removable instead of being discarded')
|
||||
assert.equal(commandFailure.commandConflict, true, 'mixed command and ordinary content must be surfaced as a conflict')
|
||||
assert.equal(mergeFailedDraft({ text: 'new' }, { text: '', userContent: [{ type: 'text', text: 'old rich text' }] }).draft.text,
|
||||
'old rich text\nnew', 'failed rich-only text is not lost when subsequent input exists')
|
||||
const failedQueueEdit = mergeFailedDraft({ text: 'typed after submit' }, { text: 'queued edit', queueEditId: 'queue-item-a' })
|
||||
assert.equal(failedQueueEdit.draft.queueEditId, 'queue-item-a', 'failed queue-edit submissions retain the durable handoff identity')
|
||||
assert.equal(draftFingerprint({ text: 'queued edit', queueEditId: 'queue-item-a' }), draftFingerprint({ text: 'queued edit' }),
|
||||
'queue handoff identity never changes the submitted-content fingerprint')
|
||||
const userClipboard = buildUserMessageClipboard({ ...userMessage, content: '<plain>&\u200B' })
|
||||
assert.equal(userClipboard.plainText, '<plain>&', 'user text/plain omits the source zero-width marker')
|
||||
assert.equal(userClipboard.html, '<plain>&\u200B')
|
||||
@@ -175,7 +232,11 @@ assert.equal(composerMarkup.indexOf('Model slot') < composerMarkup.indexOf('语
|
||||
assert.ok(composerMarkup.indexOf('cr-input-toolbar__left') < composerMarkup.indexOf('cr-input-toolbar__right'))
|
||||
assert.ok(composerMarkup.indexOf('cr-input-toolbar__right') < composerMarkup.indexOf('wb-composer__workspace-footer'), 'workspace footer remains outside the input toolbar')
|
||||
const activeRunMarkup = renderToStaticMarkup(<ComposerControls value="next draft" onChange={() => undefined} onSend={() => undefined} onStop={() => undefined} />)
|
||||
assert.match(activeRunMarkup, /aria-label="停止"/)
|
||||
assert.match(activeRunMarkup, /aria-label="停止当前运行"/)
|
||||
assert.match(activeRunMarkup, /aria-label="发送"/)
|
||||
const emptyActiveRunMarkup = renderToStaticMarkup(<ComposerControls value="" onChange={() => undefined} onSend={() => undefined} onStop={() => undefined} />)
|
||||
assert.match(emptyActiveRunMarkup, /aria-label="停止"/)
|
||||
assert.doesNotMatch(emptyActiveRunMarkup, /aria-label="发送"/)
|
||||
assert.doesNotMatch(activeRunMarkup, /<textarea[^>]*disabled/)
|
||||
|
||||
console.log('Composer/message action regression cases passed')
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createContextUsageService, type ContextUsageDependencies } from '../src/main/context-usage-service'
|
||||
import type { ChatMessage, ContextUsageBreakdown } from '../src/shared/types'
|
||||
|
||||
const sample = {
|
||||
usage: { inputTokens: 1200, outputTokens: 100, totalTokens: 1300 },
|
||||
contextWindow: 10_000,
|
||||
source: 'upstream' as const,
|
||||
sampledAt: 100,
|
||||
requestId: 'request-a',
|
||||
modelId: 'model-a'
|
||||
}
|
||||
const assistant = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
|
||||
id: 'assistant-a',
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
modelId: 'model-a',
|
||||
requestId: 'request-a',
|
||||
contextUsage: sample,
|
||||
createdAt: 1,
|
||||
status: 'done',
|
||||
...overrides
|
||||
})
|
||||
const breakdown: ContextUsageBreakdown = {
|
||||
requestId: 'request-a',
|
||||
sequence: 3,
|
||||
sampledAt: 101,
|
||||
modelId: 'model-a',
|
||||
contextWindow: 10_000,
|
||||
method: 'native-summary',
|
||||
scope: 'provider-request',
|
||||
counts: { system: 100, tools: 100, conversation: 900, mcp: 50, skills: 50, other: 0 },
|
||||
inputTokens: 1200,
|
||||
partial: false
|
||||
}
|
||||
|
||||
function service(overrides: Partial<ContextUsageDependencies> = {}) {
|
||||
const dependencies: ContextUsageDependencies = {
|
||||
messages: async () => [assistant()],
|
||||
modelId: async () => 'model-a',
|
||||
running: () => false,
|
||||
summary: async () => undefined,
|
||||
persistIfCurrent: async () => undefined,
|
||||
...overrides
|
||||
}
|
||||
return createContextUsageService(dependencies)
|
||||
}
|
||||
|
||||
assert.deepEqual(await service({ messages: async () => [] }).get('empty'),
|
||||
{ status: 'unavailable', reason: '发送消息后可查看用量' })
|
||||
|
||||
const latest = await service().get('latest')
|
||||
assert.equal(latest.status, 'latest')
|
||||
assert.equal(latest.sample?.usage.inputTokens, 1200)
|
||||
|
||||
const previous = await service({ modelId: async () => 'model-b' }).get('previous')
|
||||
assert.equal(previous.status, 'previous')
|
||||
assert.equal(previous.reason, '来自切换模型前的最近请求')
|
||||
|
||||
const running = await service({ modelId: async () => 'model-b', running: () => true }).get('running')
|
||||
assert.equal(running.status, 'latest', 'the active run must not be relabelled when the next-send model changes')
|
||||
|
||||
const summary = await service({ summary: async () => breakdown }).get('summary')
|
||||
assert.equal(summary.breakdown?.sequence, 3)
|
||||
assert.equal(summary.status, 'latest', 'a matching native summary augments the current request without replacing its upstream total')
|
||||
|
||||
const mismatched = await service({ summary: async () => ({ ...breakdown, requestId: 'older-request' }) }).get('mismatch')
|
||||
assert.equal(mismatched.breakdown, undefined, 'a late summary for another request must not attach to the latest assistant message')
|
||||
|
||||
const summaryFailure = await service({ summary: async () => { throw new Error('query failed') } }).get('failure')
|
||||
assert.equal(summaryFailure.sample?.usage.inputTokens, 1200, 'summary failures retain the real streamed usage sample')
|
||||
|
||||
console.log('context-usage-regression: latest, previous, active-run, summary identity and failure cases passed')
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { composeComposer, isActiveComposerInteraction, type ComposerCompositionInput } from '../src/renderer/src/workbuddy/composer/input-composition'
|
||||
|
||||
const idle: ComposerCompositionInput = {}
|
||||
let result = composeComposer(idle)
|
||||
assert.equal(result.input.mounted, true)
|
||||
assert.equal(result.input.destination, 'send')
|
||||
assert.equal(result.queue.visible, false)
|
||||
assert.equal(result.queue.hiddenBy, 'empty')
|
||||
assert.equal(result.toolbar.permissionPlacement, 'after-add')
|
||||
assert.equal(result.toolbar.primaryAction, 'send')
|
||||
assert.equal(result.escapeStopsGeneration, false)
|
||||
|
||||
result = composeComposer({ running: true, queueCount: 2, queueRevision: 4 })
|
||||
assert.equal(result.input.destination, 'queue', 'running messages are queued instead of interrupting the turn')
|
||||
assert.equal(result.queue.visible, true)
|
||||
assert.equal(result.toolbar.primaryAction, 'stop', 'stop remains reachable while the queue is visible')
|
||||
|
||||
for (const kind of ['question', 'approval', 'plan', 'mcp'] as const) {
|
||||
result = composeComposer({
|
||||
interaction: { id: `${kind}-1`, kind, status: 'pending' },
|
||||
notice: { kind: 'error', id: 'global-error' },
|
||||
task: { id: 'task-1', active: true },
|
||||
queueCount: 3,
|
||||
queueRevision: 8,
|
||||
running: true
|
||||
})
|
||||
assert.equal(result.interaction?.kind, kind)
|
||||
assert.equal(result.input.blockedByInteraction, true)
|
||||
assert.equal(result.input.destination, 'blocked')
|
||||
assert.equal(result.input.preservesDraft, true, 'takeover never clears the draft')
|
||||
assert.equal(result.queue.visible, false, 'takeover hides the queue')
|
||||
assert.equal(result.queue.hiddenBy, 'interaction')
|
||||
assert.equal(result.queue.count, 3, 'queue data remains available while hidden')
|
||||
assert.equal(result.queue.preservesItems, true)
|
||||
assert.equal(result.task?.id, 'task-1', 'task data remains available while hidden')
|
||||
assert.equal(result.notice, null, 'interaction-local errors must not duplicate a global notice')
|
||||
assert.equal(result.toolbar.primaryAction, 'stop', 'stop is not stolen by takeover')
|
||||
assert.deepEqual(result.stack.beforeInput.map(panel => panel.kind), [kind])
|
||||
assert.equal(result.stack.queue, null)
|
||||
}
|
||||
|
||||
result = composeComposer({
|
||||
interaction: { id: 'answered', kind: 'approval', status: 'answered' },
|
||||
task: { active: true },
|
||||
queueCount: 2
|
||||
})
|
||||
assert.equal(result.interaction, null, 'settled interactions no longer take over')
|
||||
assert.equal(result.queue.visible, true)
|
||||
assert.deepEqual(result.stack.beforeQueue.map(panel => panel.kind), ['task'])
|
||||
assert.equal(result.stack.queue?.kind, 'queue')
|
||||
|
||||
result = composeComposer({ notice: { kind: 'quota', id: 'quota-1' }, task: { active: true } })
|
||||
assert.deepEqual(result.stack.beforeInput.map(panel => panel.kind), ['quota'])
|
||||
|
||||
result = composeComposer({ notice: { kind: 'error', id: 'error-1' }, task: { id: 'task-2', active: true }, queueCount: 1 })
|
||||
assert.deepEqual(result.stack.beforeInput.map(panel => panel.kind), ['error'])
|
||||
assert.equal(result.stack.beforeQueue.length, 0)
|
||||
assert.equal(result.task?.id, 'task-2', 'notice keeps task data while suppressing its placement')
|
||||
assert.equal(result.queue.visible, false, 'notice suppresses accessory queue rendering')
|
||||
assert.equal(result.queue.hiddenBy, 'notice')
|
||||
assert.equal(result.stack.queue, null)
|
||||
|
||||
assert.equal(isActiveComposerInteraction({ id: 'q', kind: 'question', status: 'responding' }), true)
|
||||
assert.equal(isActiveComposerInteraction({ id: 'q', kind: 'question', status: 'cancelled' }), false)
|
||||
|
||||
const clamped = composeComposer({ queueCount: Number.NaN, queueRevision: -3 })
|
||||
assert.equal(clamped.queue.count, 0)
|
||||
assert.equal(clamped.queue.revision, 0)
|
||||
|
||||
const firstLayout = composeComposer({ queueCount: 1, queueRevision: 1 }).layoutKey
|
||||
const secondLayout = composeComposer({ queueCount: 1, queueRevision: 2 }).layoutKey
|
||||
assert.notEqual(firstLayout, secondLayout, 'layout key changes when queue height may change')
|
||||
|
||||
console.log('input-composition-regression: precedence, takeover, queue preservation, task placement, toolbar and escape rules passed')
|
||||
@@ -0,0 +1,415 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { hostname } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createPromptQueue } from '../src/main/prompt-queue'
|
||||
import { resolveQueuedRequestSettings } from '../src/main/agent/queued-request-settings'
|
||||
import type { SendPayload } from '../src/shared/ipc'
|
||||
import type { PromptQueueSnapshot } from '../src/shared/prompt-queue'
|
||||
|
||||
const workspace = resolve('.tmp')
|
||||
await fs.mkdir(workspace, { recursive: true })
|
||||
const root = await fs.mkdtemp(join(workspace, 'prompt-queue-adversarial-'))
|
||||
const results: Array<{ name: string; pass: boolean; error?: string }> = []
|
||||
const tick = (ms = 20) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const payload = (sessionId: string, text: string, extra: Partial<SendPayload> = {}): SendPayload =>
|
||||
({ sessionId, text, ...extra })
|
||||
|
||||
async function waitFor(check: () => boolean | Promise<boolean>, label: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 150; attempt++) {
|
||||
if (await check()) return
|
||||
await tick(10)
|
||||
}
|
||||
throw new Error('Timeout: ' + label)
|
||||
}
|
||||
|
||||
async function verify(name: string, run: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await run()
|
||||
results.push({ name, pass: true })
|
||||
} catch (error) {
|
||||
results.push({ name, pass: false, error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
function options(file: string, input: {
|
||||
blocked?: (sessionId: string) => boolean
|
||||
resolveDispatch?: (payload: SendPayload, id: string) => SendPayload | Promise<SendPayload>
|
||||
deliver?: (payload: SendPayload, id: string) => Promise<{
|
||||
completion: Promise<{ status: 'done' | 'cancelled' | 'error' }>
|
||||
}>
|
||||
changed?: (snapshot: PromptQueueSnapshot) => void
|
||||
} = {}) {
|
||||
return {
|
||||
file,
|
||||
validate: async () => undefined,
|
||||
blocked: async sessionId => input.blocked?.(sessionId) ?? true,
|
||||
...(input.resolveDispatch ? { resolveDispatch: input.resolveDispatch } : {}),
|
||||
deliver: input.deliver ?? (async () => ({
|
||||
completion: new Promise<{ status: 'done' }>(() => undefined)
|
||||
})),
|
||||
changed: input.changed ?? (() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await verify('parallel same-instance enqueues preserve exactly 19 items', async () => {
|
||||
const file = join(root, 'cap.json')
|
||||
const queue = createPromptQueue(options(file))
|
||||
const outcomes = await Promise.allSettled(Array.from({ length: 25 }, (_, index) =>
|
||||
queue.enqueue(payload('cap', String(index)))))
|
||||
assert.equal(outcomes.filter(item => item.status === 'fulfilled').length, 19)
|
||||
assert.equal(outcomes.filter(item => item.status === 'rejected').length, 6)
|
||||
const snapshot = await queue.get('cap')
|
||||
assert.equal(snapshot.items.length, 19)
|
||||
assert.equal(new Set(snapshot.items.map(item => item.id)).size, 19)
|
||||
})
|
||||
|
||||
await verify('duplicate take is serialized to one winner', async () => {
|
||||
const file = join(root, 'take.json')
|
||||
const queue = createPromptQueue(options(file))
|
||||
await queue.enqueue(payload('take', 'only'))
|
||||
const id = (await queue.get('take')).items[0].id
|
||||
const outcomes = await Promise.allSettled([queue.take('take', id), queue.take('take', id)])
|
||||
assert.equal(outcomes.filter(item => item.status === 'fulfilled').length, 1)
|
||||
assert.equal(outcomes.filter(item => item.status === 'rejected').length, 1)
|
||||
assert.equal((await queue.get('take')).items.length, 0)
|
||||
})
|
||||
|
||||
await verify('delivering item is immutable and notify does not duplicate delivery', async () => {
|
||||
const file = join(root, 'delivery-lock.json')
|
||||
let releaseDelivery!: () => void
|
||||
let releaseCompletion!: (value: { status: 'done' }) => void
|
||||
const deliveryGate = new Promise<void>(resolve => { releaseDelivery = resolve })
|
||||
const completion = new Promise<{ status: 'done' }>(resolve => { releaseCompletion = resolve })
|
||||
const delivered: string[] = []
|
||||
const queue = createPromptQueue(options(file, {
|
||||
blocked: () => false,
|
||||
deliver: async (_value, id) => {
|
||||
delivered.push(id)
|
||||
await deliveryGate
|
||||
return { completion }
|
||||
}
|
||||
}))
|
||||
await queue.enqueue(payload('delivery', 'first'))
|
||||
await queue.enqueue(payload('delivery', 'second'))
|
||||
await waitFor(() => delivered.length === 1, 'first delivery reserve')
|
||||
const delivering = await queue.get('delivery')
|
||||
assert.equal(delivering.items[0].status, 'delivering')
|
||||
const observer = createPromptQueue(options(file, { blocked: () => true }))
|
||||
assert.equal((await observer.get('delivery')).items[0].status, 'delivering',
|
||||
'fresh reads in the same process must preserve an owned active delivery')
|
||||
await assert.rejects(queue.clear('delivery'), /正在交付/)
|
||||
const first = delivering.items[0].id
|
||||
const second = delivering.items[1].id
|
||||
await queue.remove('delivery', second)
|
||||
await assert.rejects(queue.take('delivery', first), /交付/)
|
||||
await assert.rejects(queue.reorder('delivery', [first]), /排序/)
|
||||
queue.notify('delivery')
|
||||
queue.notify('delivery')
|
||||
queue.notify('delivery')
|
||||
await tick(50)
|
||||
assert.equal(delivered.length, 1)
|
||||
releaseDelivery()
|
||||
releaseCompletion({ status: 'done' })
|
||||
await waitFor(async () => (await queue.get('delivery')).items.length === 0, 'delivery cleanup')
|
||||
})
|
||||
|
||||
await verify('clear removes a non-delivering session queue and is idempotent', async () => {
|
||||
const file = join(root, 'clear.json')
|
||||
const queue = createPromptQueue(options(file))
|
||||
await queue.enqueue(payload('clear', 'one'))
|
||||
await queue.enqueue(payload('clear', 'two'))
|
||||
const cleared = await queue.clear('clear')
|
||||
assert.deepEqual(cleared.items, [])
|
||||
assert.equal(cleared.paused, false)
|
||||
assert.deepEqual((await createPromptQueue(options(file)).get('clear')).items, [])
|
||||
assert.deepEqual((await queue.clear('clear')).items, [])
|
||||
})
|
||||
|
||||
await verify('first enqueue after an empty queue was paused resumes a new run without unpausing existing work', async () => {
|
||||
const file = join(root, 'empty-pause.json')
|
||||
let blocked = true
|
||||
let finish!: (value: { status: 'done' }) => void
|
||||
const delivered: string[] = []
|
||||
const queue = createPromptQueue(options(file, {
|
||||
blocked: () => blocked,
|
||||
deliver: async value => {
|
||||
delivered.push(value.text)
|
||||
return { completion: new Promise<{ status: 'done' }>(resolve => { finish = resolve }) }
|
||||
}
|
||||
}))
|
||||
|
||||
await queue.pause('lifecycle', '上一条运行已停止')
|
||||
assert.equal((await queue.get('lifecycle')).paused, true)
|
||||
blocked = false
|
||||
await queue.enqueue(payload('lifecycle', 'new run'))
|
||||
await waitFor(() => delivered.length === 1, 'new run first delivery')
|
||||
const active = await queue.get('lifecycle')
|
||||
assert.equal(active.paused, false, 'a new run must not inherit a pause left on an empty queue')
|
||||
assert.equal(active.reason, undefined)
|
||||
finish({ status: 'done' })
|
||||
await waitFor(async () => (await queue.get('lifecycle')).items.length === 0, 'first delivery cleanup')
|
||||
|
||||
blocked = true
|
||||
await queue.enqueue(payload('lifecycle', 'queued while busy'))
|
||||
await queue.pause('lifecycle', '用户停止当前队列')
|
||||
await queue.enqueue(payload('lifecycle', 'later item'))
|
||||
const retained = await queue.get('lifecycle')
|
||||
assert.equal(retained.paused, true, 'a non-empty queue must retain its pause')
|
||||
assert.equal(retained.reason, '用户停止当前队列')
|
||||
assert.deepEqual(retained.items.map(item => item.payload.text), ['queued while busy', 'later item'])
|
||||
assert.deepEqual(delivered, ['new run'])
|
||||
})
|
||||
|
||||
await verify('incoming payload is validated before host side effects and persistence', async () => {
|
||||
const file = join(root, 'incoming-validation.json')
|
||||
let hostCalls = 0
|
||||
const queue = createPromptQueue({
|
||||
...options(file),
|
||||
validate: async () => { hostCalls++ }
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
queue.enqueue({ sessionId: 'incoming', text: '/compact', command: 'compact' } as SendPayload),
|
||||
/追加消息无效.*命令/
|
||||
)
|
||||
assert.equal(hostCalls, 0, 'invalid transport payload must not reach host validation')
|
||||
await assert.rejects(fs.access(file), (error: NodeJS.ErrnoException) => error.code === 'ENOENT')
|
||||
|
||||
await assert.rejects(
|
||||
queue.enqueue({
|
||||
sessionId: 'incoming',
|
||||
text: 'label',
|
||||
skillIds: ['skill:a'],
|
||||
capabilityLabels: { 'skill:a': { name: 'not-a-string' } }
|
||||
} as unknown as SendPayload),
|
||||
/追加消息无效.*能力标签/
|
||||
)
|
||||
assert.equal(hostCalls, 0, 'malformed display labels must not reach host validation')
|
||||
await assert.rejects(fs.access(file), (error: NodeJS.ErrnoException) => error.code === 'ENOENT')
|
||||
})
|
||||
|
||||
await verify('host-rebuilt capability labels are validated before persistence', async () => {
|
||||
const file = join(root, 'capability-labels.json')
|
||||
const queue = createPromptQueue({
|
||||
...options(file),
|
||||
validate: async value => {
|
||||
value.capabilityLabels = { 'skill:alpha': 'Alpha skill', 'mcp:server': 'Server' }
|
||||
}
|
||||
})
|
||||
await queue.enqueue(payload('labels', 'use labels', {
|
||||
skillIds: ['alpha'],
|
||||
mcpServerIds: ['server'],
|
||||
capabilityLabels: { 'skill:alpha': 'untrusted renderer label' }
|
||||
}))
|
||||
const stored = (await queue.get('labels')).items[0].payload.capabilityLabels
|
||||
assert.deepEqual(stored, { 'skill:alpha': 'Alpha skill', 'mcp:server': 'Server' })
|
||||
assert.deepEqual(
|
||||
(await createPromptQueue(options(file)).get('labels')).items[0].payload.capabilityLabels,
|
||||
stored,
|
||||
'validated labels must survive a fresh disk read'
|
||||
)
|
||||
})
|
||||
|
||||
await verify('two queue instances cannot overwrite one another', async () => {
|
||||
const file = join(root, 'two-instances.json')
|
||||
const a = createPromptQueue(options(file))
|
||||
const b = createPromptQueue(options(file))
|
||||
await Promise.all([a.get('shared'), b.get('shared')])
|
||||
await Promise.all([
|
||||
a.enqueue(payload('shared', 'from-a')),
|
||||
b.enqueue(payload('shared', 'from-b'))
|
||||
])
|
||||
await tick(80)
|
||||
const fresh = createPromptQueue(options(file))
|
||||
const snapshot = await fresh.get('shared')
|
||||
assert.deepEqual(snapshot.items.map(item => item.payload.text).sort(), ['from-a', 'from-b'])
|
||||
})
|
||||
|
||||
await verify('corrupt queue cannot redirect or duplicate item identity', async () => {
|
||||
const file = join(root, 'corrupt.json')
|
||||
await fs.writeFile(file, JSON.stringify({
|
||||
version: 1,
|
||||
queues: [{
|
||||
sessionId: 'owner',
|
||||
revision: 1,
|
||||
paused: false,
|
||||
items: [
|
||||
{ id: 'same', createdAt: 1, status: 'pending', payload: payload('other', 'redirected') },
|
||||
{ id: 'same', createdAt: 2, status: 'pending', payload: payload('other', 'duplicate') }
|
||||
]
|
||||
}]
|
||||
}))
|
||||
const queue = createPromptQueue(options(file))
|
||||
await assert.rejects(queue.get('owner'), /损坏|重复|会话/)
|
||||
})
|
||||
|
||||
await verify('invalid revision, status and payload permission are rejected', async () => {
|
||||
const base = {
|
||||
version: 1,
|
||||
queues: [{
|
||||
sessionId: 'invalid', revision: 1, paused: false,
|
||||
items: [{ id: 'valid-id', createdAt: 1, status: 'pending', payload: payload('invalid', 'text') }]
|
||||
}]
|
||||
}
|
||||
for (const [name, mutate] of [
|
||||
['revision', (value: any) => { value.queues[0].revision = -1 }],
|
||||
['status', (value: any) => { value.queues[0].items[0].status = 'mystery' }],
|
||||
['permission', (value: any) => { value.queues[0].items[0].payload.permissionMode = 'admin' }],
|
||||
['capability-label', (value: any) => { value.queues[0].items[0].payload.capabilityLabels = { 'skill:a': { name: 'bad' } } }]
|
||||
] as const) {
|
||||
const file = join(root, `invalid-${name}.json`)
|
||||
const value = structuredClone(base)
|
||||
mutate(value)
|
||||
await fs.writeFile(file, JSON.stringify(value))
|
||||
await assert.rejects(createPromptQueue(options(file)).get('invalid'), /损坏/)
|
||||
}
|
||||
})
|
||||
|
||||
await verify('post-delivery removal failure becomes paused unknown instead of stuck delivering', async () => {
|
||||
const file = join(root, 'removal-failure.json')
|
||||
let sabotage: Promise<void> | undefined
|
||||
const queue = createPromptQueue(options(file, {
|
||||
blocked: sessionId => sessionId !== 'removal',
|
||||
changed: snapshot => {
|
||||
if (!sabotage && snapshot.sessionId === 'removal' && snapshot.items[0]?.status === 'delivering') {
|
||||
sabotage = (async () => {
|
||||
await fs.rm(file, { force: true })
|
||||
await fs.mkdir(file)
|
||||
})()
|
||||
}
|
||||
},
|
||||
deliver: async () => {
|
||||
await sabotage
|
||||
return { completion: Promise.resolve({ status: 'done' }) }
|
||||
}
|
||||
}))
|
||||
await queue.enqueue(payload('bystander', 'must survive fallback'))
|
||||
await queue.enqueue(payload('removal', 'already persisted elsewhere'))
|
||||
await waitFor(() => !!sabotage, 'removal failure sabotage')
|
||||
await waitFor(async () => {
|
||||
try {
|
||||
const current = await queue.get('removal')
|
||||
return current.paused && current.items[0]?.status === 'unknown'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, 'removal uncertainty fallback')
|
||||
const snapshot = await queue.get('removal')
|
||||
assert.equal(snapshot.items[0].status, 'unknown', 'acknowledged delivery must become unknown when queue cleanup cannot be persisted')
|
||||
assert.equal(snapshot.paused, true, 'an unconfirmed queue cleanup must pause automatic dispatch')
|
||||
assert.equal((await queue.get('bystander')).items[0].payload.text, 'must survive fallback')
|
||||
const unknown = snapshot.items[0]
|
||||
await fs.rm(file, { recursive: true, force: true })
|
||||
const taken = await queue.take('removal', unknown.id)
|
||||
assert.equal(taken.status, 'unknown')
|
||||
assert.equal(taken.payload.text, 'already persisted elsewhere')
|
||||
assert.deepEqual((await queue.get('removal')).items, [])
|
||||
assert.equal((await queue.get('bystander')).items[0].payload.text, 'must survive fallback')
|
||||
})
|
||||
|
||||
await verify('stale owner nonce is recovered without trusting a reused PID', async () => {
|
||||
const file = join(root, 'owner-nonce.json')
|
||||
await fs.writeFile(file, JSON.stringify({
|
||||
version: 1,
|
||||
queues: [{
|
||||
sessionId: 'owner',
|
||||
revision: 2,
|
||||
paused: false,
|
||||
items: [{
|
||||
id: 'owned-item', createdAt: 1, status: 'delivering',
|
||||
deliveryOwner: { pid: process.pid, nonce: 'old-process-instance', hostname: hostname(), startedAt: 1 },
|
||||
payload: payload('owner', 'recover me')
|
||||
}]
|
||||
}]
|
||||
}))
|
||||
const snapshot = await createPromptQueue(options(file)).get('owner')
|
||||
assert.equal(snapshot.items[0].status, 'unknown')
|
||||
assert.equal(snapshot.paused, true)
|
||||
})
|
||||
|
||||
await verify('stale and expired lock files are reclaimed', async () => {
|
||||
const file = join(root, 'lock-reclaim.json')
|
||||
const lock = `${file}.lock`
|
||||
await fs.writeFile(lock, JSON.stringify({
|
||||
pid: 99999999, nonce: 'dead-owner', hostname: hostname(),
|
||||
createdAt: Date.now(), expiresAt: Date.now() + 60_000
|
||||
}))
|
||||
assert.deepEqual((await createPromptQueue(options(file)).get('lock')).items, [])
|
||||
await fs.writeFile(lock, JSON.stringify({
|
||||
pid: process.pid, nonce: 'expired-owner', hostname: 'remote-test-host',
|
||||
createdAt: Date.now() - 60_000, expiresAt: Date.now() - 1
|
||||
}))
|
||||
assert.deepEqual((await createPromptQueue(options(file)).get('lock')).items, [])
|
||||
})
|
||||
|
||||
await verify('live foreign lock times out with an explicit error', async () => {
|
||||
const file = join(root, 'lock-timeout.json')
|
||||
const lock = `${file}.lock`
|
||||
await fs.writeFile(lock, JSON.stringify({
|
||||
pid: process.pid, nonce: 'live-foreign-owner', hostname: hostname(),
|
||||
createdAt: Date.now(), expiresAt: Date.now() + 60_000
|
||||
}))
|
||||
try {
|
||||
await assert.rejects(createPromptQueue(options(file)).get('lock'), /文件锁超时/)
|
||||
} finally {
|
||||
await fs.rm(lock, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
await verify('blocked notify does not create a state revision', async () => {
|
||||
const file = join(root, 'blocked-revision.json')
|
||||
const queue = createPromptQueue(options(file, { blocked: () => true }))
|
||||
await queue.enqueue(payload('revision', 'pending'))
|
||||
await tick(50)
|
||||
const before = await queue.get('revision')
|
||||
queue.notify('revision')
|
||||
await tick(50)
|
||||
const after = await queue.get('revision')
|
||||
assert.equal(after.revision, before.revision, 'a blocked no-op must not persist or notify a new revision')
|
||||
})
|
||||
|
||||
await verify('host resolver applies dispatch-time permission downgrade without mutating stored payload', async () => {
|
||||
const file = join(root, 'permission.json')
|
||||
let blocked = true
|
||||
let deliveredPermission: SendPayload['permissionMode']
|
||||
assert.deepEqual(resolveQueuedRequestSettings({
|
||||
queued: false, payloadPermissionMode: 'full-access', sessionPermissionMode: 'default-sandbox'
|
||||
}), { permissionMode: 'full-access', persistSessionPermission: true })
|
||||
assert.deepEqual(resolveQueuedRequestSettings({
|
||||
queued: true, payloadPermissionMode: 'full-access', sessionPermissionMode: 'default-sandbox'
|
||||
}), { permissionMode: 'default-sandbox', persistSessionPermission: false })
|
||||
const queue = createPromptQueue(options(file, {
|
||||
blocked: () => blocked,
|
||||
resolveDispatch: value => ({
|
||||
...value,
|
||||
permissionMode: resolveQueuedRequestSettings({
|
||||
queued: true,
|
||||
payloadPermissionMode: value.permissionMode,
|
||||
sessionPermissionMode: 'default-sandbox'
|
||||
}).permissionMode
|
||||
}),
|
||||
deliver: async value => {
|
||||
deliveredPermission = value.permissionMode
|
||||
return { completion: Promise.resolve({ status: 'done' }) }
|
||||
}
|
||||
}))
|
||||
await queue.enqueue(payload('permission', 'run', { permissionMode: 'full-access' }))
|
||||
assert.equal((await queue.get('permission')).items[0].payload.permissionMode, 'full-access')
|
||||
blocked = false
|
||||
queue.notify('permission')
|
||||
await waitFor(() => deliveredPermission !== undefined, 'permission delivery')
|
||||
assert.equal(deliveredPermission, 'default-sandbox')
|
||||
await waitFor(async () => (await queue.get('permission')).items.length === 0, 'permission delivery cleanup')
|
||||
})
|
||||
} finally {
|
||||
if (!resolve(root).startsWith(workspace + '\\') && !resolve(root).startsWith(workspace + '/')) {
|
||||
throw new Error('Unsafe adversarial test cleanup path')
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const failures = results.filter(result => !result.pass)
|
||||
console.log(JSON.stringify({ result: failures.length ? 'FAIL' : 'PASS', checks: results }, null, 2))
|
||||
if (failures.length) process.exitCode = 1
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { resolve, join } from 'node:path'
|
||||
import { createPromptQueue } from '../src/main/prompt-queue'
|
||||
import type { PromptQueueSnapshot } from '../src/shared/prompt-queue'
|
||||
|
||||
const workspace = resolve('.tmp')
|
||||
await fs.mkdir(workspace, { recursive: true })
|
||||
const root = await fs.mkdtemp(join(workspace, 'prompt-queue-'))
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 10))
|
||||
try {
|
||||
let blocked = true
|
||||
const delivered: string[] = []
|
||||
const changes: PromptQueueSnapshot[] = []
|
||||
let finish: ((value: { status: 'done' | 'cancelled' }) => void) | undefined
|
||||
const options = {
|
||||
file: join(root, 'queues.json'),
|
||||
validate: async () => {},
|
||||
blocked: async () => blocked,
|
||||
changed: (snapshot: PromptQueueSnapshot) => { changes.push(snapshot) },
|
||||
deliver: async (_payload: unknown, id: string) => {
|
||||
delivered.push(id)
|
||||
return { completion: new Promise<{ status: 'done' | 'cancelled' }>(resolve => { finish = resolve }) }
|
||||
}
|
||||
}
|
||||
const queue = createPromptQueue(options)
|
||||
for (let index = 0; index < 19; index++) await queue.enqueue({ sessionId: 'a', text: `${index}`, resources: ['C:/workspace/a'], skillIds: ['skill'], mcpServerIds: ['server'], quotes: [], attachments: [] })
|
||||
await assert.rejects(queue.enqueue({ sessionId: 'a', text: 'full' }), /19/)
|
||||
assert.equal((await queue.get('a')).items.length, 19)
|
||||
const order = (await queue.get('a')).items.map(item => item.id).reverse()
|
||||
await queue.reorder('a', order)
|
||||
await assert.rejects(queue.reorder('a', [...order.slice(1), order[1]]), /排序/)
|
||||
const taken = await queue.take('a', order[0])
|
||||
assert.deepEqual(taken.payload.skillIds, ['skill'])
|
||||
assert.deepEqual(taken.payload.mcpServerIds, ['server'])
|
||||
assert.deepEqual(taken.payload.resources, ['C:/workspace/a'])
|
||||
assert.equal((await queue.get('b')).items.length, 0, 'sessions are isolated')
|
||||
assert.equal(delivered.length, 0, 'active run blocks ordinary dispatch')
|
||||
|
||||
const recovered = createPromptQueue(options)
|
||||
assert.equal((await recovered.get('a')).paused, true, 'restart requires explicit resume')
|
||||
blocked = false
|
||||
await recovered.resume('a')
|
||||
for (let attempt = 0; attempt < 100 && !delivered.length; attempt++) await tick()
|
||||
assert.equal(delivered.length, 1)
|
||||
for (let attempt = 0; attempt < 100 && (await recovered.get('a')).items.length === 18; attempt++) await tick()
|
||||
assert.equal((await recovered.get('a')).items.length, 17, 'only acknowledged delivery leaves the queue')
|
||||
finish!({ status: 'cancelled' })
|
||||
for (let attempt = 0; attempt < 100 && !(await recovered.get('a')).paused; attempt++) await tick()
|
||||
assert.equal((await recovered.get('a')).paused, true)
|
||||
assert.equal(delivered.length, 1, 'stop pauses all remaining ordinary sends')
|
||||
|
||||
const uncertain = createPromptQueue({ ...options, file: join(root, 'uncertain.json'),
|
||||
deliver: async () => { throw new Error('lost acknowledgement') } })
|
||||
await uncertain.enqueue({ sessionId: 'c', text: 'uncertain' })
|
||||
for (let attempt = 0; attempt < 100 && !(await uncertain.get('c')).paused; attempt++) await tick()
|
||||
assert.equal((await uncertain.get('c')).items[0].status, 'unknown')
|
||||
await assert.rejects(uncertain.resume('c'), /结果不明/)
|
||||
await assert.rejects(uncertain.sendNow('c', 'unknown'), /尚未接入/)
|
||||
const disk = JSON.parse(await fs.readFile(options.file, 'utf8'))
|
||||
const interruptedItem = disk.queues[0].items.find((item: { status: string }) => item.status !== 'editing')
|
||||
interruptedItem.status = 'delivering'
|
||||
delete interruptedItem.deliveryOwner
|
||||
await fs.writeFile(options.file, JSON.stringify(disk))
|
||||
const interrupted = createPromptQueue(options)
|
||||
assert.equal((await interrupted.get('a')).items[0].status, 'unknown')
|
||||
await assert.rejects(interrupted.resume('a'), /结果不明/)
|
||||
assert.ok(changes.every(snapshot => snapshot.revision >= 1))
|
||||
const editFile = join(root, 'editing.json')
|
||||
const editing = createPromptQueue({ ...options, file: editFile, blocked: async () => true })
|
||||
await editing.enqueue({ sessionId: 'edit', text: 'original', skillIds: ['skill'] })
|
||||
const originalId = (await editing.get('edit')).items[0].id
|
||||
const draft = await editing.take('edit', originalId)
|
||||
assert.equal(draft.payload.queueEditId, originalId)
|
||||
assert.equal((await editing.get('edit')).items.length, 0)
|
||||
assert.equal((await editing.get('edit')).editingDraft?.payload.text, 'original', 'take remains recoverable on disk until submission')
|
||||
await editing.restoreEdit('edit', originalId)
|
||||
assert.equal((await editing.get('edit')).items[0].id, originalId, 'rollback preserves identity and order')
|
||||
const retaken = await editing.take('edit', originalId)
|
||||
await editing.enqueue({ ...retaken.payload, text: 'edited' })
|
||||
assert.equal((await editing.get('edit')).items[0].payload.text, 'edited')
|
||||
assert.equal((await editing.get('edit')).editingDraft, undefined)
|
||||
await assert.rejects(editing.enqueue({ sessionId: 'edit', text: 'invalid handoff', queueEditId: '' }), /编辑取回标识/)
|
||||
const ordered = createPromptQueue({ ...options, file: join(root, 'edit-order.json'), blocked: async () => true })
|
||||
for (const text of ['first', 'second', 'third']) await ordered.enqueue({ sessionId: 'edit-order', text })
|
||||
const middle = (await ordered.get('edit-order')).items[1]
|
||||
const middleDraft = await ordered.take('edit-order', middle.id)
|
||||
await ordered.enqueue({ ...middleDraft.payload, text: 'second edited' })
|
||||
assert.deepEqual((await ordered.get('edit-order')).items.map(item => item.payload.text),
|
||||
['first', 'second edited', 'third'], 'editing and resubmitting keeps the original queue position')
|
||||
const toRecover = (await editing.get('edit')).items[0].id
|
||||
await editing.take('edit', toRecover)
|
||||
const editingDisk = JSON.parse(await fs.readFile(editFile, 'utf8'))
|
||||
editingDisk.queues[0].items[0].deliveryOwner.nonce = 'previous-process'
|
||||
await fs.writeFile(editFile, JSON.stringify(editingDisk))
|
||||
assert.equal((await createPromptQueue({ ...options, file: editFile }).get('edit')).items[0].payload.text, 'edited', 'restart recovers an unsubmitted editor handoff')
|
||||
|
||||
const guideAcks = new Map<string, { resolve(): void; reject(error: Error): void }>()
|
||||
const immediate = createPromptQueue({ ...options, file: join(root, 'immediate.json'), blocked: async () => true,
|
||||
deliverNow: (_payload, id) => new Promise<void>((resolve, reject) => { guideAcks.set(id, { resolve, reject }) }) })
|
||||
await immediate.enqueue({ sessionId: 'guide', text: 'a' })
|
||||
await immediate.enqueue({ sessionId: 'guide', text: 'b' })
|
||||
const guideIds = (await immediate.get('guide')).items.map(item => item.id)
|
||||
const firstGuide = immediate.sendNow('guide', guideIds[0])
|
||||
const secondGuide = immediate.sendNow('guide', guideIds[1])
|
||||
const secondFailure = assert.rejects(secondGuide, /cancelled/)
|
||||
for (let attempt = 0; attempt < 100 && guideAcks.size !== 2; attempt++) await tick()
|
||||
assert.equal(guideAcks.size, 2)
|
||||
assert.ok((await immediate.get('guide')).items.every(item => item.status === 'delivering'))
|
||||
guideAcks.get(guideIds[0])!.resolve()
|
||||
await firstGuide
|
||||
assert.equal((await immediate.get('guide')).items[0].id, guideIds[1], 'one acknowledgement cannot consume another input')
|
||||
guideAcks.get(guideIds[1])!.reject(Object.assign(new Error('cancelled'), { submitted: true }))
|
||||
await secondFailure
|
||||
assert.equal((await immediate.get('guide')).items[0].status, 'unknown')
|
||||
assert.equal((await immediate.get('guide')).paused, true)
|
||||
console.log('prompt-queue-regression: cap, payload, order, isolation, delivery, stop, recovery and unknown checks passed')
|
||||
} finally {
|
||||
if (!resolve(root).startsWith(workspace + '\\') && !resolve(root).startsWith(workspace + '/')) throw new Error('Unsafe test cleanup path')
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { randomUUID, createHash } from 'node:crypto'
|
||||
import { build } from 'esbuild'
|
||||
import { zipSync } from 'fflate'
|
||||
|
||||
const testRoot = await fs.mkdtemp(path.resolve('.tmp/recommended-skills-'))
|
||||
const tempRoot = path.join(testRoot, 'installs')
|
||||
await fs.mkdir(tempRoot, { recursive: true })
|
||||
let userData = path.join(testRoot, 'data')
|
||||
await fs.mkdir(userData, { recursive: true })
|
||||
;(globalThis as Record<string, unknown>).__ycodeUserData = userData
|
||||
const checks: string[] = []
|
||||
const check = (name: string, actual: unknown, expected: unknown): void => {
|
||||
assert.deepEqual(actual, expected, name)
|
||||
checks.push(name)
|
||||
}
|
||||
const sha256 = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex')
|
||||
|
||||
/** Each group gets its own skill store so installed state never leaks between cases. */
|
||||
async function useGroup(name: string): Promise<void> {
|
||||
userData = path.join(testRoot, `data-${name}`)
|
||||
await fs.mkdir(userData, { recursive: true })
|
||||
;(globalThis as Record<string, unknown>).__ycodeUserData = userData
|
||||
}
|
||||
|
||||
const electronStub = {
|
||||
name: 'isolated-electron-path',
|
||||
setup(context: import('esbuild').PluginBuild) {
|
||||
context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'capability-test' }))
|
||||
context.onLoad({ filter: /.*/, namespace: 'capability-test' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return globalThis.__ycodeUserData}};
|
||||
export const safeStorage={isEncryptionAvailable:()=>false,encryptString:()=>{throw Error('unused')},decryptString:()=>{throw Error('unused')}};`
|
||||
}))
|
||||
}
|
||||
}
|
||||
async function compile(entry: string, file: string): Promise<string> {
|
||||
const outfile = path.join(testRoot, file)
|
||||
await build({ entryPoints: [path.resolve(entry)], outfile, bundle: true, platform: 'node', format: 'esm',
|
||||
packages: 'external', logLevel: 'warning', plugins: [electronStub] })
|
||||
return outfile
|
||||
}
|
||||
const servicePath = await compile('src/main/capabilities/recommended-skills.ts', 'recommended-skills.mjs')
|
||||
const skillsPath = await compile('src/main/capabilities/skills.ts', 'skills.mjs')
|
||||
const service = await import(pathToFileURL(servicePath).href)
|
||||
const skills = await import(pathToFileURL(skillsPath).href)
|
||||
|
||||
interface FakeEntry { name: string; description?: string; version?: string; license?: string }
|
||||
function archiveFor(entry: FakeEntry, extra: Record<string, string> = {}): Uint8Array {
|
||||
const front = ['---', `name: ${entry.name}`, `description: ${entry.description ?? 'Controlled recommended skill'}`,
|
||||
...(entry.version ? [`version: "${entry.version}"`] : []), ...(entry.license ? [`license: ${entry.license}`] : []), '---'].join('\n')
|
||||
const files: Record<string, Uint8Array> = {
|
||||
'skill/SKILL.md': new TextEncoder().encode(`${front}\n\nControlled body.\n`),
|
||||
'skill/LICENSE': new TextEncoder().encode('MIT License\n\nPermission is hereby granted, free of charge.\n')
|
||||
}
|
||||
for (const [name, value] of Object.entries(extra)) files[`skill/${name}`] = new TextEncoder().encode(value)
|
||||
return zipSync(files, { level: 0 })
|
||||
}
|
||||
|
||||
/** A local skill folder that is not a recommendation package. */
|
||||
async function makeLocalSkill(name: string, description = 'Locally created skill'): Promise<string> {
|
||||
const folder = path.join(testRoot, `local-${name}-${randomUUID().slice(0, 8)}`)
|
||||
await fs.mkdir(folder, { recursive: true })
|
||||
await fs.writeFile(path.join(folder, 'SKILL.md'),
|
||||
`---\nname: ${name}\ndescription: ${description}\n---\nLocal body.\n`)
|
||||
return folder
|
||||
}
|
||||
|
||||
const baseEntry = { id: 'archify', name: 'archify', description: 'Controlled', author: 'tt-a1i', license: 'MIT',
|
||||
repository: 'tt-a1i/archify', version: '3.2.0', commit: '25cf37080fc8b1dbe758a4efc3935b55f3327083',
|
||||
packagePath: 'archify.zip', expectedBytes: 0, containsScripts: true }
|
||||
const archive = archiveFor({ name: 'archify', version: '3.2.0', license: 'MIT' })
|
||||
const archiveHash = sha256(archive)
|
||||
const catalog = [{ ...baseEntry, sha256: archiveHash, expectedBytes: archive.byteLength }]
|
||||
|
||||
function fakeResponse(body: Uint8Array, init: { status?: number; headers?: Record<string, string> } = {}): Response {
|
||||
return new Response(new Uint8Array(body), { status: init.status ?? 200,
|
||||
headers: { 'content-length': String(body.byteLength), ...(init.headers ?? {}) } })
|
||||
}
|
||||
type FakeRequest = (url: URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
/** A response that never completes until the caller's abort signal fires. */
|
||||
function stallingResponse(totalBytes: number, chunk: Uint8Array, init?: RequestInit): Response {
|
||||
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
streamController = controller
|
||||
// The interval stays referenced so the test event loop keeps running.
|
||||
timer = setInterval(() => { try { controller.enqueue(chunk) } catch { /* aborted */ } }, 20)
|
||||
}
|
||||
})
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
if (timer) clearInterval(timer)
|
||||
try { streamController?.error(Object.assign(new Error('aborted'), { name: 'AbortError' })) } catch { /* already closed */ }
|
||||
})
|
||||
return new Response(stream, { headers: { 'content-length': String(totalBytes) } })
|
||||
}
|
||||
|
||||
function makeInstaller(request: FakeRequest, overrides: Partial<{
|
||||
catalog: typeof catalog; timeoutMs: number
|
||||
}> = {}) {
|
||||
return service.createRecommendedSkillInstaller({
|
||||
tempRoot, catalog: overrides.catalog ?? catalog, request, timeoutMs: overrides.timeoutMs ?? 5000,
|
||||
snapshot: skills.getSkillInstallSnapshot, importArchive: skills.importRecommendedSkillArchive
|
||||
})
|
||||
}
|
||||
|
||||
// ---- unknown and duplicate operation identifiers -----------------------------
|
||||
{
|
||||
await useGroup('ids')
|
||||
const installer = makeInstaller(async () => fakeResponse(archive))
|
||||
await assert.rejects(installer.install('missing', { operationId: randomUUID() }), /推荐技能不存在/)
|
||||
check('an unknown recommendation id is rejected', true, true)
|
||||
const operationId = randomUUID()
|
||||
const first = installer.install('archify', { operationId })
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /已有推荐技能正在安装/)
|
||||
check('a second install is rejected while one is active', true, true)
|
||||
await first
|
||||
await assert.rejects(installer.install('archify', { operationId }), /不能重复执行/)
|
||||
check('a reused operation id cannot run twice', true, true)
|
||||
}
|
||||
|
||||
// ---- download failures --------------------------------------------------------
|
||||
{
|
||||
await useGroup('official-api')
|
||||
const installer = makeInstaller(async (input, init) => {
|
||||
const url = new URL(String(input))
|
||||
check('download uses the official GitHub contents API', url.origin, 'https://api.github.com')
|
||||
check('download pins the complete commit', url.searchParams.get('ref'), catalog[0].commit)
|
||||
check('download requests raw bytes rather than JSON metadata', new Headers(init?.headers).get('accept'), 'application/vnd.github.raw+json')
|
||||
return fakeResponse(archive)
|
||||
})
|
||||
await installer.install('archify', { operationId: randomUUID() })
|
||||
}
|
||||
{
|
||||
await useGroup('api-rate-limit')
|
||||
const installer = makeInstaller(async () => fakeResponse(archive, { status: 403, headers: { 'x-ratelimit-remaining': '0' } }))
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /限额/)
|
||||
check('GitHub API rate limits are actionable failures', installer.state()?.phase, 'failed')
|
||||
}
|
||||
{
|
||||
await useGroup('http-404')
|
||||
const installer = makeInstaller(async () => fakeResponse(archive, { status: 404 }))
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /HTTP 404/)
|
||||
check('a failed download is reported as a failure', installer.state()?.phase, 'failed')
|
||||
}
|
||||
{
|
||||
await useGroup('redirect-host')
|
||||
const installer = makeInstaller(async () => fakeResponse(archive, { status: 302, headers: { location: 'https://evil.example.com/archify.zip' } }))
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /不允许的地址/)
|
||||
check('a redirect to another host is refused', installer.state()?.phase, 'failed')
|
||||
}
|
||||
{
|
||||
await useGroup('redirect-http')
|
||||
const installer = makeInstaller(async () => fakeResponse(archive, { status: 302, headers: { location: 'http://raw.githubusercontent.com/x' } }))
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /不允许的地址/)
|
||||
check('a plain http redirect is refused', installer.state()?.phase, 'failed')
|
||||
}
|
||||
{
|
||||
await useGroup('redirect-loop')
|
||||
const installer = makeInstaller(async () => fakeResponse(archive, { status: 302, headers: { location: 'https://raw.githubusercontent.com/a/b/c' } }))
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /重定向过多/)
|
||||
check('too many redirects are refused', installer.state()?.phase, 'failed')
|
||||
}
|
||||
{
|
||||
await useGroup('disconnect')
|
||||
const installer = makeInstaller(async () => { throw Error('socket hang up') })
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /无法连接 GitHub.*网络或代理/)
|
||||
check('a disconnected transport is reported', installer.state()?.phase, 'failed')
|
||||
}
|
||||
{
|
||||
await useGroup('timeout')
|
||||
const installer = makeInstaller(async (_url, init) => {
|
||||
return stallingResponse(archive.byteLength, archive.subarray(0, 16), init)
|
||||
}, { timeoutMs: 60 })
|
||||
const operationId = randomUUID()
|
||||
const pending = installer.install('archify', { operationId })
|
||||
await assert.rejects(pending, /超时/)
|
||||
check('a stalled download times out', installer.state()?.phase, 'failed')
|
||||
}
|
||||
{
|
||||
await useGroup('oversize')
|
||||
const oversize = new Uint8Array(21 * 1024 * 1024)
|
||||
const installer = makeInstaller(async () => new Response(new ReadableStream({
|
||||
start(controller) { controller.enqueue(oversize); controller.close() }
|
||||
}), { headers: { 'content-length': String(oversize.byteLength) } }))
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /超过 20MB/)
|
||||
check('an oversized package is refused', installer.state()?.phase, 'failed')
|
||||
}
|
||||
{
|
||||
await useGroup('hash')
|
||||
const wrong = archiveFor({ name: 'archify', version: '3.2.0', license: 'MIT' })
|
||||
wrong[0] = wrong[0] ^ 0xff
|
||||
const installer = makeInstaller(async () => fakeResponse(wrong))
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /SHA-256 校验失败/)
|
||||
check('a hash mismatch is refused', installer.state()?.phase, 'failed')
|
||||
}
|
||||
{
|
||||
await useGroup('truncated')
|
||||
const installer = makeInstaller(async () => fakeResponse(archive.subarray(0, archive.byteLength - 3)))
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /大小与目录不符/)
|
||||
check('a truncated package is refused', installer.state()?.phase, 'failed')
|
||||
}
|
||||
|
||||
// ---- cancellation -------------------------------------------------------------
|
||||
{
|
||||
await useGroup('cancel')
|
||||
const installer = makeInstaller(async (_url, init) => stallingResponse(archive.byteLength, archive.subarray(0, 64), init))
|
||||
const operationId = randomUUID()
|
||||
const pending = installer.install('archify', { operationId })
|
||||
await new Promise(resolve => setTimeout(resolve, 40))
|
||||
const cancelled = installer.cancel(operationId)
|
||||
check('a running download can be cancelled', cancelled.cancelled, true)
|
||||
await assert.rejects(pending, /取消/)
|
||||
check('a cancelled install never reports success', installer.state()?.phase, 'cancelled')
|
||||
check('a cancelled install is not reported as failed', installer.state()?.phase === 'failed', false)
|
||||
}
|
||||
|
||||
// ---- progress ordering --------------------------------------------------------
|
||||
{
|
||||
await useGroup('progress')
|
||||
const seen: string[] = []
|
||||
const installer = makeInstaller(async () => fakeResponse(archive))
|
||||
installer.subscribe(progress => seen.push(progress.phase))
|
||||
await installer.install('archify', { operationId: randomUUID() })
|
||||
check('progress reaches downloading, validating and installed',
|
||||
['downloading', 'validating', 'installed'].every(phase => seen.includes(phase)), true)
|
||||
const stamps = seen.length
|
||||
const state = installer.state()
|
||||
check('the install state is a snapshot object', typeof state?.updatedAt, 'number')
|
||||
check('progress events are emitted at least three times', stamps >= 3, true)
|
||||
}
|
||||
|
||||
// ---- shutdown blocks new work and initialize restores it -----------------------
|
||||
{
|
||||
await useGroup('shutdown')
|
||||
const installer = makeInstaller(async () => fakeResponse(archive))
|
||||
await installer.stop()
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /正在停止/)
|
||||
check('a stopped service refuses new installs', true, true)
|
||||
await installer.initialize()
|
||||
const installed = await installer.install('archify', { operationId: randomUUID() })
|
||||
check('initialize restores the service after a stop', installed.name, 'archify')
|
||||
}
|
||||
|
||||
// ---- real skill store: first install, origin and idempotency --------------------
|
||||
{
|
||||
await useGroup('first-install')
|
||||
let downloads = 0
|
||||
const installer = makeInstaller(async () => { downloads += 1; return fakeResponse(archive) })
|
||||
const installed = await installer.install('archify', { operationId: randomUUID() })
|
||||
check('the first install returns the installed skill', installed.name, 'archify')
|
||||
check('the first install records the recommendation origin', installed.origin?.recommendationId, 'archify')
|
||||
check('the first install records the archive hash', installed.origin?.archiveSha256, archiveHash)
|
||||
check('the first install records the commit', installed.origin?.commit, catalog[0].commit)
|
||||
check('the first install is enabled', installed.enabled, true)
|
||||
const listed = await skills.listSkills()
|
||||
check('the installed skill appears in the skill list', listed.length, 1)
|
||||
check('the listed skill keeps the origin', listed[0].origin?.recommendationId, 'archify')
|
||||
check('the first install downloaded exactly once', downloads, 1)
|
||||
|
||||
const again = await installer.install('archify', { operationId: randomUUID() })
|
||||
check('an unchanged same-version install is idempotent', again.origin?.recommendationId, 'archify')
|
||||
check('an unchanged same-version install does not download again', downloads, 1)
|
||||
}
|
||||
|
||||
// ---- conflicting local skill requires explicit overwrite -----------------------
|
||||
{
|
||||
await useGroup('conflict')
|
||||
const installer = makeInstaller(async () => fakeResponse(archive))
|
||||
const local = await makeLocalSkill('archify')
|
||||
await skills.importSkill(local, true)
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /已存在同名技能/)
|
||||
check('a same-name local skill blocks an unconfirmed install', true, true)
|
||||
const replaced = await installer.install('archify', { operationId: randomUUID(), overwrite: true })
|
||||
check('an authorized overwrite installs the recommended package', replaced.origin?.recommendationId, 'archify')
|
||||
}
|
||||
|
||||
// ---- a manual import clears a previous recommendation origin -------------------
|
||||
{
|
||||
await useGroup('manual-clears-origin')
|
||||
const installer = makeInstaller(async () => fakeResponse(archive))
|
||||
const installed = await installer.install('archify', { operationId: randomUUID() })
|
||||
check('the recommended install records an origin', installed.origin?.recommendationId, 'archify')
|
||||
const folder = path.join(userData, 'capabilities', 'skills', 'packages')
|
||||
const entries = await fs.readdir(folder)
|
||||
const managed = entries.find(name => !name.startsWith('.'))!
|
||||
const manual = await makeLocalSkill('archify', 'Manual replacement')
|
||||
await skills.importSkill(manual, true)
|
||||
const listed = await skills.listSkills()
|
||||
check('a manual import keeps the same skill id', listed.length, 1)
|
||||
check('a manual import clears the recommendation origin', listed[0].origin, undefined)
|
||||
check('a manual import is not flagged as locally edited', listed[0].userModified, false)
|
||||
check('a manual import keeps the local description', listed[0].description, 'Manual replacement')
|
||||
void managed
|
||||
}
|
||||
|
||||
// ---- a disabled skill keeps its disabled state across a replacement -----------
|
||||
{
|
||||
await useGroup('disabled-retained')
|
||||
const installer = makeInstaller(async () => fakeResponse(archive))
|
||||
const installed = await installer.install('archify', { operationId: randomUUID() })
|
||||
await skills.setSkillEnabled(installed.id, false)
|
||||
const disabled = (await skills.listSkills())[0]
|
||||
check('the skill can be disabled', disabled.enabled, false)
|
||||
await installer.install('archify', { operationId: randomUUID(), overwrite: true })
|
||||
const after = (await skills.listSkills())[0]
|
||||
check('a replacement keeps the disabled state', after.enabled, false)
|
||||
check('a replacement keeps the recommendation origin', after.origin?.recommendationId, 'archify')
|
||||
}
|
||||
|
||||
// ---- package identity is enforced -------------------------------------------
|
||||
{
|
||||
await useGroup('package-identity')
|
||||
const wrongVersion = archiveFor({ name: 'archify', version: '9.9.9', license: 'MIT' })
|
||||
const versionInstaller = makeInstaller(async () => fakeResponse(wrongVersion),
|
||||
{ catalog: [{ ...catalog[0], sha256: sha256(wrongVersion), expectedBytes: wrongVersion.byteLength }] })
|
||||
await assert.rejects(versionInstaller.install('archify', { operationId: randomUUID() }), /名称、版本或许可证与目录不符/)
|
||||
check('a package with another version is refused', versionInstaller.state()?.phase, 'failed')
|
||||
}
|
||||
{
|
||||
await useGroup('package-license')
|
||||
const wrongLicense = archiveFor({ name: 'archify', version: '3.2.0', license: 'Apache-2.0' })
|
||||
const licenseInstaller = makeInstaller(async () => fakeResponse(wrongLicense),
|
||||
{ catalog: [{ ...catalog[0], sha256: sha256(wrongLicense), expectedBytes: wrongLicense.byteLength }] })
|
||||
await assert.rejects(licenseInstaller.install('archify', { operationId: randomUUID() }), /名称、版本或许可证与目录不符/)
|
||||
check('a package with another license is refused', licenseInstaller.state()?.phase, 'failed')
|
||||
}
|
||||
{
|
||||
await useGroup('package-traversal')
|
||||
const traversal = zipSync({ 'skill/SKILL.md': new TextEncoder().encode('---\nname: archify\ndescription: x\nversion: "3.2.0"\nlicense: MIT\n---\n'),
|
||||
'../escape.txt': new TextEncoder().encode('nope') }, { level: 0 })
|
||||
const traversalInstaller = makeInstaller(async () => fakeResponse(traversal),
|
||||
{ catalog: [{ ...catalog[0], sha256: sha256(traversal), expectedBytes: traversal.byteLength }] })
|
||||
await assert.rejects(traversalInstaller.install('archify', { operationId: randomUUID() }))
|
||||
check('a package with a path traversal entry is refused', traversalInstaller.state()?.phase, 'failed')
|
||||
}
|
||||
{
|
||||
await useGroup('package-no-skill')
|
||||
const noSkill = zipSync({ 'skill/readme.md': new TextEncoder().encode('nothing here') }, { level: 0 })
|
||||
const noSkillInstaller = makeInstaller(async () => fakeResponse(noSkill),
|
||||
{ catalog: [{ ...catalog[0], sha256: sha256(noSkill), expectedBytes: noSkill.byteLength }] })
|
||||
await assert.rejects(noSkillInstaller.install('archify', { operationId: randomUUID() }))
|
||||
check('a package without SKILL.md is refused', noSkillInstaller.state()?.phase, 'failed')
|
||||
}
|
||||
|
||||
// ---- a changed local skill is detected at commit time ------------------------
|
||||
{
|
||||
await useGroup('commit-conflict')
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
let calls = 0
|
||||
const installer = makeInstaller(async () => {
|
||||
calls += 1
|
||||
if (calls > 1) await gate
|
||||
return fakeResponse(archive)
|
||||
})
|
||||
const installed = await installer.install('archify', { operationId: randomUUID() })
|
||||
const skillFolder = path.join(userData, 'capabilities', 'skills', 'packages')
|
||||
const managed = (await fs.readdir(skillFolder)).find(name => !name.startsWith('.'))!
|
||||
await fs.writeFile(path.join(skillFolder, managed, 'SKILL.md'), '---\nname: archify\ndescription: edited locally\n---\nedited\n')
|
||||
const pending = installer.install('archify', { operationId: randomUUID(), overwrite: true })
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
await fs.writeFile(path.join(skillFolder, managed, 'SKILL.md'), '---\nname: archify\ndescription: edited during download\n---\nedited\n')
|
||||
release!()
|
||||
await assert.rejects(pending, /已变化,请重新确认替换/)
|
||||
check('a skill edited during the download is not overwritten silently', true, true)
|
||||
const listed = await skills.listSkills()
|
||||
check('the locally edited skill is preserved', listed[0].description, 'edited during download')
|
||||
void installed
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, phase: 'archive' }))
|
||||
|
||||
// ---- a registry write failure must roll the install back ----------------------
|
||||
{
|
||||
await useGroup('registry-failure')
|
||||
const installer = makeInstaller(async () => fakeResponse(archive))
|
||||
const installed = await installer.install('archify', { operationId: randomUUID() })
|
||||
check('the baseline install succeeds before the injected failure', installed.origin?.recommendationId, 'archify')
|
||||
// A locally edited skill bypasses the idempotent fast path, so the import really runs.
|
||||
const packages = path.join(userData, 'capabilities', 'skills', 'packages')
|
||||
const managed = (await fs.readdir(packages)).find(name => !name.startsWith('.'))!
|
||||
await fs.writeFile(path.join(packages, managed, 'SKILL.md'),
|
||||
'---\nname: archify\ndescription: locally edited before the failure\n---\nedited\n')
|
||||
const registryFile = path.join(userData, 'capabilities', 'skills', 'registry.json')
|
||||
const before = await fs.readFile(registryFile, 'utf8')
|
||||
const realRename = fs.rename
|
||||
let failed = false
|
||||
;(fs as unknown as { rename: typeof fs.rename }).rename = (async (from: string, to: string) => {
|
||||
if (!failed && String(to).endsWith('registry.json')) { failed = true; throw Error('injected registry write failure') }
|
||||
return realRename(from, to)
|
||||
}) as typeof fs.rename
|
||||
try {
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID(), overwrite: true }), /injected registry write failure/)
|
||||
} finally {
|
||||
;(fs as unknown as { rename: typeof fs.rename }).rename = realRename
|
||||
}
|
||||
check('a registry write failure is reported to the caller', failed, true)
|
||||
const after = await fs.readFile(registryFile, 'utf8')
|
||||
check('a failed registry write leaves the previous registry intact', after, before)
|
||||
const listed = await skills.listSkills()
|
||||
check('a failed registry write keeps the previously installed skill', listed.length, 1)
|
||||
check('a failed registry write keeps the previous origin', listed[0].origin?.recommendationId, 'archify')
|
||||
}
|
||||
|
||||
// ---- the commit stage refuses cancellation -----------------------------------
|
||||
{
|
||||
await useGroup('commit-cancel')
|
||||
let commitEntered: (() => void) | undefined
|
||||
const commitGate = new Promise<void>(resolve => { commitEntered = resolve })
|
||||
const installer = service.createRecommendedSkillInstaller({
|
||||
tempRoot, catalog, request: async () => fakeResponse(archive), timeoutMs: 5000,
|
||||
snapshot: skills.getSkillInstallSnapshot,
|
||||
importArchive: async (source, recommendation, overwrite, fingerprint, beforeCommit) => {
|
||||
const real = skills.importRecommendedSkillArchive
|
||||
// Enter the commit phase and hold it, so the cancel lands on an uncommitted-free stage.
|
||||
const pending = real(source, recommendation, overwrite, fingerprint, () => {
|
||||
beforeCommit()
|
||||
commitEntered?.()
|
||||
})
|
||||
await commitGate
|
||||
return pending
|
||||
}
|
||||
})
|
||||
const operationId = randomUUID()
|
||||
const pending = installer.install('archify', { operationId })
|
||||
await commitGate
|
||||
const cancelled = installer.cancel(operationId)
|
||||
check('a cancel during the commit stage is refused', cancelled.cancelled, false)
|
||||
const installed = await pending
|
||||
check('a refused cancel still completes the install', installed.origin?.recommendationId, 'archify')
|
||||
}
|
||||
|
||||
// ---- stop cancels a pre-commit install and later restores the service --------
|
||||
{
|
||||
await useGroup('stop-cancel')
|
||||
let stall = true
|
||||
const installer = makeInstaller(async (_url, init) => stall
|
||||
? stallingResponse(archive.byteLength, archive.subarray(0, 32), init)
|
||||
: fakeResponse(archive))
|
||||
const operationId = randomUUID()
|
||||
const pending = installer.install('archify', { operationId })
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
await installer.stop()
|
||||
await assert.rejects(pending, /取消/)
|
||||
check('stop cancels an install that has not committed', installer.state()?.phase, 'cancelled')
|
||||
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /正在停止/)
|
||||
check('a stopped service refuses a new install', true, true)
|
||||
await installer.initialize()
|
||||
stall = false
|
||||
const restored = await installer.install('archify', { operationId: randomUUID() })
|
||||
check('initialize restores installs after a stop', restored.origin?.recommendationId, 'archify')
|
||||
}
|
||||
|
||||
// ---- the shipped catalog entry must match the real published package ---------
|
||||
{
|
||||
const cached = path.resolve('.tmp/recommended-audit/archify.zip')
|
||||
const override = process.env.YCODE_ARCHIFY_ZIP ? path.resolve(process.env.YCODE_ARCHIFY_ZIP) : undefined
|
||||
let available = true
|
||||
let bytes: Uint8Array | undefined
|
||||
try {
|
||||
bytes = await fs.readFile(override ?? cached)
|
||||
} catch {
|
||||
// Offline by default: the published package is audited by a separate network test.
|
||||
available = false
|
||||
}
|
||||
if (available) {
|
||||
const catalogModule = await import(pathToFileURL(await compile('src/main/capabilities/skill-recommendations.ts', 'catalog.mjs')).href)
|
||||
const entry = catalogModule.SKILL_RECOMMENDATIONS.find((item: { id: string }) => item.id === 'archify')
|
||||
check('the shipped catalog contains the archify recommendation', !!entry, true)
|
||||
check('the shipped catalog hash matches the published package', entry.sha256, sha256(bytes))
|
||||
check('the shipped catalog size matches the published package', entry.expectedBytes, bytes.byteLength)
|
||||
const written = path.join(testRoot, 'real-archify.zip')
|
||||
await fs.writeFile(written, bytes)
|
||||
await useGroup('real-package')
|
||||
const installer = makeInstaller(async () => fakeResponse(bytes),
|
||||
{ catalog: [{ ...catalog[0], sha256: entry.sha256, expectedBytes: entry.expectedBytes }] })
|
||||
const installed = await installer.install('archify', { operationId: randomUUID() })
|
||||
check('the published package installs through the real import path', installed.name, 'archify')
|
||||
check('the published package keeps its recommendation origin', installed.origin?.recommendationId, 'archify')
|
||||
const document = await skills.readSkill(installed.id)
|
||||
check('the published package keeps its resource files', document.files.length > 100, true)
|
||||
} else {
|
||||
console.log(JSON.stringify({ result: 'SKIP', phase: 'real-package', reason: 'published package not present in .tmp' }))
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, phase: 'real-package' }))
|
||||
@@ -0,0 +1,437 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { nextRequestSettings } from '../src/main/agent/request-settings'
|
||||
import { useChatStore } from '../src/renderer/src/stores/chat'
|
||||
import type { ActiveChatRun, SendPayload, SendResult } from '../src/shared/ipc'
|
||||
import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta } from '../src/shared/types'
|
||||
import type { SessionContext } from '../src/shared/workspaces'
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve(value: T): void
|
||||
reject(reason?: unknown): void
|
||||
}
|
||||
|
||||
interface FakeBackend {
|
||||
api: FakeApi
|
||||
sessions: SessionMeta[]
|
||||
messages: Record<string, ChatMessage[]>
|
||||
replaceSession(session: SessionMeta): void
|
||||
}
|
||||
|
||||
interface FakeApi {
|
||||
session: {
|
||||
list(): Promise<SessionMeta[]>
|
||||
create(workspaceId: string | undefined, context?: SessionContext): Promise<SessionMeta>
|
||||
messages(sessionId: string): Promise<ChatMessage[]>
|
||||
updateContext(sessionId: string, patch: SessionContext): Promise<SessionMeta>
|
||||
remove(sessionId: string, options?: { onlyIfEmpty?: boolean }): Promise<void>
|
||||
rename(sessionId: string, title: string): Promise<void>
|
||||
}
|
||||
chat: {
|
||||
send(payload: SendPayload): Promise<SendResult>
|
||||
setSessionVisible(sessionId: string | null): Promise<{ accepted: boolean }>
|
||||
cancel(runId: string): Promise<{ cancelled: boolean }>
|
||||
revert(sessionId: string, messageId: string, options?: unknown): Promise<{ messages: ChatMessage[] }>
|
||||
respond(runId: string, interactionId: string, response: unknown): Promise<{ accepted: boolean }>
|
||||
activeRun(sessionId: string): Promise<ActiveChatRun | null>
|
||||
onEvent(listener: (event: ChatEvent) => void): () => void
|
||||
onStarted(listener: (run: ActiveChatRun) => void): () => void
|
||||
queue: {
|
||||
get(sessionId: string): Promise<{ items: unknown[] }>
|
||||
enqueue(payload: SendPayload): Promise<unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((accept, decline) => {
|
||||
resolve = accept
|
||||
reject = decline
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
async function waitFor(description: string, predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 5))
|
||||
}
|
||||
throw new Error(`timed out waiting for ${description}`)
|
||||
}
|
||||
|
||||
function makeSession(id: string, overrides: Partial<SessionMeta> = {}): SessionMeta {
|
||||
return { id, title: id, createdAt: 1, updatedAt: 1, ...overrides }
|
||||
}
|
||||
|
||||
function makeMessage(id: string, role: ChatMessage['role'], content: string): ChatMessage {
|
||||
return { id, role, content, createdAt: 1, status: 'done', ...(role === 'assistant' ? { tools: [], parts: [] } : {}) }
|
||||
}
|
||||
|
||||
function makeSendResult(
|
||||
runId: string,
|
||||
modelId: string,
|
||||
modelName: string,
|
||||
contextUsage?: ContextUsageSnapshot
|
||||
): SendResult {
|
||||
const userMessage = makeMessage(`${runId}-user`, 'user', 'question')
|
||||
const assistant: ChatMessage = {
|
||||
id: `${runId}-assistant`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tools: [],
|
||||
parts: [],
|
||||
requestId: runId,
|
||||
modelId,
|
||||
modelName,
|
||||
contextUsage,
|
||||
createdAt: 2,
|
||||
turnStartedAt: userMessage.createdAt,
|
||||
status: 'streaming'
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
assistantMessageId: assistant.id,
|
||||
modelId,
|
||||
modelName,
|
||||
userMessage,
|
||||
messages: [userMessage, assistant]
|
||||
}
|
||||
}
|
||||
|
||||
function installFakeBackend(
|
||||
sessionSeeds: SessionMeta[],
|
||||
messageSeeds: Record<string, ChatMessage[]> = {}
|
||||
): FakeBackend {
|
||||
const sessions = sessionSeeds.map((item) => ({ ...item }))
|
||||
const messages = Object.fromEntries(Object.entries(messageSeeds).map(([id, items]) => [id, [...items]]))
|
||||
let createdSequence = 0
|
||||
|
||||
const api: FakeApi = {
|
||||
session: {
|
||||
list: async () => sessions.map((item) => ({ ...item })),
|
||||
create: async (_workspaceId, context = {}) => {
|
||||
const created = makeSession(`created-${++createdSequence}`, context)
|
||||
sessions.unshift(created)
|
||||
messages[created.id] = []
|
||||
return { ...created }
|
||||
},
|
||||
messages: async (sessionId) => [...(messages[sessionId] ?? [])],
|
||||
updateContext: async (sessionId, patch) => {
|
||||
const index = sessions.findIndex((item) => item.id === sessionId)
|
||||
if (index < 0) throw new Error(`unknown session ${sessionId}`)
|
||||
const updated = { ...sessions[index], ...patch }
|
||||
sessions[index] = updated
|
||||
return { ...updated }
|
||||
},
|
||||
remove: async (sessionId, options) => {
|
||||
const index = sessions.findIndex((item) => item.id === sessionId)
|
||||
if (index < 0) return
|
||||
if (options?.onlyIfEmpty && (messages[sessionId]?.length ?? 0) > 0) throw new Error('session is not empty')
|
||||
sessions.splice(index, 1)
|
||||
delete messages[sessionId]
|
||||
},
|
||||
rename: async (sessionId, title) => {
|
||||
const index = sessions.findIndex((item) => item.id === sessionId)
|
||||
if (index >= 0) sessions[index] = { ...sessions[index], title }
|
||||
}
|
||||
},
|
||||
chat: {
|
||||
send: async () => { throw new Error('fake send was not configured') },
|
||||
setSessionVisible: async () => ({ accepted: true }),
|
||||
cancel: async () => ({ cancelled: true }),
|
||||
revert: async () => ({ messages: [] }),
|
||||
respond: async () => ({ accepted: true }),
|
||||
activeRun: async () => null,
|
||||
onEvent: () => () => undefined,
|
||||
onStarted: () => () => undefined,
|
||||
queue: {
|
||||
get: async () => ({ items: [] }),
|
||||
enqueue: async () => undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { ycode: api }
|
||||
})
|
||||
|
||||
return {
|
||||
api,
|
||||
sessions,
|
||||
messages,
|
||||
replaceSession(session) {
|
||||
const index = sessions.findIndex((item) => item.id === session.id)
|
||||
if (index >= 0) sessions[index] = { ...session }
|
||||
else sessions.unshift({ ...session })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetStore(
|
||||
sessions: SessionMeta[],
|
||||
currentSessionId: string | null,
|
||||
messages: ChatMessage[] = [],
|
||||
draftContext: SessionContext = {}
|
||||
): void {
|
||||
useChatStore.setState({
|
||||
sessions,
|
||||
currentSessionId,
|
||||
messages,
|
||||
sessionMessages: currentSessionId ? { [currentSessionId]: messages } : {},
|
||||
sessionUsage: {},
|
||||
sessionContextWindow: {},
|
||||
runs: {},
|
||||
runSequences: {},
|
||||
sendingSessions: [],
|
||||
running: null,
|
||||
sending: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
draftContext,
|
||||
draftId: 0
|
||||
})
|
||||
}
|
||||
|
||||
async function testSerialModelWritesAndFrozenRunningTurn(): Promise<void> {
|
||||
const main = makeSession('main', { modelId: 'model-a', thinkingLevel: 'low' })
|
||||
resetStore([main], main.id)
|
||||
const backend = installFakeBackend([main], { [main.id]: [] })
|
||||
const frozenUsage: ContextUsageSnapshot = {
|
||||
usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 },
|
||||
contextWindow: 64_000,
|
||||
sampledAt: 10,
|
||||
requestId: 'run-a',
|
||||
modelId: 'model-a'
|
||||
}
|
||||
let sentPayload: SendPayload | undefined
|
||||
backend.api.chat.send = async (payload) => {
|
||||
sentPayload = payload
|
||||
return makeSendResult('run-a', 'model-a', 'Model A', frozenUsage)
|
||||
}
|
||||
|
||||
await useChatStore.getState().send('freeze current run')
|
||||
assert.equal(sentPayload?.sessionId, main.id)
|
||||
assert.equal(useChatStore.getState().running?.runId, 'run-a')
|
||||
|
||||
const writes: Array<{ id: string; patch: SessionContext; gate: Deferred<SessionMeta> }> = []
|
||||
backend.api.session.updateContext = (sessionId, patch) => {
|
||||
const gate = deferred<SessionMeta>()
|
||||
writes.push({ id: sessionId, patch, gate })
|
||||
return gate.promise
|
||||
}
|
||||
const resolveWrite = (index: number): void => {
|
||||
const write = writes[index]
|
||||
const current = backend.sessions.find((item) => item.id === write.id) ?? makeSession(write.id)
|
||||
const updated = { ...current, ...write.patch }
|
||||
backend.replaceSession(updated)
|
||||
write.gate.resolve(updated)
|
||||
}
|
||||
|
||||
const pendingA = useChatStore.getState().selectModel('model-a')
|
||||
await waitFor('first serialized model write', () => writes.length === 1)
|
||||
const pendingB = useChatStore.getState().selectModel('model-b')
|
||||
const pendingC = useChatStore.getState().selectModel('model-c')
|
||||
await flushAsync()
|
||||
assert.equal(writes.length, 1, 'model writes must be serialized instead of racing')
|
||||
|
||||
resolveWrite(0)
|
||||
await waitFor('second serialized model write', () => writes.length === 2)
|
||||
resolveWrite(1)
|
||||
await waitFor('third serialized model write', () => writes.length === 3)
|
||||
resolveWrite(2)
|
||||
await Promise.all([pendingA, pendingB, pendingC])
|
||||
|
||||
assert.deepEqual(writes.map((write) => write.patch.modelId), ['model-a', 'model-b', 'model-c'])
|
||||
const state = useChatStore.getState()
|
||||
assert.equal(state.sessions.find((item) => item.id === main.id)?.modelId, 'model-c')
|
||||
const assistant = state.messages.find((message) => message.id === 'run-a-assistant')
|
||||
assert.ok(assistant)
|
||||
assert.equal(assistant.modelId, 'model-a', 'the active assistant must keep its starting model')
|
||||
assert.equal(assistant.modelName, 'Model A')
|
||||
assert.deepEqual(assistant.contextUsage?.usage, frozenUsage.usage)
|
||||
assert.equal(assistant.contextUsage?.contextWindow, 64_000)
|
||||
assert.deepEqual(state.sessionUsage[main.id], frozenUsage.usage)
|
||||
assert.equal(state.sessionContextWindow[main.id], 64_000)
|
||||
}
|
||||
|
||||
async function testSendFailureDoesNotPoisonRetry(): Promise<void> {
|
||||
const main = makeSession('retry-session', { modelId: 'model-a' })
|
||||
resetStore([main], main.id)
|
||||
const backend = installFakeBackend([main], { [main.id]: [] })
|
||||
let calls = 0
|
||||
backend.api.chat.send = async () => {
|
||||
calls++
|
||||
if (calls === 1) throw new Error('mock send failure')
|
||||
return makeSendResult('run-retry', 'model-a', 'Model A')
|
||||
}
|
||||
|
||||
const fakeWindow = (globalThis as typeof globalThis & { window: { ycode: Record<string, unknown> } }).window
|
||||
assert.equal('models' in fakeWindow.ycode, false, 'the fake window must not expose paid model APIs')
|
||||
assert.equal('providers' in fakeWindow.ycode, false, 'the fake window must not expose provider APIs')
|
||||
|
||||
await assert.rejects(useChatStore.getState().send('first attempt'), /mock send failure/)
|
||||
let state = useChatStore.getState()
|
||||
assert.equal(state.sending, false)
|
||||
assert.deepEqual(state.sendingSessions, [])
|
||||
assert.deepEqual(state.messages, [])
|
||||
assert.equal(state.error, 'mock send failure')
|
||||
|
||||
await useChatStore.getState().send('retry attempt')
|
||||
state = useChatStore.getState()
|
||||
assert.equal(calls, 2, 'the retry must reach the send boundary exactly once')
|
||||
assert.equal(state.error, null)
|
||||
assert.equal(state.running?.runId, 'run-retry')
|
||||
assert.equal(state.messages.some((message) => message.id === 'run-retry-assistant'), true)
|
||||
}
|
||||
|
||||
async function testLateResultAndEventStayInOriginalSession(): Promise<void> {
|
||||
const first = makeSession('session-a', { modelId: 'model-a' })
|
||||
const second = makeSession('session-b', { modelId: 'model-b' })
|
||||
resetStore([first, second], first.id)
|
||||
const secondMessage = makeMessage('session-b-history', 'user', 'keep this session')
|
||||
const backend = installFakeBackend([first, second], {
|
||||
[first.id]: [],
|
||||
[second.id]: [secondMessage]
|
||||
})
|
||||
const response = deferred<SendResult>()
|
||||
let sendStarted = false
|
||||
backend.api.chat.send = async () => {
|
||||
sendStarted = true
|
||||
return response.promise
|
||||
}
|
||||
|
||||
const sending = useChatStore.getState().send('slow send')
|
||||
await waitFor('slow send to reach IPC', () => sendStarted)
|
||||
await useChatStore.getState().selectSession(second.id)
|
||||
assert.equal(useChatStore.getState().currentSessionId, second.id)
|
||||
assert.deepEqual(useChatStore.getState().messages, [secondMessage])
|
||||
|
||||
response.resolve(makeSendResult('late-run', 'model-a', 'Model A'))
|
||||
await sending
|
||||
let state = useChatStore.getState()
|
||||
assert.equal(state.currentSessionId, second.id, 'a late send response must not switch sessions')
|
||||
assert.deepEqual(state.messages, [secondMessage], 'a late send response must not replace the selected session messages')
|
||||
assert.equal(state.running, null, 'the original session run must not become the selected session run')
|
||||
assert.equal(state.runs['late-run']?.sessionId, first.id)
|
||||
assert.equal(state.sessionMessages[first.id]?.some((message) => message.id === 'late-run-assistant'), true)
|
||||
|
||||
useChatStore.getState().applyEvent({ runId: 'late-run', sessionId: first.id, type: 'text', delta: 'late text', sequence: 1 })
|
||||
useChatStore.getState().applyEvent({ runId: 'late-run', sessionId: first.id, type: 'done', content: 'late text', tools: [], sequence: 2 })
|
||||
await waitFor('late terminal event to commit', () => {
|
||||
const assistant = useChatStore.getState().sessionMessages[first.id]?.find((message) => message.id === 'late-run-assistant')
|
||||
return assistant?.content === 'late text' && assistant.status === 'done'
|
||||
})
|
||||
|
||||
state = useChatStore.getState()
|
||||
assert.equal(state.currentSessionId, second.id)
|
||||
assert.deepEqual(state.messages, [secondMessage], 'late stream events must not leak into the selected session')
|
||||
const originalAssistant = state.sessionMessages[first.id]?.find((message) => message.id === 'late-run-assistant')
|
||||
assert.equal(originalAssistant?.content, 'late text')
|
||||
assert.equal(originalAssistant?.status, 'done')
|
||||
}
|
||||
|
||||
async function testDraftContextWinsWhileFirstSessionIsCreated(): Promise<void> {
|
||||
resetStore([], null, [], { modelId: 'draft-a', thinkingLevel: 'low' })
|
||||
const backend = installFakeBackend([], {})
|
||||
const createGate = deferred<SessionMeta>()
|
||||
const updateGate = deferred<SessionMeta>()
|
||||
let createContext: SessionContext | undefined
|
||||
let updateCapture: { id: string; patch: SessionContext } | undefined
|
||||
let sendCalls = 0
|
||||
|
||||
backend.api.session.create = async (_workspaceId, context) => {
|
||||
createContext = context ? { ...context } : undefined
|
||||
return createGate.promise
|
||||
}
|
||||
backend.api.session.updateContext = async (sessionId, patch) => {
|
||||
updateCapture = { id: sessionId, patch: { ...patch } }
|
||||
return updateGate.promise
|
||||
}
|
||||
backend.api.chat.send = async () => {
|
||||
sendCalls++
|
||||
return makeSendResult('run-first', 'draft-b', 'Draft B')
|
||||
}
|
||||
|
||||
const sending = useChatStore.getState().send('first message')
|
||||
await waitFor('first session creation', () => createContext !== undefined)
|
||||
assert.deepEqual(createContext, { modelId: 'draft-a', thinkingLevel: 'low' })
|
||||
|
||||
await useChatStore.getState().selectModel('draft-b')
|
||||
await useChatStore.getState().selectThinkingLevel('high')
|
||||
assert.deepEqual(useChatStore.getState().draftContext, { modelId: 'draft-b', thinkingLevel: 'high' })
|
||||
|
||||
const created = makeSession('created-session', { modelId: 'draft-a', thinkingLevel: 'low' })
|
||||
backend.replaceSession(created)
|
||||
createGate.resolve(created)
|
||||
await waitFor('draft context patch after session creation', () => updateCapture !== undefined)
|
||||
assert.deepEqual(updateCapture, {
|
||||
id: 'created-session',
|
||||
patch: { modelId: 'draft-b', thinkingLevel: 'high' }
|
||||
})
|
||||
assert.equal(sendCalls, 0, 'the first send must wait for the newer draft settings to persist')
|
||||
|
||||
const updated = { ...created, ...updateCapture!.patch }
|
||||
backend.replaceSession(updated)
|
||||
updateGate.resolve(updated)
|
||||
await sending
|
||||
|
||||
const state = useChatStore.getState()
|
||||
assert.equal(state.currentSessionId, 'created-session')
|
||||
assert.equal(state.sessions.find((item) => item.id === 'created-session')?.modelId, 'draft-b')
|
||||
assert.equal(state.sessions.find((item) => item.id === 'created-session')?.thinkingLevel, 'high')
|
||||
assert.deepEqual(state.draftContext, { modelId: 'draft-b', thinkingLevel: 'high' })
|
||||
assert.equal(sendCalls, 1)
|
||||
}
|
||||
|
||||
function testNextRequestSettingsWhitelist(): void {
|
||||
assert.deepEqual(nextRequestSettings({ modelId: 'model-next', thinkingLevel: 'high' }), {
|
||||
modelId: 'model-next',
|
||||
thinkingLevel: 'high'
|
||||
})
|
||||
assert.deepEqual(nextRequestSettings({}), {})
|
||||
|
||||
const forbidden: SessionContext[] = [
|
||||
{ permissionMode: 'full-access' },
|
||||
{ cwd: 'C:\\workspace' },
|
||||
{ agentMode: 'plan' },
|
||||
{ projectId: 'project-1' },
|
||||
{ workspaceId: 'workspace-1' },
|
||||
{ expertId: 'expert-1' },
|
||||
{ modelId: 'model-next', permissionMode: 'default-sandbox' }
|
||||
]
|
||||
for (const patch of forbidden) {
|
||||
assert.throws(() => nextRequestSettings(patch), /运行期间只能修改下次发送的模型和思考强度/)
|
||||
}
|
||||
assert.throws(() => nextRequestSettings({ modelId: ' ' }), /模型标识无效/)
|
||||
assert.throws(() => nextRequestSettings({ thinkingLevel: 'invalid' as never }), /无效的思考强度/)
|
||||
}
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error('network access is forbidden in request-settings regression')
|
||||
}) as typeof fetch
|
||||
|
||||
const completed: string[] = []
|
||||
try {
|
||||
await testSerialModelWritesAndFrozenRunningTurn()
|
||||
completed.push('serial model writes + frozen active turn')
|
||||
await testSendFailureDoesNotPoisonRetry()
|
||||
completed.push('send failure retry')
|
||||
await testLateResultAndEventStayInOriginalSession()
|
||||
completed.push('late result/event session isolation')
|
||||
await testDraftContextWinsWhileFirstSessionIsCreated()
|
||||
completed.push('draft settings during first session creation')
|
||||
testNextRequestSettingsWhitelist()
|
||||
completed.push('nextRequestSettings whitelist')
|
||||
console.log(JSON.stringify({ result: 'PASS', tests: completed }, null, 2))
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { build } from 'esbuild'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const tests = [
|
||||
'prompt-queue-regression.ts',
|
||||
'prompt-queue-adversarial-regression.ts',
|
||||
'input-composition-regression.ts',
|
||||
'capability-picker-regression.tsx',
|
||||
'request-settings-regression.ts',
|
||||
'context-usage-regression.ts',
|
||||
'claude-capabilities-regression.ts'
|
||||
]
|
||||
const directory = '.tmp/composer-capabilities-tests'
|
||||
await build({
|
||||
entryPoints: tests.map(file => `scripts/${file}`),
|
||||
outdir: directory, outExtension: { '.js': '.mjs' }, bundle: true,
|
||||
platform: 'node', format: 'esm', packages: 'external', jsx: 'automatic',
|
||||
loader: { '.css': 'empty' }, logLevel: 'warning'
|
||||
})
|
||||
for (const file of tests) {
|
||||
const result = spawnSync(process.execPath, [`${directory}/${file.replace(/\.tsx?$/, '.mjs')}`], {
|
||||
stdio: 'inherit', windowsHide: true, timeout: 120000
|
||||
})
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { build } from 'esbuild'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const test = 'recommended-skills-backend-regression.ts'
|
||||
const directory = '.tmp/recommended-skills-tests'
|
||||
await build({
|
||||
entryPoints: [`scripts/${test}`], outdir: directory, outExtension: { '.js': '.mjs' },
|
||||
bundle: true, platform: 'node', format: 'esm', packages: 'external', logLevel: 'warning'
|
||||
})
|
||||
const result = spawnSync(process.execPath, [`${directory}/${test.replace(/\.tsx?$/, '.mjs')}`], {
|
||||
stdio: 'inherit', windowsHide: true, timeout: 180000
|
||||
})
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
@@ -121,7 +121,8 @@ async function main(): Promise<void> {
|
||||
export const ipcMain = {
|
||||
handle: (name, handler) => state.handlers.set(name, handler)
|
||||
};
|
||||
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
|
||||
export const webContents = { getAllWebContents: () => [] };
|
||||
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }), showSaveDialog: async () => ({ canceled: true }) };
|
||||
export const shell = { openPath: async () => '' };
|
||||
export const powerSaveBlocker = { start: () => 1, stop: () => undefined };
|
||||
export class BrowserWindow {}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Real ComposerControls + Slate in isolated Electron. No model, network or user storage.
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
import { spawn } from 'node:child_process'
|
||||
const require = createRequire(import.meta.url), electron = require('electron')
|
||||
if (typeof electron === 'string') {
|
||||
const directory = fs.mkdtempSync(path.resolve('.tmp/capability-slash-'))
|
||||
const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = spawn(electron, ['--no-sandbox', path.resolve('scripts/test-capability-slash-ui.mjs'), directory], { env, windowsHide: true, stdio: 'inherit' })
|
||||
const timer = setTimeout(() => child.kill(), 90000)
|
||||
process.exitCode = await new Promise(resolve => child.once('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
|
||||
} else {
|
||||
void (async () => {
|
||||
const { app, BrowserWindow } = electron, directory = path.resolve(process.argv.at(-1))
|
||||
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('capability-slash-'))
|
||||
app.setPath('userData', path.join(directory, 'data')); app.disableHardwareAcceleration()
|
||||
const source = file => JSON.stringify(path.resolve(file).replaceAll('\\', '/'))
|
||||
fs.writeFileSync(path.join(directory, 'fixture.tsx'), `import React,{useState}from'react';import{createRoot}from'react-dom/client';
|
||||
import{ComposerControls}from${source('src/renderer/src/workbuddy/composer/ComposerControls')};
|
||||
import{withCapabilitySelection}from${source('src/renderer/src/workbuddy/composer/capability-selection')};
|
||||
import${source('src/renderer/src/styles/tokens.css')};import${source('src/renderer/src/styles/app.css')};
|
||||
window.ycode={resources:{},chat:{}};
|
||||
function Test(){const[state,set]=useState({text:'',skills:[]}),[version,bump]=useState(0);window.__qa={state,setup:s=>{set(s);bump(v=>v+1)},toggle:()=>set(s=>withCapabilitySelection(s,{id:'archify',name:'archify',kind:'skill'},{source:'menu'}))};return <div className="chat-pane cr-theme" style={{padding:40}}><ComposerControls key={version} value={state.text} userContent={state.userContent} skills={state.skills} attachments={state.attachments??[]} selectedCapabilities={state.skills.map(s=>({id:s.id,kind:'skill'}))} capabilityItems={[{id:'archify',name:'archify',kind:'skill'},{id:'existing',name:'existing',kind:'skill'}]} onChange={text=>set(s=>({...s,text}))} onUserContentChange={userContent=>set(s=>({...s,userContent}))} onSelectCapability={(item,intent)=>set(s=>withCapabilitySelection(s,item,intent))} onRemoveSkill={id=>set(s=>({...s,skills:s.skills.filter(v=>v.id!==id)}))} onSend={()=>{throw Error('Unexpected send')}} /></div>}createRoot(document.getElementById('root')).render(<Test/>);`)
|
||||
await require('esbuild').build({ entryPoints: [path.join(directory, 'fixture.tsx')], bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic', outfile: path.join(directory, 'fixture.js'), loader: { '.svg': 'file', '.png': 'file', '.jpg': 'file', '.woff': 'file', '.woff2': 'file', '.ttf': 'file' }, logLevel: 'warning' })
|
||||
fs.writeFileSync(path.join(directory, 'index.html'), '<html data-theme="light"><link rel="stylesheet" href="fixture.css"><body><div id="root"></div><script type="module" src="fixture.js"></script></body></html>')
|
||||
await app.whenReady()
|
||||
const win = new BrowserWindow({ show: false, width: 1100, height: 850, webPreferences: { offscreen: true, backgroundThrottling: false } }), checks = [], errors = []
|
||||
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
|
||||
const read = code => win.webContents.executeJavaScript(code), pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const wait = async code => { for (let n = 0; n < 150; n++) { if (await read(code)) return; await pause(30) } throw Error('Timeout ' + code) }
|
||||
const check = (name, actual, expected = true) => { assert.deepEqual(actual, expected, name); checks.push(name) }
|
||||
const key = async (keyCode, modifiers = []) => { win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await pause(60) }
|
||||
const setup = async state => { await read(`__qa.setup(${JSON.stringify(state)})`); await pause(160); await read('document.querySelector(".wb-composer__input").focus()') }
|
||||
const choose = async (keyboard = false) => {
|
||||
await wait('!!document.querySelector(".yc-composer-slash-picker")')
|
||||
if (keyboard) { await read('document.querySelector(".yc-capability-picker input").focus()'); await key('Enter') }
|
||||
else await read('[...document.querySelectorAll(".yc-capability-picker button")].find(e=>e.textContent.includes("archify")).click()')
|
||||
await wait('!document.querySelector(".yc-composer-slash-picker")'); await pause(200)
|
||||
}
|
||||
try {
|
||||
await win.loadFile(path.join(directory, 'index.html')); await wait('!!window.__qa')
|
||||
await setup({ text: '', skills: [] }); await win.webContents.insertText('/archify'); await choose()
|
||||
check('mouse selection consumes the textarea slash', await read('__qa.state.text'), '')
|
||||
check('structured content has no stale slash', await read('__qa.state.userContent.filter(v=>v.type==="text").map(v=>v.text).join("")'), '')
|
||||
check('one selected skill survives transition into Slate', await read('__qa.state.skills.map(v=>v.id)'), ['archify'])
|
||||
check('the menu stays closed after editor remount', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
|
||||
check('the real rich editor is focused', await read('!!document.activeElement.closest(".cr-reference-editor")'))
|
||||
await setup({ text: '', skills: [] }); await win.webContents.insertText('/'); await choose(true)
|
||||
check('Enter consumes a bare slash without sending', await read('__qa.state.text'), '')
|
||||
await setup({ text: 'left RIGHT', skills: [] })
|
||||
await read('document.querySelector("textarea").setSelectionRange(5,5)'); await win.webContents.insertText('/archify'); await choose()
|
||||
check('selection preserves text after the caret', await read('__qa.state.text'), 'left RIGHT')
|
||||
await setup({ text: 'before ', skills: [{ id: 'existing', name: 'existing' }], userContent: [{ type: 'skill', id: 'existing' }, { type: 'text', text: 'before ' }] })
|
||||
await key('End', ['control']); await win.webContents.insertText('/archify'); await choose(true)
|
||||
check('rich text selection preserves the existing skill', await read('__qa.state.skills.map(v=>v.id)'), ['existing', 'archify'])
|
||||
check('rich text selection preserves surrounding text', await read('__qa.state.text'), 'before ')
|
||||
check('rich text references survive token deletion', await read('__qa.state.userContent.filter(v=>v.type==="skill").map(v=>v.id).sort()'), ['archify', 'existing'])
|
||||
await win.webContents.insertText('next'); await wait('__qa.state.text.includes("next")')
|
||||
check('typing continues at the consumed trigger position', await read('__qa.state.text'), 'before next')
|
||||
await setup({ text: '', skills: [{ id: 'archify', name: 'archify' }] })
|
||||
await key('End', ['control']); await win.webContents.insertText('/archify'); await choose()
|
||||
check('slash selection of an already selected skill never toggles it off', await read('__qa.state.skills.map(v=>v.id)'), ['archify'])
|
||||
await read('__qa.toggle()'); await pause(100)
|
||||
check('the add-menu policy still toggles an existing selection', await read('__qa.state.skills.length'), 0)
|
||||
await setup({ text: 'first\nsecond ', skills: [], attachments: [{ id: 'fixture-file', name: 'keep.txt', mimeType: 'text/plain', size: 4 }], userContent: [{ type: 'attachment', id: 'fixture-file' }, { type: 'text', text: 'first\nsecond ' }] })
|
||||
await key('End', ['control']); await win.webContents.insertText('/archify'); await choose()
|
||||
check('multiline rich text survives slash deletion', await read('__qa.state.text'), 'first\nsecond ')
|
||||
check('attachment metadata survives slash selection', await read('__qa.state.attachments.map(v=>v.id)'), ['fixture-file'])
|
||||
check('attachment inline reference survives slash selection', await read('__qa.state.userContent.some(v=>v.type==="attachment"&&v.id==="fixture-file")'))
|
||||
await setup({ text: '', skills: [] }); await win.webContents.insertText('/'); await wait('!!document.querySelector(".yc-composer-slash-picker")'); await read('document.querySelector(".yc-capability-picker input").focus()'); await key('Escape')
|
||||
await pause(250); check('Escape closes the menu until the trigger is edited', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
|
||||
await win.webContents.insertText('a'); await wait('!!document.querySelector(".yc-composer-slash-picker")')
|
||||
check('editing the dismissed trigger reopens the menu', true)
|
||||
await setup({ text: '', skills: [] })
|
||||
await read('document.querySelector("textarea").dispatchEvent(new CompositionEvent("compositionstart",{bubbles:true}))')
|
||||
await win.webContents.insertText('/'); await pause(150)
|
||||
check('IME composition does not open a slash menu', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
|
||||
await read('document.querySelector("textarea").dispatchEvent(new CompositionEvent("compositionend",{bubbles:true}))'); await wait('!!document.querySelector(".yc-composer-slash-picker")')
|
||||
check('composition completion allows slash detection', true)
|
||||
check('no renderer errors', errors, [])
|
||||
fs.writeFileSync(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, errors }, null, 2)); console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory })); app.exit(0)
|
||||
} catch (error) {
|
||||
fs.writeFileSync(path.join(directory, 'failure.json'), JSON.stringify({ error: String(error.stack ?? error), checks, errors, state: await read('__qa.state'), focus: await read('({active:document.activeElement.outerHTML,selection:document.getSelection()?.toString(),anchor:document.getSelection()?.anchorNode?.parentElement?.outerHTML})'), html: await read('document.body.innerHTML') }, null, 2)); console.error(error); app.exit(1)
|
||||
}
|
||||
})().catch(error => { console.error(error); electron.app.exit(1) })
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/** Real production ConnectorsPage in isolated Electron with a controlled MCP host.
|
||||
* Only the host IPC is faked; the page, styles, icons and helpers are the shipped ones.
|
||||
* No WorkBuddy runtime or original evidence is accessed by this script. */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const electronModule = require('electron')
|
||||
const electron = typeof electronModule === 'object' && electronModule.app ? electronModule : null
|
||||
const root = path.resolve('.'), base = path.join(root, '.tmp')
|
||||
|
||||
if (!electron) {
|
||||
fs.mkdirSync(base, { recursive: true })
|
||||
const output = fs.mkdtempSync(path.join(base, 'connectors-page-')), env = { ...process.env }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = spawn(electronModule, ['--no-sandbox', '--disable-gpu-sandbox', path.resolve('scripts/test-connectors-page.mjs'), output], { env, windowsHide: true, stdio: 'inherit' })
|
||||
const timer = setTimeout(() => child.kill(), 120_000)
|
||||
process.exitCode = await new Promise(resolve => child.once('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
|
||||
} else void (async () => {
|
||||
const { app, BrowserWindow, ipcMain } = electron
|
||||
const output = path.resolve(process.argv.at(-1))
|
||||
assert.ok(path.relative(base, output).startsWith('connectors-page-') && !path.relative(base, output).includes('..'))
|
||||
electron.app.disableHardwareAcceleration()
|
||||
fs.mkdirSync(path.join(output, 'data'), { recursive: true })
|
||||
app.setPath('userData', path.join(output, 'data'))
|
||||
await app.whenReady()
|
||||
|
||||
// The controlled host mirrors the production McpApi contract, including the
|
||||
// approval hash rule and the per-server enable/connect state machine.
|
||||
const calls = [], servers = new Map()
|
||||
let failNextEnable = false
|
||||
const clone = value => JSON.parse(JSON.stringify(value))
|
||||
const makeServer = (id, name, overrides = {}) => ({
|
||||
id, name, transport: 'stdio', command: 'node', args: ['server.mjs'], enabled: false,
|
||||
status: 'disconnected', trusted: true, configHash: `hash-${id}`, tools: [],
|
||||
envKeys: [], headerKeys: [], ...overrides
|
||||
})
|
||||
servers.set('ready', makeServer('ready', 'Ready Server', {
|
||||
enabled: true, status: 'connected', tools: [
|
||||
{ name: 'echo', description: 'Echo text', inputSchema: { type: 'object' }, enabled: true },
|
||||
{ name: 'search', description: 'Find needles', inputSchema: { type: 'object' }, enabled: false }
|
||||
]
|
||||
}))
|
||||
servers.set('untrusted', makeServer('untrusted', 'Unapproved Server', { trusted: false }))
|
||||
servers.set('failing', makeServer('failing', 'Failing Server', { enabled: true, status: 'error', error: 'MCP 连接失败,请检查命令、地址和运行环境' }))
|
||||
|
||||
const host = async (method, args) => {
|
||||
calls.push([method, ...args])
|
||||
if (method === 'list') return clone([...servers.values()])
|
||||
if (method === 'qaFailNextEnable') { failNextEnable = true; return }
|
||||
const server = servers.get(args[0])
|
||||
if (!server) throw Error('MCP 服务不存在')
|
||||
if (method === 'save') {
|
||||
const input = args[0]
|
||||
if (!input?.name?.trim()) throw Error('请输入有效的 MCP 名称')
|
||||
if (input.transport !== 'stdio' && input.transport !== 'http') throw Error('不支持的 MCP 连接类型')
|
||||
if (input.transport === 'stdio' && !input.command?.trim()) throw Error('请输入有效的 MCP 启动命令')
|
||||
const previous = input.id ? servers.get(input.id) : undefined
|
||||
if (input.id && !previous) throw Error('MCP 服务不存在')
|
||||
if (input.id && input.id !== previous.id) throw Error('MCP 服务不存在')
|
||||
const next = makeServer(input.id ?? `saved-${servers.size}`, input.name.trim(), {
|
||||
transport: input.transport, command: input.command, args: input.args ?? [], cwd: input.cwd, url: input.url,
|
||||
enabled: previous?.enabled ?? false, trusted: false, tools: previous?.tools ?? []
|
||||
})
|
||||
servers.set(next.id, next)
|
||||
return clone(next)
|
||||
}
|
||||
if (method === 'trust') {
|
||||
if (server.configHash !== args[1]) throw Error('MCP 配置已变化,请重新查看并批准')
|
||||
server.trusted = true
|
||||
return
|
||||
}
|
||||
if (method === 'connect') {
|
||||
if (!server.enabled) throw Error('MCP 服务已禁用')
|
||||
if (!server.trusted) throw Error('请先批准当前 MCP 配置')
|
||||
server.status = 'connected'; delete server.error
|
||||
return clone(server)
|
||||
}
|
||||
if (method === 'disconnect') { server.status = 'disconnected'; return }
|
||||
if (method === 'setEnabled') {
|
||||
if (failNextEnable) { failNextEnable = false; throw Error('MCP 服务已禁用') }
|
||||
if (args[1] && !server.trusted) throw Error('请先批准当前 MCP 配置')
|
||||
server.enabled = args[1]
|
||||
server.status = args[1] ? 'connected' : 'disconnected'
|
||||
return
|
||||
}
|
||||
if (method === 'setToolEnabled') {
|
||||
const tool = server.tools.find(item => item.name === args[1])
|
||||
if (!tool) throw Error('MCP 工具不存在')
|
||||
tool.enabled = args[2]
|
||||
return
|
||||
}
|
||||
if (method === 'remove') { servers.delete(server.id); return }
|
||||
throw Error('Unknown QA method ' + method)
|
||||
}
|
||||
ipcMain.handle('mcp-qa', async (_event, method, args) => host(method, args))
|
||||
ipcMain.handle('resources-qa', async () => undefined)
|
||||
fs.writeFileSync(path.join(output, 'preload.cjs'),
|
||||
`const{contextBridge,ipcRenderer}=require('electron');contextBridge.exposeInMainWorld('ycode',{mcp:Object.fromEntries(['list','save','trust','connect','disconnect','setEnabled','setToolEnabled','remove','qaFailNextEnable'].map(method=>[method,(...args)=>ipcRenderer.invoke('mcp-qa',method,args)])),resources:{openExternal:async()=>undefined}});`)
|
||||
|
||||
const location = file => JSON.stringify(path.join(root, file).replaceAll('\\', '/'))
|
||||
fs.writeFileSync(path.join(output, 'fixture.tsx'), `
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import Center from ${location('src/renderer/src/capabilities/CapabilityCenter')}
|
||||
import ${location('src/renderer/src/styles/tokens.css')}
|
||||
import ${location('src/renderer/src/styles/app.css')}
|
||||
createRoot(document.getElementById('root')!).render(<div className="chat-pane cr-theme" style={{ height: '100vh' }}><Center initialTab="connectors" onBackToChat={() => {}} /></div>)
|
||||
`)
|
||||
await require('esbuild').build({
|
||||
entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic',
|
||||
outfile: path.join(output, 'fixture.js'), loader: { '.svg': 'file', '.png': 'file', '.woff': 'file', '.woff2': 'file', '.ttf': 'file' },
|
||||
logLevel: 'warning'
|
||||
})
|
||||
fs.writeFileSync(path.join(output, 'index.html'),
|
||||
'<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><style>html,body,#root{width:100%;height:100%;margin:0}</style></head><body><div id="root"></div><script type="module" src="fixture.js"></script></body></html>')
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1052, height: 901, useContentSize: true, frame: false, show: false,
|
||||
webPreferences: { preload: path.join(output, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false }
|
||||
})
|
||||
const errors = [], checks = []
|
||||
win.webContents.on('console-message', (_event, level, message) => { if (level === 3 || level === 'error') errors.push(String(message)) })
|
||||
const read = code => win.webContents.executeJavaScript(code)
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const check = (name, actual, expected) => {
|
||||
if (typeof expected === 'function') assert.ok(expected(actual), name + ': ' + JSON.stringify(actual))
|
||||
else assert.deepEqual(actual, expected, name)
|
||||
checks.push(name)
|
||||
}
|
||||
const wait = async code => {
|
||||
for (let index = 0; index < 200; index++) { if (await read(code)) return; await pause(50) }
|
||||
throw Error('Timeout ' + code)
|
||||
}
|
||||
const click = async selector => {
|
||||
const point = await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing '+${JSON.stringify(selector)});const r=e.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`)
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
|
||||
await pause(120)
|
||||
}
|
||||
const clickText = async (selector, label) => {
|
||||
const match = await read(`(()=>{const e=[...document.querySelectorAll(${JSON.stringify(selector)})].find(e=>e.textContent.trim()===${JSON.stringify(label)});if(!e)throw Error('Missing text '+${JSON.stringify(label)});e.dataset.qaTarget='true';return '[data-qa-target="true"]'})()`)
|
||||
await click(match)
|
||||
await read(`document.querySelector(${JSON.stringify(match)})?.removeAttribute('data-qa-target')`)
|
||||
}
|
||||
const capture = async name => {
|
||||
win.webContents.invalidate()
|
||||
// A hidden window keeps the previous frame, so warm the paint first, give
|
||||
// the compositor a frame, and keep the second capture.
|
||||
await win.webContents.capturePage()
|
||||
await pause(220)
|
||||
const image = await win.webContents.capturePage()
|
||||
fs.writeFileSync(path.join(output, name + '.png'), image.toPNG())
|
||||
return !image.isEmpty()
|
||||
}
|
||||
try {
|
||||
await win.loadFile(path.join(output, 'index.html'))
|
||||
// An inactive window still composites real frames; a hidden one does not.
|
||||
win.showInactive()
|
||||
await wait('!!document.querySelector(".ycode-connectors")')
|
||||
|
||||
check('the maintenance placeholder is gone', await read('!!document.querySelector("[data-capability-maintenance]")'), false)
|
||||
check('the page renders the real connector list', await read('document.querySelectorAll(".connector-card").length'), 3)
|
||||
check('the connector card names come from the host', await read('[...document.querySelectorAll(".connector-card-name")].map(e=>e.textContent)'), ['Ready Server', 'Unapproved Server', 'Failing Server'])
|
||||
check('the toolbar keeps the shared search and the custom connector action', await read('[!!document.querySelector(".connectors-toolbar .ec-search-wrapper"), !!document.querySelector(".connector-topbar-action-btn")]'), [true, true])
|
||||
await capture('connector-list')
|
||||
|
||||
check('the detail overlay opens for a selected connector', await (async () => {
|
||||
await click('.connector-card')
|
||||
await wait('!!document.querySelector(".connector-detail-modal")')
|
||||
return read('document.querySelector(".connector-detail-name").textContent')
|
||||
})(), 'Ready Server')
|
||||
check('the detail overlay lists the real tool count', await read('document.querySelector(".connector-detail-desc").textContent'), '已连接 · 2 个工具')
|
||||
await capture('connector-detail')
|
||||
await click('.connector-detail-close')
|
||||
check('the detail overlay closes', await read('!!document.querySelector(".connector-detail-modal")'), false)
|
||||
|
||||
await click('.connector-topbar-action-btn')
|
||||
await wait('!!document.querySelector(".mcp-modal")')
|
||||
check('the management modal opens with the real server list', await read('document.querySelectorAll(".mcp-item").length'), 3)
|
||||
check('the panel reports the enabled count from the host', await read('document.querySelector(".mcp-panel-status-summary").textContent'), '2 启用 · 1 失败')
|
||||
check('an unapproved server shows the approval notice', await read('!!document.querySelector(".mcp-item-approval")'), true)
|
||||
check('an unapproved server cannot be enabled by the toggle', await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Unapproved Server'));return item.querySelector('.mcp-toggle').disabled})()`), true)
|
||||
await capture('mcp-panel')
|
||||
|
||||
await clickText('.mcp-item-approval-btn', '批准配置')
|
||||
await wait(`!document.querySelector('.mcp-item-approval')`)
|
||||
check('approving the configuration reaches the host', calls.some(call => call[0] === 'trust' && call[1] === 'untrusted'), true)
|
||||
check('approving also enables the approved server', await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Unapproved Server'));return item.querySelector('.mcp-toggle').getAttribute('aria-checked')})()`), 'true')
|
||||
|
||||
await click('.mcp-item-header')
|
||||
await wait('!!document.querySelector(".mcp-item-tools")')
|
||||
check('the expanded server lists its real tools', await read('[...document.querySelectorAll(".mcp-item-tool")].map(e=>e.textContent)'), ['echo', 'search'])
|
||||
await clickText('.mcp-item-tool', 'search')
|
||||
await wait(`!document.querySelector('.mcp-item-tool')?.classList.contains('mcp-item-tool--disabled')`)
|
||||
check('toggling a tool reaches the host', calls.some(call => call[0] === 'setToolEnabled' && call[1] === 'ready' && call[2] === 'search'), true)
|
||||
|
||||
check('a failed server shows the real host error', await read('!!document.querySelector(".mcp-item-error")'), true)
|
||||
await click('.mcp-item-error-link')
|
||||
await wait(`!document.querySelector('.mcp-item-error')`)
|
||||
check('the retry action reconnects the failed server', calls.some(call => call[0] === 'connect' && call[1] === 'failing'), true)
|
||||
|
||||
await clickText('.mcp-item-action', '编辑')
|
||||
await wait('!!document.querySelector(".mcp-config-editor")')
|
||||
check('the config editor opens with the real server document', await read(`(()=>{const ta=document.querySelector('.mcp-config-textarea');const editor=document.querySelector('.monaco-editor');return ta?ta.value:(document.querySelector('.mcp-config-editor-monaco-wrapper')?editor?'monaco':'loading':'')})()`),
|
||||
value => value === 'loading' || value === 'monaco' || JSON.parse(value).mcpServers.ReadyServer)
|
||||
await capture('mcp-editor')
|
||||
await clickText('.mcp-config-editor-header-actions button', '取消')
|
||||
await wait('!document.querySelector(".mcp-config-editor")')
|
||||
|
||||
await clickText('.mcp-modal-header-right button', '添加 MCP')
|
||||
await wait('!!document.querySelector(".mcp-config-editor")')
|
||||
check('the add editor starts from an empty stdio document', await read(`(()=>{const ta=document.querySelector('.mcp-config-textarea');if(ta)return JSON.parse(ta.value).mcpServers;const w=document.querySelector('.mcp-config-editor-monaco-wrapper');return w?'loading-or-monaco':''})()`),
|
||||
value => value === 'loading-or-monaco' || !!value['my-mcp'])
|
||||
await clickText('.mcp-config-editor-header-actions button', '取消')
|
||||
await wait('!document.querySelector(".mcp-config-editor")')
|
||||
|
||||
// The card action opens the modal with exactly that server expanded.
|
||||
await click('.mcp-modal-close')
|
||||
await wait('!document.querySelector(".mcp-modal")')
|
||||
await read(`(()=>{const card=[...document.querySelectorAll('.connector-card')].find(e=>e.textContent.includes('Failing Server'));card.querySelector('.connector-connect-btn').click();return true})()`)
|
||||
await wait('!!document.querySelector(".mcp-modal")')
|
||||
await wait(`!![...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Failing Server'))?.querySelector('.mcp-item-action[aria-label="删除 Failing Server"]')`)
|
||||
await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Failing Server'));item.querySelector('.mcp-item-action[aria-label="删除 Failing Server"]').click();return true})()`)
|
||||
await wait('!!document.querySelector(".yc-mcp-confirm")')
|
||||
check('the delete confirmation names the real server', await read('document.querySelector(".yc-mcp-confirm").textContent.includes("Failing Server")'), true)
|
||||
await clickText('.yc-mcp-confirm button', '取消')
|
||||
await wait('!document.querySelector(".yc-mcp-confirm")')
|
||||
check('cancelling the delete keeps the server', await read('document.querySelectorAll(".mcp-item").length'), 3)
|
||||
|
||||
const setSearch = async value => {
|
||||
await read(`(()=>{const e=document.querySelector('.cb-search-input__input');if(!e)throw Error('missing MCP search input');
|
||||
const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;setter.call(e,${JSON.stringify(value)});
|
||||
e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
|
||||
await pause(200)
|
||||
}
|
||||
await setSearch('fail')
|
||||
await pause(200)
|
||||
check('the management search filters the real list', await read('document.querySelectorAll(".mcp-item").length'), 1)
|
||||
await setSearch('')
|
||||
|
||||
await click('.mcp-modal-close')
|
||||
await wait('!document.querySelector(".mcp-modal")')
|
||||
check('closing the management modal returns to the card list', await read('document.querySelectorAll(".connector-card").length'), 3)
|
||||
|
||||
// A rejected host call must stay visible inside the management modal.
|
||||
await read(`window.ycode.mcp.qaFailNextEnable()`)
|
||||
await read(`(()=>{document.querySelector('.connector-topbar-action-btn').click();return true})()`)
|
||||
await wait('!!document.querySelector(".mcp-modal")')
|
||||
await wait(`!![...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Ready Server'))`)
|
||||
await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Ready Server'));item.querySelector('.mcp-toggle').click();return true})()`)
|
||||
await wait('!!document.querySelector(".mcp-modal [role=alert]")')
|
||||
check('a rejected action reports the real host error inside the modal',
|
||||
await read('document.querySelector(".mcp-modal [role=alert]").textContent'),
|
||||
value => typeof value === 'string' && value.includes('MCP 服务已禁用'))
|
||||
check('the error banner is not only behind the modal overlay',
|
||||
await read(`(()=>{const el=document.querySelector('.mcp-modal [role=alert]');const r=el.getBoundingClientRect();return r.width>0&&r.height>0})()`), true)
|
||||
check('the error banner is painted inside the visible modal body',
|
||||
await read(`(()=>{const el=document.querySelector('.mcp-modal [role=alert]');const r=el.getBoundingClientRect();
|
||||
const body=document.querySelector('.mcp-modal-body').getBoundingClientRect();
|
||||
const s=getComputedStyle(el);
|
||||
return {text:el.textContent,top:Math.round(r.top),bottom:Math.round(r.bottom),bodyTop:Math.round(body.top),
|
||||
bodyBottom:Math.round(body.bottom),color:s.color,background:s.backgroundColor,visible:s.visibility,opacity:s.opacity}})()`),
|
||||
value => value.text.includes('MCP 服务已禁用') && value.top >= value.bodyTop && value.bottom <= value.bodyBottom + 1 &&
|
||||
value.color !== 'rgba(0, 0, 0, 0)' && value.background !== 'rgba(0, 0, 0, 0)' && value.visible === 'visible' && value.opacity === '1')
|
||||
await capture('mcp-error')
|
||||
|
||||
check('no renderer errors', errors, [])
|
||||
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({
|
||||
result: 'PASS', checks,
|
||||
calls,
|
||||
limits: 'Controlled MCP host in isolated Electron; the shipped page, helpers, icons and styles are used. Not WorkBuddy pixel acceptance and not a real MCP server connection.'
|
||||
}, null, 2))
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, output }))
|
||||
win.destroy(); app.exit(0)
|
||||
} catch (error) {
|
||||
await capture('failure')
|
||||
fs.writeFileSync(path.join(output, 'failure.json'), JSON.stringify({ error: String(error), checks, errors, calls }, null, 2))
|
||||
console.error(error)
|
||||
win.destroy(); app.exit(1)
|
||||
}
|
||||
})().catch(error => { console.error(error); electron.app.exit(1) })
|
||||
@@ -0,0 +1,223 @@
|
||||
// Real Archify package audit for the recommended-skill installer.
|
||||
// Downloads the pinned archive, verifies its digest, audits the ZIP structure and
|
||||
// metadata, then imports it through the production skills module against an
|
||||
// isolated Electron userData directory. No package script is ever executed.
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import fsSync from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import { unzipSync } from 'fflate'
|
||||
|
||||
const COMMIT = '25cf37080fc8b1dbe758a4efc3935b55f3327083'
|
||||
const REPOSITORY = 'tt-a1i/archify'
|
||||
const PACKAGE_PATH = 'archify.zip'
|
||||
const EXPECTED_SHA256 = '70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5'
|
||||
const EXPECTED_BYTES = 5271959
|
||||
const DOWNLOAD_URL = `https://api.github.com/repos/${REPOSITORY}/contents/${PACKAGE_PATH}?ref=${COMMIT}`
|
||||
const EXPECTED_SKILL_NAME = 'archify'
|
||||
const EXPECTED_SKILL_VERSION = '3.2'
|
||||
const EXPECTED_PACKAGE_VERSION = '3.2.0'
|
||||
const EXPECTED_LICENSE = 'MIT'
|
||||
const EXPECTED_FILE_COUNT = 200
|
||||
const EXPECTED_EXPANDED_BYTES = 17812933
|
||||
|
||||
const repository = path.resolve('.')
|
||||
const testRoot = await fs.mkdtemp(path.resolve('.tmp/archify-package-'))
|
||||
const relative = path.relative(path.resolve('.tmp'), testRoot)
|
||||
assert.ok(relative.startsWith('archify-package-') && !relative.includes('..') && !path.isAbsolute(relative))
|
||||
const userData = path.join(testRoot, 'data')
|
||||
const workspace = path.join(testRoot, 'workspace')
|
||||
await Promise.all([fs.mkdir(userData, { recursive: true }), fs.mkdir(workspace, { recursive: true })])
|
||||
|
||||
const checks = []
|
||||
const check = (name, actual, expected) => {
|
||||
assert.deepEqual(actual, expected, name)
|
||||
checks.push(name)
|
||||
}
|
||||
const finding = (name, detail) => checks.push(`${name} :: ${detail}`)
|
||||
const note = (name, detail) => checks.push(`${name} :: ${detail}`)
|
||||
|
||||
async function download() {
|
||||
const local = process.env.YCODE_ARCHIFY_ZIP
|
||||
if (local) {
|
||||
const bytes = await fs.readFile(path.resolve(local))
|
||||
note('archive source', `local file ${path.resolve(local)}`)
|
||||
return bytes
|
||||
}
|
||||
const response = await fetch(DOWNLOAD_URL, { redirect: 'error', signal: AbortSignal.timeout(120000),
|
||||
headers: { Accept: 'application/vnd.github.raw+json', 'X-GitHub-Api-Version': '2022-11-28' } })
|
||||
assert.equal(response.status, 200, 'pinned archive download must succeed')
|
||||
const bytes = Buffer.from(await response.arrayBuffer())
|
||||
note('archive source', DOWNLOAD_URL)
|
||||
return bytes
|
||||
}
|
||||
|
||||
const bytes = await download()
|
||||
const archivePath = path.join(testRoot, PACKAGE_PATH)
|
||||
await fs.writeFile(archivePath, bytes)
|
||||
|
||||
check('pinned archive byte length matches the audited package', bytes.length, EXPECTED_BYTES)
|
||||
const actualSha256 = crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
check('pinned archive digest matches the audited package', actualSha256, EXPECTED_SHA256)
|
||||
check('archive is served from the pinned commit URL', DOWNLOAD_URL.includes(COMMIT), true)
|
||||
|
||||
const entries = unzipSync(bytes)
|
||||
const names = Object.keys(entries)
|
||||
const expandedBytes = names.reduce((sum, name) => sum + entries[name].length, 0)
|
||||
check('archive contains the audited file count', names.length, EXPECTED_FILE_COUNT)
|
||||
check('archive expands to the audited size', expandedBytes, EXPECTED_EXPANDED_BYTES)
|
||||
check('every archive entry lives under a single skill directory',
|
||||
names.every(name => name.startsWith('archify/')), true)
|
||||
check('archive has exactly one SKILL.md at the package root',
|
||||
names.filter(name => name === 'archify/SKILL.md').length, 1)
|
||||
check('archive contains no nested second skill',
|
||||
names.filter(name => name.split('/').filter(part => part === 'SKILL.md').length > 1).length, 0)
|
||||
|
||||
const text = (name) => Buffer.from(entries[name]).toString('utf8')
|
||||
const skillMarkdown = text('archify/SKILL.md')
|
||||
const frontMatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMarkdown)
|
||||
assert.ok(frontMatter, 'SKILL.md must carry YAML front matter')
|
||||
const skillMeta = frontMatter[1]
|
||||
check('SKILL.md declares the audited skill name', /(^|\n)name:\s*archify\s*(\n|$)/.test(skillMeta), true)
|
||||
check('SKILL.md declares the audited license', /(^|\n)license:\s*MIT\s*(\n|$)/.test(skillMeta), true)
|
||||
check('SKILL.md declares the audited metadata version', /version:\s*"?3\.2"?/.test(skillMeta), true)
|
||||
check('SKILL.md declares the audited author', /author:\s*tt-a1i/.test(skillMeta), true)
|
||||
|
||||
const packageJson = JSON.parse(text('archify/package.json'))
|
||||
check('package.json declares the audited package name', packageJson.name, EXPECTED_SKILL_NAME)
|
||||
check('package.json declares the audited package version', packageJson.version, EXPECTED_PACKAGE_VERSION)
|
||||
check('package.json declares the audited license', packageJson.license, EXPECTED_LICENSE)
|
||||
check('package.json keeps the package private', packageJson.private, true)
|
||||
const release = JSON.parse(text('archify/skill-release.json'))
|
||||
check('release manifest declares the audited version', release.version, EXPECTED_PACKAGE_VERSION)
|
||||
check('release manifest points at the audited repository', release.source.repository, `https://github.com/${REPOSITORY}`)
|
||||
check('release manifest exposes an update URL that the host must not auto-follow',
|
||||
typeof release.updateManifestUrl === 'string' && release.updateManifestUrl.length > 0, true)
|
||||
check('archive ships the MIT license text', text('archify/LICENSE').includes('MIT License'), true)
|
||||
check('archive ships the third-party notices', names.includes('archify/THIRD_PARTY_NOTICES.md'), true)
|
||||
|
||||
const scriptEntries = names.filter(name => /\.(mjs|cjs|js|ts|sh|ps1|bat|cmd)$/i.test(name))
|
||||
check('archive ships executable helper scripts as inert files', scriptEntries.length > 0, true)
|
||||
note('script entries (not executed)', `${scriptEntries.length} files, e.g. ${scriptEntries.slice(0, 3).join(', ')}`)
|
||||
|
||||
// Import through the production skills module. Only Electron's userData path is
|
||||
// redirected; every other code path is the shipped implementation.
|
||||
const bundled = path.join(testRoot, 'skills-archify.mjs')
|
||||
await build({
|
||||
entryPoints: [path.resolve('src/main/capabilities/skills.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
outfile: bundled,
|
||||
logLevel: 'warning',
|
||||
banner: { js: "import { createRequire as createArchifyTestRequire } from 'node:module';const require=createArchifyTestRequire(import.meta.url);" },
|
||||
plugins: [{
|
||||
name: 'isolated-electron-path',
|
||||
setup(context) {
|
||||
context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'archify-test' }))
|
||||
context.onLoad({ filter: /.*/, namespace: 'archify-test' }, () => ({
|
||||
contents: `export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return ${JSON.stringify(userData)}}};export const safeStorage={isEncryptionAvailable:()=>false,encryptString:(v)=>Buffer.from(String(v),'utf8'),decryptString:(v)=>Buffer.from(v).toString('utf8')}`
|
||||
}))
|
||||
}
|
||||
}, {
|
||||
name: 'record-child-process',
|
||||
setup(context) {
|
||||
context.onResolve({ filter: /^(node:)?child_process$/ }, () => ({ path: 'child-process', namespace: 'archify-test' }))
|
||||
context.onLoad({ filter: /.*/, namespace: 'archify-test' }, () => ({
|
||||
contents: `const record=(name)=>globalThis.__archifyChildProcesses.push(name);export const spawn=(...a)=>{record('spawn');throw Error('child process blocked in package audit')};export const spawnSync=(...a)=>{record('spawnSync');throw Error('child process blocked in package audit')};export const exec=(...a)=>{record('exec');throw Error('child process blocked in package audit')};export const execSync=(...a)=>{record('execSync');throw Error('child process blocked in package audit')};export const fork=(...a)=>{record('fork');throw Error('child process blocked in package audit')};export default{spawn,spawnSync,exec,execSync,fork};`
|
||||
}))
|
||||
}
|
||||
}]
|
||||
})
|
||||
globalThis.__archifyChildProcesses = []
|
||||
const bundleSource = await fs.readFile(bundled, 'utf8')
|
||||
check('the production skills module does not spawn a child process during import',
|
||||
globalThis.__archifyChildProcesses.length, 0)
|
||||
|
||||
const api = await import(pathToFileURL(bundled).href)
|
||||
check('the isolated skill library starts empty', await api.listSkills(), [])
|
||||
|
||||
const imported = await api.importSkill(archivePath)
|
||||
check('the pinned archive imports as the audited skill name', imported.name, EXPECTED_SKILL_NAME)
|
||||
check('the imported skill is a user-scoped import', imported.scope, 'user')
|
||||
check('the imported skill is enabled by default', imported.enabled, true)
|
||||
check('the imported skill is not flagged as locally modified', imported.userModified, false)
|
||||
// Archify nests its version under `metadata:` in the YAML front matter. The host
|
||||
// parser only reads a top-level `version`, so record what the host can see and
|
||||
// keep the package manifest as the authoritative version for validation.
|
||||
finding('host-visible skill version', `imported.version=${String(imported.version)} (front matter nests version under metadata:; expected ${EXPECTED_SKILL_VERSION})`)
|
||||
check('the package manifest carries the authoritative version', packageJson.version, EXPECTED_PACKAGE_VERSION)
|
||||
check('the imported skill keeps the audited license text in its body',
|
||||
(await api.readSkill(imported.id)).content.includes('license: MIT'), true)
|
||||
|
||||
const installedPath = path.resolve(imported.path)
|
||||
check('the installed package stays inside the isolated userData directory',
|
||||
installedPath.startsWith(path.resolve(userData) + path.sep), true)
|
||||
check('the installed package never lands in the real user skill library',
|
||||
installedPath.includes(path.join('capabilities', 'skills', 'packages')), true)
|
||||
|
||||
const document = await api.readSkill(imported.id)
|
||||
check('the imported skill exposes every audited file', document.files.length, EXPECTED_FILE_COUNT)
|
||||
// The host strips the single enclosing directory, so installed paths are
|
||||
// relative to the skill root rather than to the archive root.
|
||||
const installedNames = names.map(name => name.replace(/^archify\//, ''))
|
||||
check('the imported file list is relative to the skill root',
|
||||
document.files.every(name => !name.startsWith('archify/')), true)
|
||||
check('the imported file list is sorted and complete',
|
||||
document.files.join('|') === installedNames.slice().sort().join('|'), true)
|
||||
check('the imported skill body is the audited SKILL.md',
|
||||
document.content === skillMarkdown, true)
|
||||
|
||||
const listed = (await api.listSkills(workspace)).find(item => item.id === imported.id)
|
||||
check('the installed skill is listed for the workspace', listed?.name, EXPECTED_SKILL_NAME)
|
||||
check('the installed skill stays unmodified after import', listed?.userModified, false)
|
||||
|
||||
const reference = await api.readSkillResource(imported.id, 'references/repository-authoring.md', workspace)
|
||||
check('a shipped reference resource is readable through the host', reference.length > 0, true)
|
||||
await assert.rejects(api.readSkillResource(imported.id, '../escape.md', workspace), /越界|非法|不安全/)
|
||||
checks.push('resource reads reject traversal outside the package')
|
||||
|
||||
const context = await api.buildSkillContext([imported.id], workspace)
|
||||
check('the explicit skill context carries the real instructions', context.includes('<skill id='), true)
|
||||
check('the explicit skill context lists the package resources', context.includes('bin/archify.mjs'), true)
|
||||
|
||||
// Nothing in the package may run during installation.
|
||||
const workspaceEntries = await fs.readdir(workspace)
|
||||
check('importing the package creates no workspace output', workspaceEntries, [])
|
||||
check('importing the package creates no generated artifacts directory',
|
||||
fsSync.existsSync(path.join(workspace, '.archify')), false)
|
||||
const exampleFiles = []
|
||||
const collectExamples = async (folder, prefix) => {
|
||||
for (const item of await fs.readdir(folder, { withFileTypes: true })) {
|
||||
if (item.isDirectory()) await collectExamples(path.join(folder, item.name), `${prefix}${item.name}/`)
|
||||
else exampleFiles.push(`${prefix}${item.name}`)
|
||||
}
|
||||
}
|
||||
await collectExamples(path.join(installedPath, 'examples'), '')
|
||||
check('importing the package leaves the shipped examples untouched',
|
||||
exampleFiles.length, names.filter(name => name.startsWith('archify/examples/')).length)
|
||||
|
||||
await fs.writeFile(path.join(testRoot, 'report.json'), JSON.stringify({
|
||||
result: 'PASS',
|
||||
scope: 'Real pinned GitHub archive; digest and ZIP structure audited; import through the production skills module against an isolated Electron userData directory. No package script is executed.',
|
||||
source: { repository: REPOSITORY, commit: COMMIT, packagePath: PACKAGE_PATH, downloadUrl: DOWNLOAD_URL },
|
||||
archive: { bytes: bytes.length, sha256: actualSha256, fileCount: names.length, expandedBytes },
|
||||
metadata: {
|
||||
skillName: EXPECTED_SKILL_NAME,
|
||||
skillVersion: imported.version,
|
||||
packageVersion: packageJson.version,
|
||||
license: packageJson.license,
|
||||
author: 'tt-a1i',
|
||||
releaseVersion: release.version
|
||||
},
|
||||
checks,
|
||||
limitations: [
|
||||
'The digest and metadata confirm the bytes match the audited package; they are not a security audit of the skill code.',
|
||||
'Package scripts are shipped as inert files and were never executed, so runtime dependencies and rendering behavior remain unverified.',
|
||||
'The update manifest URL inside the package is reported only; the host must not auto-follow it.'
|
||||
]
|
||||
}, null, 2))
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory: testRoot, sha256: actualSha256 }))
|
||||
@@ -0,0 +1,382 @@
|
||||
/** Isolated Electron fixture for the recommended-skill page.
|
||||
* The host is fully controlled: no network, no real skill storage, no user data. */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const electronModule = require('electron')
|
||||
const electron = typeof electronModule === 'object' && electronModule.app ? electronModule : null
|
||||
const repository = path.resolve('.'), base = path.join(repository, '.tmp')
|
||||
|
||||
if (!electron) {
|
||||
fs.mkdirSync(base, { recursive: true })
|
||||
const output = fs.mkdtempSync(path.join(base, 'recommended-skills-ui-'))
|
||||
const env = { ...process.env }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
delete env.ELECTRON_RENDERER_URL
|
||||
const child = spawn(electronModule, ['--no-sandbox', '--disable-gpu-sandbox', path.resolve('scripts/test-recommended-skills-ui.mjs'), output], { stdio: 'inherit', windowsHide: true, env })
|
||||
const timer = setTimeout(() => child.kill(), 120000)
|
||||
process.exitCode = await new Promise(resolve => child.on('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
|
||||
} else {
|
||||
void (async () => {
|
||||
const { app, BrowserWindow, ipcMain } = electron
|
||||
const output = path.resolve(process.argv.at(-1)), relative = path.relative(base, output)
|
||||
assert.ok(relative.startsWith('recommended-skills-ui-') && !relative.includes('..') && !path.isAbsolute(relative))
|
||||
const userData = path.join(output, 'data')
|
||||
fs.mkdirSync(userData, { recursive: true })
|
||||
app.setPath('userData', userData)
|
||||
const checks = [], errors = [], calls = [], screenshots = []
|
||||
|
||||
const recommendation = {
|
||||
id: 'archify',
|
||||
name: 'Archify',
|
||||
description: '生成可交互的架构图、流程图和一页式说明',
|
||||
author: 'tt-a1i',
|
||||
license: 'MIT',
|
||||
repository: 'tt-a1i/archify',
|
||||
version: '3.2.0',
|
||||
commit: '25cf37080fc8b1dbe758a4efc3935b55f3327083',
|
||||
packagePath: 'archify.zip',
|
||||
sha256: '70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5',
|
||||
expectedBytes: 5271959,
|
||||
containsScripts: true
|
||||
}
|
||||
const localSkill = {
|
||||
id: 'user:local', name: 'Archify', description: '本地导入的同名技能', version: '1.0.0',
|
||||
path: 'C:/local/archify', scope: 'user', enabled: true, userInvocable: true,
|
||||
disableModelInvocation: false, source: 'imported', userModified: true
|
||||
}
|
||||
const installedRecommendation = {
|
||||
id: 'user:rec', name: 'Archify', description: recommendation.description, version: '3.2.0',
|
||||
path: 'C:/managed/archify', scope: 'user', enabled: true, userInvocable: true,
|
||||
disableModelInvocation: false, source: 'imported', userModified: false,
|
||||
origin: {
|
||||
recommendationId: 'archify', repository: recommendation.repository, commit: recommendation.commit,
|
||||
packagePath: recommendation.packagePath, archiveSha256: recommendation.sha256, version: '3.2.0', installedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
let installed = [], progress = null, failNext = false, cancelRequested = false, delay = 0, recommendationOverride = null, recommendationFails = false
|
||||
let clock = 0
|
||||
const listeners = new Set(), changedListeners = new Set()
|
||||
const emit = state => {
|
||||
// The host stamps a monotonic time; a strict comparison rejects a stale frame.
|
||||
const stamped = { ...state, updatedAt: ++clock }
|
||||
progress = stamped
|
||||
for (const listener of listeners) listener(stamped)
|
||||
for (const win of BrowserWindow.getAllWindows()) if (!win.isDestroyed()) win.webContents.send('skills:install-progress', stamped)
|
||||
}
|
||||
const changed = () => {
|
||||
for (const listener of changedListeners) listener()
|
||||
for (const win of BrowserWindow.getAllWindows()) if (!win.isDestroyed()) win.webContents.send('skills:changed')
|
||||
}
|
||||
|
||||
ipcMain.handle('skills-qa', async (_event, method, args) => {
|
||||
calls.push([method, ...args])
|
||||
if (method === 'recommendations') {
|
||||
if (recommendationFails) throw Error('推荐目录服务不可用')
|
||||
return recommendationOverride ?? [recommendation]
|
||||
}
|
||||
if (method === 'list') return installed
|
||||
if (method === 'setEnabled') { installed = installed.map(skill => skill.id === args[0] ? { ...skill, enabled: args[1] } : skill); changed(); return }
|
||||
if (method === 'installState') return progress
|
||||
// The host only accepts the cancel request; the terminal frame still comes from the install flow.
|
||||
if (method === 'cancelInstall') { cancelRequested = true; return { cancelled: true } }
|
||||
if (method === 'installRecommended') {
|
||||
const [id, options] = args
|
||||
if (id !== recommendation.id) throw Error('未知推荐技能')
|
||||
if (failNext) {
|
||||
failNext = false
|
||||
emit({ operationId: options.operationId, recommendationId: id, phase: 'failed', receivedBytes: 0, error: '下载失败:网络错误', updatedAt: Date.now() })
|
||||
throw Error('下载失败:网络错误')
|
||||
}
|
||||
emit({ operationId: options.operationId, recommendationId: id, phase: 'downloading', receivedBytes: 0, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
if (cancelRequested) { cancelRequested = false; emit({ operationId: options.operationId, recommendationId: id, phase: 'cancelled', receivedBytes: 0, updatedAt: Date.now() }); throw Object.assign(Error('推荐技能安装已取消'), { name: 'AbortError' }) }
|
||||
emit({ operationId: options.operationId, recommendationId: id, phase: 'validating', receivedBytes: recommendation.expectedBytes, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
if (cancelRequested) { cancelRequested = false; emit({ operationId: options.operationId, recommendationId: id, phase: 'cancelled', receivedBytes: recommendation.expectedBytes, updatedAt: Date.now() }); throw Object.assign(Error('推荐技能安装已取消'), { name: 'AbortError' }) }
|
||||
emit({ operationId: options.operationId, recommendationId: id, phase: 'installing', receivedBytes: recommendation.expectedBytes, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
installed = [installedRecommendation]
|
||||
emit({ operationId: options.operationId, recommendationId: id, phase: 'installed', receivedBytes: recommendation.expectedBytes, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
|
||||
changed()
|
||||
return installedRecommendation
|
||||
}
|
||||
if (method === 'qaSetInstalled') { installed = args[0]; changed(); return installed }
|
||||
if (method === 'qaFailNext') { failNext = true; return true }
|
||||
if (method === 'qaDelay') { delay = args[0]; return true }
|
||||
if (method === 'qaSetRecommendations') { recommendationOverride = args[0]; return true }
|
||||
if (method === 'qaFailRecommendations') { recommendationFails = args[0] === true; return true }
|
||||
// Simulate an operation owned by another window.
|
||||
if (method === 'qaEmitForeign') {
|
||||
const [phase, operationId] = args
|
||||
emit({ operationId, recommendationId: recommendation.id, phase, receivedBytes: 0, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
|
||||
return true
|
||||
}
|
||||
// Replay a frame with an older timestamp to prove the terminal state never regresses.
|
||||
if (method === 'qaEmitStale') {
|
||||
const [phase, operationId, updatedAt] = args
|
||||
const stamped = { operationId, recommendationId: recommendation.id, phase, receivedBytes: 0, totalBytes: recommendation.expectedBytes, updatedAt }
|
||||
progress = stamped
|
||||
for (const listener of listeners) listener(stamped)
|
||||
for (const win of BrowserWindow.getAllWindows()) if (!win.isDestroyed()) win.webContents.send('skills:install-progress', stamped)
|
||||
return true
|
||||
}
|
||||
if (method === 'qaSubscribe') { listeners.add(args[0]); return true }
|
||||
if (method === 'qaSubscribeChanged') { changedListeners.add(args[0]); return true }
|
||||
throw Error('Unknown QA method ' + method)
|
||||
})
|
||||
|
||||
fs.writeFileSync(path.join(output, 'preload.cjs'), `const{contextBridge,ipcRenderer}=require('electron');
|
||||
const invoke=(method,...args)=>ipcRenderer.invoke('skills-qa',method,args);
|
||||
contextBridge.exposeInMainWorld('ycode',{skills:{
|
||||
list:(cwd)=>invoke('list',cwd),
|
||||
recommendations:()=>invoke('recommendations'),
|
||||
installRecommended:(id,options)=>invoke('installRecommended',id,options),
|
||||
cancelInstall:(operationId)=>invoke('cancelInstall',operationId),
|
||||
installState:()=>invoke('installState'),
|
||||
onInstallProgress:(listener)=>{const wrapped=(_event,state)=>listener(state);ipcRenderer.on('skills:install-progress',wrapped);return()=>ipcRenderer.removeListener('skills:install-progress',wrapped)},
|
||||
onChanged:(listener)=>{const wrapped=()=>listener();ipcRenderer.on('skills:changed',wrapped);return()=>ipcRenderer.removeListener('skills:changed',wrapped)},
|
||||
read:async()=>({skill:{},content:'',files:[]}),
|
||||
setEnabled:(...args)=>invoke('setEnabled',...args), remove:async()=>{}, export:async()=>null, importPath:async()=>null
|
||||
,qaSetInstalled:(v)=>invoke('qaSetInstalled',v),
|
||||
qaFailNext:()=>invoke('qaFailNext'),
|
||||
qaDelay:(v)=>invoke('qaDelay',v),
|
||||
qaSetRecommendations:(v)=>invoke('qaSetRecommendations',v),
|
||||
qaFailRecommendations:(v)=>invoke('qaFailRecommendations',v),
|
||||
qaEmitForeign:(phase,operationId)=>invoke('qaEmitForeign',phase,operationId),
|
||||
qaEmitStale:(phase,operationId,updatedAt)=>invoke('qaEmitStale',phase,operationId,updatedAt)
|
||||
},resources:{openExternal:async()=>undefined}});`)
|
||||
|
||||
const location = file => JSON.stringify(path.join(repository, file).replaceAll('\\', '/'))
|
||||
fs.writeFileSync(path.join(output, 'fixture.tsx'), `import React from 'react';
|
||||
import{createRoot}from'react-dom/client';
|
||||
import Center from ${location('src/renderer/src/capabilities/CapabilityCenter')};
|
||||
import${location('src/renderer/src/styles/tokens.css')};
|
||||
import${location('src/renderer/src/styles/app.css')};
|
||||
window.__qa={uses:[],created:0,find:0};
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<div className="chat-pane cr-theme" style={{height:'100vh'}}>
|
||||
<Center initialTab="skills" onBackToChat={()=>{}} onUseSkill={skill=>window.__qa.uses.push(skill)} onCreateSkill={()=>window.__qa.created++} onFindSkill={()=>window.__qa.find++}/>
|
||||
</div>);`)
|
||||
await require('esbuild').build({
|
||||
entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic',
|
||||
outfile: path.join(output, 'fixture.js'), loader: { '.svg': 'file', '.png': 'file', '.jpg': 'file', '.woff': 'file', '.woff2': 'file', '.ttf': 'file' }, logLevel: 'warning'
|
||||
})
|
||||
fs.writeFileSync(path.join(output, 'index.html'), '<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><style>html,body,#root{width:100%;height:100%;margin:0}</style></head><body><div id="root"></div><script type="module" src="fixture.js"></script></body></html>')
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1052, height: 901, useContentSize: true, frame: false, show: false,
|
||||
webPreferences: { preload: path.join(output, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false }
|
||||
})
|
||||
win.webContents.on('console-message', (_event, level, message) => { if (level === 3) errors.push(message) })
|
||||
const read = code => win.webContents.executeJavaScript(code)
|
||||
const check = (label, actual, expected) => {
|
||||
const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected)
|
||||
assert.ok(pass, `${label}: ${JSON.stringify(actual)}`)
|
||||
checks.push(label)
|
||||
}
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const wait = async code => { for (let index = 0; index < 200; index++) { if (await read(code)) return; await pause(50) } throw Error('Timeout ' + code) }
|
||||
const click = async selector => {
|
||||
await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing '+${JSON.stringify(selector)});e.click();return true})()`)
|
||||
await pause(140)
|
||||
}
|
||||
const capture = async name => {
|
||||
win.webContents.invalidate()
|
||||
await pause(120)
|
||||
// A hidden window can capture the previous frame; warm the compositor first.
|
||||
await win.webContents.capturePage()
|
||||
await pause(200)
|
||||
const image = await win.webContents.capturePage()
|
||||
fs.writeFileSync(path.join(output, name + '.png'), image.toPNG())
|
||||
screenshots.push(name + '.png')
|
||||
}
|
||||
|
||||
try {
|
||||
await win.loadFile(path.join(output, 'index.html'))
|
||||
await wait('!!document.querySelector(".yc-skills-page")')
|
||||
await wait('!!document.querySelector("[data-recommendation-id=archify]")')
|
||||
|
||||
check('the recommendation card renders from the host catalog', await read('document.querySelector("[data-recommendation-id=archify] .sm-card-name")?.textContent'), 'Archify')
|
||||
check('the card exposes its install state for the host test', await read('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState'), 'none')
|
||||
check('the card shows the author supplied description', await read('document.querySelector("[data-recommendation-id=archify] .sm-card-desc")?.textContent'), recommendation.description)
|
||||
check('no maintenance placeholder is shown', await read('!!document.querySelector("[data-capability-maintenance]")'), false)
|
||||
check('the remote catalog tabs still report no connection', await read('!!document.querySelector(".yc-skill-market-unavailable")'), false)
|
||||
await capture('recommendation-card')
|
||||
|
||||
// First install asks for an explicit confirmation.
|
||||
await click('[aria-label="安装 Archify"]')
|
||||
await wait('!!document.querySelector(".yc-skill-detail-shell")')
|
||||
check('the confirm dialog names the source repository', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("tt-a1i/archify")'), true)
|
||||
check('the confirm dialog states that no install script runs', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("不会执行仓库中的任何安装命令")'), true)
|
||||
check('the confirm dialog warns that the skill contains scripts', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("包含脚本") || document.querySelector(".yc-skill-detail-shell").textContent.includes("脚本")'), true)
|
||||
await capture('install-confirm')
|
||||
await read('(()=>{const first=document.querySelector("[role=dialog] .sm-skill-detail-close");first.focus();first.dispatchEvent(new KeyboardEvent("keydown",{key:"Tab",shiftKey:true,bubbles:true,cancelable:true}))})()')
|
||||
check('reverse Tab reaches the collapsible source information', await read('document.activeElement.tagName'), 'SUMMARY')
|
||||
await read('document.activeElement.dispatchEvent(new KeyboardEvent("keydown",{key:"Tab",bubbles:true,cancelable:true}))')
|
||||
check('Tab wraps inside the modal rather than entering the sidebar', await read('document.activeElement.getAttribute("aria-label")'), '关闭技能详情')
|
||||
await read('window.__detailBeforeInstall=document.querySelector("[role=dialog]")')
|
||||
await click('[aria-label="确认安装推荐技能"]')
|
||||
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installed"')
|
||||
check('the install progress is reported by the host', await read('document.querySelector(".yc-skill-inline-install").dataset.installPhase'), 'installed')
|
||||
check('the host received the recommendation id', await read(`JSON.stringify(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').map(c=>c[1]))`), '["archify"]')
|
||||
check('the host received a fresh operation id', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').every(c=>typeof c[2]?.operationId==='string'&&c[2].operationId.length>8))`), true)
|
||||
check('a first install is not an overwrite', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').every(c=>c[2]?.overwrite!==true))`), true)
|
||||
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="current"')
|
||||
check('the card reports the recommendation as installed', await read('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState'), 'current')
|
||||
check('the installed skill is not offered for download again', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), true)
|
||||
check('the detail remains the same DOM instance throughout installation', await read('window.__detailBeforeInstall===document.querySelector("[role=dialog]")'), true)
|
||||
check('the detail offers use after installation', await read('document.querySelector("[role=dialog] .sm-skill-detail-action-row")?.textContent.includes("去使用")'), true)
|
||||
check('the success notification also offers use', await read('document.querySelector(".yc-skill-install-success")?.textContent.includes("去使用")'), true)
|
||||
check('there is no permanent list footer progress panel', await read('!!document.querySelector(".yc-skills-scroll > .yc-skill-inline-install")'), false)
|
||||
await capture('installed-detail')
|
||||
await click('[role=dialog] .sm-skill-detail-close')
|
||||
|
||||
// The installed list stays reachable and the skill can be used.
|
||||
await click('.yc-skill-recommendation-footer button')
|
||||
await wait('document.querySelectorAll(".sm-card").length===1')
|
||||
check('the installed skill appears in the installed list', await read('document.querySelector(".sm-card-name")?.textContent'), 'Archify')
|
||||
await click('[aria-label="返回专家中心"]')
|
||||
await wait('!!document.querySelector("[data-recommendation-id=archify]")')
|
||||
|
||||
// A local skill with the same name requires an explicit overwrite.
|
||||
await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([localSkill])})`)
|
||||
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="conflict"')
|
||||
await click('[aria-label="安装 Archify"]')
|
||||
await wait('!!document.querySelector(".yc-skill-detail-shell")')
|
||||
check('a same-name local skill requires an explicit replacement', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("替换")'), true)
|
||||
check('the replacement notice mentions the local modification', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("本地内容已被修改")'), true)
|
||||
await click('[aria-label="确认安装推荐技能"]')
|
||||
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="current"')
|
||||
check('an explicit replacement is sent to the host as an overwrite', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').at(-1)[2]?.overwrite)===true`), true)
|
||||
await click('[role=dialog] .sm-skill-detail-close')
|
||||
|
||||
// A failing download reports the real host error and allows a retry.
|
||||
await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([])})`)
|
||||
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="none"')
|
||||
await read(`window.ycode.skills.qaFailNext()`)
|
||||
await click('[aria-label="安装 Archify"]')
|
||||
await click('[aria-label="确认安装推荐技能"]')
|
||||
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="failed"')
|
||||
check('a failed install shows the host error', await read('document.querySelector(".yc-skill-inline-install").textContent.includes("网络错误")'), true)
|
||||
check('a failed install can be retried from the same card', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), false)
|
||||
await click('[role=dialog] .sm-skill-detail-close')
|
||||
|
||||
// Cancellation is allowed before the atomic commit phase.
|
||||
await read(`window.ycode.skills.qaDelay(400)`)
|
||||
await click('[aria-label="安装 Archify"]')
|
||||
await click('[aria-label="确认安装推荐技能"]')
|
||||
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="downloading"')
|
||||
check('the download phase can be cancelled', await read('[...document.querySelectorAll("[role=dialog] button")].some(e=>e.textContent==="取消安装"&&!e.disabled)'), true)
|
||||
await read('document.querySelector("[role=dialog]").querySelectorAll("button").forEach(e=>{if(e.textContent==="取消安装")e.click()})')
|
||||
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="cancelled"')
|
||||
check('the cancellation is sent to the host', await read(`${JSON.stringify(calls)}.some(c=>c[0]==='cancelInstall')`), true)
|
||||
await click('[role=dialog] .sm-skill-detail-close')
|
||||
await read(`window.ycode.skills.qaDelay(0)`)
|
||||
|
||||
// Closing the detail never aborts an accepted install; reopening resumes it.
|
||||
await read('window.ycode.skills.qaDelay(250)')
|
||||
await click('[aria-label="安装 Archify"]'); await click('[aria-label="确认安装推荐技能"]')
|
||||
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="downloading"')
|
||||
const continuingId = await read('document.querySelector(".yc-skill-inline-install").dataset.operationId')
|
||||
await click('[role=dialog] .sm-skill-detail-close')
|
||||
check('closing a running detail restores the background', await read('document.getElementById("root").inert'), false)
|
||||
await click('[data-recommendation-id=archify] .sm-card')
|
||||
check('reopening detail keeps the same install operation', await read('document.querySelector(".yc-skill-inline-install").dataset.operationId'), continuingId)
|
||||
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installing"')
|
||||
check('the commit phase disables cancellation in place', await read('[...document.querySelectorAll("[role=dialog] button")].find(e=>e.textContent==="取消安装")?.disabled'), true)
|
||||
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installed"')
|
||||
await click('[role=dialog] .sm-skill-detail-close')
|
||||
await click('.yc-skill-install-success [aria-label="使用推荐技能 Archify"]')
|
||||
check('the success notification invokes the same use callback', await read('window.__qa.uses.length'), 1)
|
||||
await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([{ ...installedRecommendation, enabled: false }])})`)
|
||||
await wait('document.querySelector(".yc-skill-recommendation-meta")?.textContent.includes("启用后使用")')
|
||||
await click('.yc-skill-recommendation-meta [aria-label="使用推荐技能 Archify"]')
|
||||
await wait('window.__qa.uses.length===2')
|
||||
check('disabled recommendation is enabled before use', await read('window.__qa.uses.at(-1).enabled'), true)
|
||||
await read('window.ycode.skills.qaDelay(0)'); await read('window.ycode.skills.qaSetInstalled([])')
|
||||
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="none"')
|
||||
|
||||
// A failing catalog is reported as unavailable, not as a maintenance page.
|
||||
// The page loads its catalog once, so the failure is proven on a fresh mount.
|
||||
await read(`window.ycode.skills.qaFailRecommendations(true)`)
|
||||
await read(`location.reload()`)
|
||||
await wait('!!document.querySelector(".yc-skill-recommendation-unavailable")')
|
||||
check('an empty catalog is reported as unavailable, not as a maintenance page', await read('!!document.querySelector("[data-capability-maintenance]")'), false)
|
||||
check('the unavailable state still offers the installed list', await read('!!document.querySelector(".yc-skill-recommendation-unavailable button")'), true)
|
||||
await read(`window.ycode.skills.qaFailRecommendations(false)`)
|
||||
await read(`location.reload()`)
|
||||
await wait('!!document.querySelector("[data-recommendation-id=archify]")')
|
||||
|
||||
// An install owned by another window blocks a new install here.
|
||||
const foreignOperation = '11111111-2222-4333-8444-555555555555'
|
||||
await read(`window.ycode.skills.qaEmitForeign('downloading', ${JSON.stringify(foreignOperation)})`)
|
||||
await wait('!!document.querySelector("[data-foreign-install=true]")')
|
||||
check('an install from another window is reported', await read('document.querySelector("[data-foreign-install=true]").textContent.includes("其他窗口正在安装推荐技能")'), true)
|
||||
check('a new install is blocked while another window is installing', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), true)
|
||||
check('the blocked state is explained on the card', await read('!!document.querySelector(".yc-skill-recommendation-busy")'), true)
|
||||
await capture('foreign-install')
|
||||
await read(`window.ycode.skills.qaEmitForeign('installed', ${JSON.stringify(foreignOperation)})`)
|
||||
await wait('!document.querySelector("[data-foreign-install=true]")')
|
||||
check('the foreign block clears when that operation finishes', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), false)
|
||||
|
||||
// A stale frame must not move a settled operation back into a running phase.
|
||||
await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([])})`)
|
||||
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="none"')
|
||||
await read(`window.ycode.skills.qaDelay(0)`)
|
||||
await click('[aria-label="安装 Archify"]')
|
||||
await click('[aria-label="确认安装推荐技能"]')
|
||||
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installed"')
|
||||
const settledOperation = await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').at(-1)[2]?.operationId)`)
|
||||
check('the settled install recorded its operation id', typeof settledOperation === 'string' && settledOperation.length > 8, true)
|
||||
await read(`window.ycode.skills.qaEmitStale('downloading', ${JSON.stringify(settledOperation)}, 1)`)
|
||||
await pause(250)
|
||||
check('a stale frame does not regress the settled install', await read('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase'), 'installed')
|
||||
await click('[role=dialog] .sm-skill-detail-close')
|
||||
|
||||
// Dark and narrow captures of the real page.
|
||||
// This fixture mounts only the capability center, so the theme attribute is set directly.
|
||||
// The dark token switch is proven here only; the production app harness covers the light flow.
|
||||
await read(`(()=>{localStorage.setItem('ycode:theme','dark');document.documentElement.dataset.theme='dark';return true})()`)
|
||||
await wait('document.documentElement.dataset.theme==="dark"')
|
||||
await wait('!!document.querySelector(".yc-capability-center .expert-center-dark")')
|
||||
check('the dark theme reaches the real page', await read('!!document.querySelector(".yc-capability-center .expert-center-dark")'), true)
|
||||
// The card transitions its background, so the dark surface only settles after the animation.
|
||||
await wait(`(()=>{const card=document.querySelector('[data-recommendation-id=archify] .sm-card');if(!card)return false;const m=/rgba?\\((\\d+)/.exec(getComputedStyle(card).backgroundColor);return !!m&&Number(m[1])<90})()`)
|
||||
const darkColors = await read(`(()=>{const card=document.querySelector('[data-recommendation-id=archify] .sm-card');const page=document.querySelector('.yc-capability-center .expert-center-page');return{card:card?getComputedStyle(card).backgroundColor:null,page:page?getComputedStyle(page).backgroundColor:null}})()`)
|
||||
check('the dark card uses a dark surface', darkColors.card, value => typeof value === 'string' && /rgba?\((\d+), (\d+), (\d+)/.test(value) && Number(value.match(/rgba?\((\d+)/)[1]) < 90)
|
||||
const overflow = await read(`(()=>{const wide=[...document.querySelectorAll('*')].filter(e=>e.getBoundingClientRect().right>innerWidth+1).map(e=>e.className||e.tagName).slice(0,4);return{scrollWidth:document.documentElement.scrollWidth,innerWidth,wide}})()`)
|
||||
check('the dark layout keeps the recommendation card inside the viewport', await read(`(()=>{const c=document.querySelector('[data-recommendation-id=archify] .sm-card');if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()`), true)
|
||||
check('the dark layout does not overflow the document', overflow.scrollWidth <= overflow.innerWidth + 1, true)
|
||||
await capture('recommendation-dark')
|
||||
win.setSize(720, 900)
|
||||
await pause(400)
|
||||
check('the recommendation card stays inside the narrow viewport', await read('(()=>{const c=document.querySelector("[data-recommendation-id=archify] .sm-card");if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()'), true)
|
||||
const narrowOverflow = await read(`(()=>{const wide=[...document.querySelectorAll('*')].filter(e=>e.getBoundingClientRect().right>innerWidth+1).map(e=>String(e.className||e.tagName)).slice(0,5);return{scrollWidth:document.documentElement.scrollWidth,innerWidth,wide}})()`)
|
||||
check('the narrow recommendation card causes no document overflow', narrowOverflow.scrollWidth <= narrowOverflow.innerWidth + 1, true)
|
||||
await capture('recommendation-narrow')
|
||||
win.setSize(1052, 901)
|
||||
await pause(250)
|
||||
|
||||
check('no renderer errors', errors, [])
|
||||
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({
|
||||
result: 'PASS', checks, calls, screenshots,
|
||||
limits: 'Controlled host in isolated Electron; the shipped page and styles are used. No network, no real skill storage, no real install.'
|
||||
}, null, 2))
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, output }))
|
||||
win.destroy()
|
||||
app.exit(0)
|
||||
} catch (error) {
|
||||
await capture('failure')
|
||||
fs.writeFileSync(path.join(output, 'failure.json'), JSON.stringify({ error: String(error), stack: error?.stack, checks, errors, calls, html: await read('document.body.innerHTML') }, null, 2))
|
||||
console.error(error)
|
||||
win.destroy()
|
||||
app.exit(1)
|
||||
}
|
||||
})().catch(error => { console.error(error); electron.app.exit(1) })
|
||||
}
|
||||
@@ -39,7 +39,7 @@ if (!electron) {
|
||||
fs.writeFileSync(path.join(output,'preload.cjs'), `const{contextBridge,ipcRenderer}=require('electron');contextBridge.exposeInMainWorld('ycode',{skills:Object.fromEntries(['list','read','setEnabled','remove','export','importPath'].map(method=>[method,(...args)=>ipcRenderer.invoke('skills-qa',method,args)]))});`)
|
||||
const location = file=>JSON.stringify(path.join(repository,file).replaceAll('\\','/'))
|
||||
fs.writeFileSync(path.join(output,'fixture.tsx'), `import React from 'react';import{createRoot}from'react-dom/client';import Center from ${location('src/renderer/src/capabilities/CapabilityCenter')};import ${location('src/renderer/src/styles/tokens.css')};import ${location('src/renderer/src/styles/app.css')};window.__skillsQa={uses:[],created:0,find:0};createRoot(document.getElementById('root')).render(<div className="chat-pane cr-theme" style={{height:'100vh'}}><Center cwd={${JSON.stringify(workspace)}} onBackToChat={()=>{}} onUseSkill={skill=>window.__skillsQa.uses.push(skill)} onCreateSkill={()=>window.__skillsQa.created++} onFindSkill={()=>window.__skillsQa.find++}/></div>);`)
|
||||
await require('esbuild').build({entryPoints:[path.join(output,'fixture.tsx')],bundle:true,platform:'browser',format:'esm',jsx:'automatic',outfile:path.join(output,'fixture.js'),loader:{'.svg':'file','.png':'file','.woff':'file','.woff2':'file','.ttf':'file'},logLevel:'warning'})
|
||||
await require('esbuild').build({entryPoints:[path.join(output,'fixture.tsx')],bundle:true,platform:'browser',format:'esm',jsx:'automatic',outfile:path.join(output,'fixture.js'),loader:{'.svg':'file','.png':'file','.jpg':'file','.woff':'file','.woff2':'file','.ttf':'file'},logLevel:'warning'})
|
||||
fs.writeFileSync(path.join(output,'index.html'),'<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><style>html,body,#root{width:100%;height:100%;margin:0}</style></head><body><div id="root"></div><script type="module" src="fixture.js"></script></body></html>')
|
||||
const win=new BrowserWindow({width:1052,height:901,useContentSize:true,frame:false,show:false,webPreferences:{preload:path.join(output,'preload.cjs'),contextIsolation:true,nodeIntegration:false,backgroundThrottling:false}})
|
||||
win.webContents.on('console-message',(_event,level,message)=>{if(level===3)errors.push(message)})
|
||||
@@ -67,9 +67,9 @@ if (!electron) {
|
||||
check('success notice does not affect normal flow',await read('getComputedStyle(document.querySelector(".yc-skill-notice")).position'),'absolute');await wait('!document.querySelector(".yc-skill-notice")');check('success notice disappears automatically',await read('!!document.querySelector(".yc-skill-notice")'),false)
|
||||
await clickText('.sm-installed-batch-manage','批量管理');await click('.sm-card');await clickText('.sm-batch-toolbar button','禁用');check('batch disable persists real state',(await api.listSkills(workspace))[0].enabled,false);await clickText('.sm-batch-toolbar button','启用');check('batch enable persists real state',(await api.listSkills(workspace))[0].enabled,true);await clickText('.sm-installed-batch-manage','完成')
|
||||
await click('.wb-switch__input');check('native toggle persists actual disabled state',(await api.listSkills(workspace))[0].enabled,false)
|
||||
await click('.wb-switch__input');await click('.sm-card-name');await wait('document.querySelector(".sm-skill-detail-overview-body")?.textContent.includes("NATIVE_SKILL_PROOF")');check('detail renders real SKILL.md markdown',await read('document.querySelector(".sm-skill-detail-overview-body").textContent.includes("Actual skill instructions")'),true);await capture('detail')
|
||||
await click('.wb-switch__input');await click('.sm-card-name');await wait('document.querySelector(".yc-skill-description-body")?.textContent.includes("NATIVE_SKILL_PROOF")');check('detail renders real SKILL.md markdown',await read('document.querySelector(".yc-skill-description-body").textContent.includes("Actual skill instructions")'),true);await capture('detail')
|
||||
await click('[aria-label="使用技能"]');check('use callback receives actual enabled identity',await read('window.__skillsQa.uses[0].id'),(await api.listSkills(workspace))[0].id)
|
||||
await clickText('.sm-skill-detail-action-row button','导出');check('export writes real ZIP',fs.existsSync(path.join(output,'export.zip')),true)
|
||||
await click('.sm-card-name');await wait('!!document.querySelector(".sm-skill-detail-card")');await clickText('.sm-skill-detail-action-row button','导出');check('export writes real ZIP',fs.existsSync(path.join(output,'export.zip')),true)
|
||||
await click('[aria-label="卸载技能"]');await key('Escape');await settle();check('nested Escape closes only top uninstall dialog',await read('!!document.querySelector(".sm-skill-detail-card")&&!document.querySelector("[aria-label=确认卸载]")'),true)
|
||||
await click('[aria-label="关闭技能详情"]');await click('[aria-label="搜索技能"]');await cdp('Input.insertText',{text:'NO_MATCH'});await settle();check('real search filters installed list',await read('document.querySelectorAll(".sm-card").length'),0);await click('[aria-label="清空技能搜索"]');await wait('document.querySelectorAll(".sm-card").length===1')
|
||||
await click('.sm-card-pin');check('pin updates shared slash menu memory',await read(`JSON.parse(localStorage.getItem(${JSON.stringify('ycode.skills.menu.v1:'+workspace.replaceAll('\\','/').toLowerCase())})).pinned.length`),1)
|
||||
|
||||
@@ -0,0 +1,827 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash, generateKeyPairSync } from 'node:crypto'
|
||||
import { createServer, type ServerResponse } from 'node:http'
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { signPayload as releaseSignPayload } from './updates/lib.mjs'
|
||||
import { FileUpdatePersistence } from '../src/main/updates/storage'
|
||||
import { compareVersions, parseVersion } from '../src/main/updates/semver'
|
||||
import { normalizeManifestUrl, verifySignedManifest } from '../src/main/updates/manifest'
|
||||
import { isTrustedUpdateRendererUrl } from '../src/main/updates/trust'
|
||||
import { createNsisInstaller, NSIS_UNINSTALL_KEY } from '../src/main/updates/installer'
|
||||
import { sanitizeLogText } from '../src/main/updates/logger'
|
||||
import { UpdateManager } from '../src/main/updates/manager'
|
||||
import {
|
||||
UpdateError,
|
||||
type CachedInstaller,
|
||||
type FetchLike,
|
||||
type InstallEnvironment,
|
||||
type InstallerAdapter,
|
||||
type SignedUpdateManifest,
|
||||
type UpdateManifestPayload
|
||||
} from '../src/main/updates/types'
|
||||
|
||||
const tests: Array<[string, () => Promise<void> | void]> = []
|
||||
const test = (name: string, run: () => Promise<void> | void): void => {
|
||||
tests.push([name, run])
|
||||
}
|
||||
|
||||
const sleep = (milliseconds: number): Promise<void> => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
async function waitFor(predicate: () => boolean | Promise<boolean>, timeout = 5_000): Promise<void> {
|
||||
const deadline = Date.now() + timeout
|
||||
while (!await predicate()) {
|
||||
if (Date.now() >= deadline) throw new Error('Timed out waiting for test condition')
|
||||
await sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
function makeBytes(size: number, fill: number): Buffer {
|
||||
const bytes = Buffer.allocUnsafe(size)
|
||||
for (let index = 0; index < size; index += 1) bytes[index] = (fill + index) & 0xff
|
||||
return bytes
|
||||
}
|
||||
|
||||
function expectUpdateError(run: () => unknown, code: string): void {
|
||||
assert.throws(run, (error: unknown) => error instanceof UpdateError && error.code === code)
|
||||
}
|
||||
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||
const publicPem = publicKey.export({ type: 'spki', format: 'pem' }).toString()
|
||||
|
||||
function manifestFor(
|
||||
origin: string,
|
||||
version: string,
|
||||
bytes: Buffer,
|
||||
notes = { zh: `更新 ${version}`, en: `Release ${version}` },
|
||||
filename = `YCode-${version}-Setup.exe`
|
||||
): SignedUpdateManifest {
|
||||
const payload: UpdateManifestPayload = {
|
||||
schemaVersion: 1,
|
||||
version,
|
||||
publishedAt: '2026-10-20T09:00:00.000Z',
|
||||
notes,
|
||||
platforms: {
|
||||
'windows-x64': {
|
||||
url: `${origin}/releases/versions/${version}/${filename}`,
|
||||
size: bytes.byteLength,
|
||||
sha256: sha256(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
payload,
|
||||
signature: releaseSignPayload(payload, privatePem)
|
||||
}
|
||||
}
|
||||
|
||||
interface HttpFixture {
|
||||
latest: SignedUpdateManifest | null
|
||||
files: Map<string, Buffer>
|
||||
requests: string[]
|
||||
downloadRanges: Array<string | null>
|
||||
redirect: 'none' | 'same-origin' | 'cross-origin'
|
||||
crossOrigin: string
|
||||
oversizeManifest: boolean
|
||||
interruptNextDownload: boolean
|
||||
chunkSize: number
|
||||
chunkDelayMs: number
|
||||
downloadStarted: number
|
||||
origin: string
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
async function sendBody(response: ServerResponse, body: Buffer, fixture: HttpFixture): Promise<void> {
|
||||
for (let offset = 0; offset < body.byteLength; offset += fixture.chunkSize) {
|
||||
if (response.destroyed) return
|
||||
const chunk = body.subarray(offset, Math.min(body.byteLength, offset + fixture.chunkSize))
|
||||
if (!response.write(chunk)) await new Promise<void>(resolve => response.once('drain', resolve))
|
||||
if (fixture.chunkDelayMs > 0) await sleep(fixture.chunkDelayMs)
|
||||
}
|
||||
if (!response.destroyed) response.end()
|
||||
}
|
||||
|
||||
async function createHttpFixture(): Promise<HttpFixture> {
|
||||
let server: ReturnType<typeof createServer>
|
||||
const fixture: HttpFixture = {
|
||||
latest: null,
|
||||
files: new Map(),
|
||||
requests: [],
|
||||
downloadRanges: [],
|
||||
redirect: 'none',
|
||||
crossOrigin: '',
|
||||
oversizeManifest: false,
|
||||
interruptNextDownload: false,
|
||||
chunkSize: 64 * 1024,
|
||||
chunkDelayMs: 0,
|
||||
downloadStarted: 0,
|
||||
origin: '',
|
||||
close: async () => {
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
}
|
||||
}
|
||||
server = createServer((request, response) => {
|
||||
response.on('error', () => undefined)
|
||||
const url = new URL(request.url ?? '/', fixture.origin)
|
||||
fixture.requests.push(url.pathname)
|
||||
if (url.pathname === '/releases/latest.json') {
|
||||
if (fixture.oversizeManifest) {
|
||||
const length = 1024 * 1024 + 1
|
||||
response.writeHead(200, { 'content-type': 'application/json', 'content-length': String(length) })
|
||||
response.end(Buffer.alloc(length, 0x20))
|
||||
return
|
||||
}
|
||||
if (fixture.redirect === 'same-origin' && !url.searchParams.has('redirected')) {
|
||||
response.writeHead(302, { location: '/releases/latest.json?redirected=1' })
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
if (fixture.redirect === 'cross-origin') {
|
||||
response.writeHead(302, { location: `${fixture.crossOrigin}/releases/latest.json` })
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
const body = Buffer.from(JSON.stringify(fixture.latest))
|
||||
response.writeHead(200, { 'content-type': 'application/json', 'content-length': String(body.byteLength) })
|
||||
response.end(body)
|
||||
return
|
||||
}
|
||||
|
||||
const file = fixture.files.get(url.pathname)
|
||||
if (!file) {
|
||||
response.writeHead(404)
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
const rangeMatch = /^bytes=(\d+)-$/.exec(request.headers.range ?? '')
|
||||
const range = rangeMatch ? Number(rangeMatch[1]) : null
|
||||
fixture.downloadRanges.push(rangeMatch?.[0] ?? null)
|
||||
fixture.downloadStarted += 1
|
||||
if (range !== null && range >= file.byteLength) {
|
||||
response.writeHead(416, { 'content-range': `bytes */${file.byteLength}` })
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
const start = range ?? 0
|
||||
if (fixture.interruptNextDownload && start === 0) {
|
||||
fixture.interruptNextDownload = false
|
||||
const partial = file.subarray(0, Math.max(1, Math.floor(file.byteLength / 2)))
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/octet-stream',
|
||||
'content-length': String(file.byteLength)
|
||||
})
|
||||
response.write(partial)
|
||||
setTimeout(() => response.destroy(new Error('simulated interruption')), 20)
|
||||
return
|
||||
}
|
||||
response.writeHead(start > 0 ? 206 : 200, {
|
||||
'content-type': 'application/octet-stream',
|
||||
'content-length': String(file.byteLength - start),
|
||||
...(start > 0 ? { 'content-range': `bytes ${start}-${file.byteLength - 1}/${file.byteLength}` } : {})
|
||||
})
|
||||
void sendBody(response, file.subarray(start), fixture).catch(() => undefined)
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => resolve())
|
||||
})
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Fake HTTP server did not bind')
|
||||
fixture.origin = `http://127.0.0.1:${address.port}`
|
||||
return fixture
|
||||
}
|
||||
|
||||
class FakeInstaller implements InstallerAdapter {
|
||||
validateCount = 0
|
||||
verifyCount = 0
|
||||
launchCount = 0
|
||||
launchError: Error | null = null
|
||||
onLaunch: (() => Promise<void>) | null = null
|
||||
|
||||
async validateEnvironment(_requiredBytes?: number): Promise<InstallEnvironment> {
|
||||
this.validateCount += 1
|
||||
return {
|
||||
installationDirectory: 'C:\\Users\\Test\\AppData\\Local\\Programs\\YCode',
|
||||
executablePath: 'C:\\Users\\Test\\AppData\\Local\\Programs\\YCode\\YCode.exe',
|
||||
registryKey: `HKCU\\${NSIS_UNINSTALL_KEY}`
|
||||
}
|
||||
}
|
||||
|
||||
async verifyInstaller(_installer: CachedInstaller): Promise<void> {
|
||||
this.verifyCount += 1
|
||||
}
|
||||
|
||||
async launch(_installer: CachedInstaller, _environment: InstallEnvironment): Promise<void> {
|
||||
this.launchCount += 1
|
||||
if (this.onLaunch) await this.onLaunch()
|
||||
if (this.launchError) throw this.launchError
|
||||
}
|
||||
}
|
||||
|
||||
interface ManagerFixture {
|
||||
manager: UpdateManager
|
||||
installer: FakeInstaller
|
||||
persistence: FileUpdatePersistence
|
||||
cacheDirectory: string
|
||||
controls: {
|
||||
beforeResult: boolean
|
||||
beforeGate: Promise<boolean> | null
|
||||
beforeCalls: number
|
||||
failedCalls: number
|
||||
quitCalls: number
|
||||
quitError: Error | null
|
||||
}
|
||||
}
|
||||
|
||||
let managerSequence = 0
|
||||
|
||||
function createManagerFixture(
|
||||
root: string,
|
||||
origin: string,
|
||||
options: { autoDownload?: boolean; currentVersion?: string; identity?: string } = {}
|
||||
): ManagerFixture {
|
||||
managerSequence += 1
|
||||
const directory = join(root, `manager-${options.identity ?? managerSequence}`)
|
||||
const installer = new FakeInstaller()
|
||||
const persistence = new FileUpdatePersistence(join(directory, 'state.json'))
|
||||
const controls: ManagerFixture['controls'] = {
|
||||
beforeResult: true,
|
||||
beforeGate: null,
|
||||
beforeCalls: 0,
|
||||
failedCalls: 0,
|
||||
quitCalls: 0,
|
||||
quitError: null
|
||||
}
|
||||
const manager = new UpdateManager({
|
||||
currentVersion: options.currentVersion ?? '0.0.1',
|
||||
manifestUrl: `${origin}/releases/latest.json`,
|
||||
cacheDirectory: join(directory, 'cache'),
|
||||
logFile: join(directory, 'updater.log'),
|
||||
persistence,
|
||||
publicKey: async () => publicPem,
|
||||
supported: true,
|
||||
installer,
|
||||
beforeInstall: async () => {
|
||||
controls.beforeCalls += 1
|
||||
if (controls.beforeGate) return controls.beforeGate
|
||||
return controls.beforeResult
|
||||
},
|
||||
quitForUpdate: async () => {
|
||||
controls.quitCalls += 1
|
||||
if (controls.quitError) throw controls.quitError
|
||||
},
|
||||
installFailed: async () => {
|
||||
controls.failedCalls += 1
|
||||
},
|
||||
autoDownload: options.autoDownload ?? false,
|
||||
checkDelayMs: 5,
|
||||
checkIntervalMs: 60_000,
|
||||
upToDateResetMs: 30,
|
||||
manifestTimeoutMs: 2_000,
|
||||
downloadTimeoutMs: 10_000
|
||||
})
|
||||
return { manager, installer, persistence, cacheDirectory: join(directory, 'cache'), controls }
|
||||
}
|
||||
|
||||
test('strict SemVer parser and comparator', () => {
|
||||
assert.deepEqual(parseVersion('1.2.3-alpha.1+build.7').prerelease, ['alpha', '1'])
|
||||
assert.equal(compareVersions('1.2.3+build.1', '1.2.3+build.2'), 0)
|
||||
assert.equal(compareVersions('1.2.3-alpha.2', '1.2.3-alpha.10'), -1)
|
||||
assert.equal(compareVersions('1.2.3', '1.2.3-rc.1'), 1)
|
||||
expectUpdateError(() => parseVersion('01.2.3'), 'invalid-version')
|
||||
expectUpdateError(() => parseVersion('1.2.3-01'), 'invalid-version')
|
||||
})
|
||||
|
||||
test('manifest signature, exact metadata and URL policy', async () => {
|
||||
const origin = 'http://127.0.0.1:65530'
|
||||
const bytes = makeBytes(1024, 5)
|
||||
const manifest = manifestFor(origin, '1.0.1', bytes)
|
||||
const verified = verifySignedManifest(Buffer.from(JSON.stringify(manifest)), {
|
||||
manifestUrl: `${origin}/releases/latest.json`,
|
||||
publicKeyPem: publicPem
|
||||
})
|
||||
assert.equal(verified.payload.version, '1.0.1')
|
||||
|
||||
const tampered = structuredClone(manifest)
|
||||
tampered.payload.platforms['windows-x64'].sha256 = '0'.repeat(64)
|
||||
expectUpdateError(
|
||||
() => verifySignedManifest(Buffer.from(JSON.stringify(tampered)), {
|
||||
manifestUrl: `${origin}/releases/latest.json`,
|
||||
publicKeyPem: publicPem
|
||||
}),
|
||||
'invalid-signature'
|
||||
)
|
||||
|
||||
const invalidVersionPayload = structuredClone(manifest.payload) as unknown as Record<string, unknown>
|
||||
invalidVersionPayload.version = '01.0.0'
|
||||
const invalidVersionDocument = {
|
||||
payload: invalidVersionPayload,
|
||||
signature: releaseSignPayload(invalidVersionPayload, privatePem)
|
||||
}
|
||||
expectUpdateError(
|
||||
() => verifySignedManifest(Buffer.from(JSON.stringify(invalidVersionDocument)), {
|
||||
manifestUrl: `${origin}/releases/latest.json`,
|
||||
publicKeyPem: publicPem
|
||||
}),
|
||||
'invalid-version'
|
||||
)
|
||||
|
||||
const outsidePath = structuredClone(manifest.payload)
|
||||
outsidePath.platforms['windows-x64'].url = `${origin}/releases/other/YCode.exe`
|
||||
const outsidePathDocument = {
|
||||
payload: outsidePath,
|
||||
signature: releaseSignPayload(outsidePath, privatePem)
|
||||
}
|
||||
expectUpdateError(
|
||||
() => verifySignedManifest(Buffer.from(JSON.stringify(outsidePathDocument)), {
|
||||
manifestUrl: `${origin}/releases/latest.json`,
|
||||
publicKeyPem: publicPem
|
||||
}),
|
||||
'invalid-manifest'
|
||||
)
|
||||
assert.equal(normalizeManifestUrl(`${origin}/releases/latest.json`), `${origin}/releases/latest.json`)
|
||||
})
|
||||
|
||||
test('trusted renderer URL gate rejects task and foreign frames', () => {
|
||||
const appPath = 'C:\\Program Files\\YCode\\resources\\app.asar'
|
||||
const mainFile = pathToFileURL('C:\\Program Files\\YCode\\resources\\app.asar\\out\\renderer\\index.html').href
|
||||
const settingsFile = pathToFileURL('C:\\Program Files\\YCode\\resources\\app.asar\\out\\renderer\\settings.html').href
|
||||
assert.equal(isTrustedUpdateRendererUrl(mainFile, { appPath, platform: 'win32' }), true)
|
||||
assert.equal(isTrustedUpdateRendererUrl(settingsFile, { appPath, platform: 'win32' }), true)
|
||||
assert.equal(isTrustedUpdateRendererUrl(`${mainFile}?window=task`, { appPath, platform: 'win32' }), false)
|
||||
assert.equal(isTrustedUpdateRendererUrl('file:///C:/Windows/System32/drivers/etc/hosts', { appPath, platform: 'win32' }), false)
|
||||
assert.equal(isTrustedUpdateRendererUrl('http://localhost:5173/index.html?window=task', {
|
||||
appPath,
|
||||
rendererUrl: 'http://localhost:5173'
|
||||
}), false)
|
||||
assert.equal(isTrustedUpdateRendererUrl('http://localhost:5173/settings.html', {
|
||||
appPath,
|
||||
rendererUrl: 'http://localhost:5173'
|
||||
}), true)
|
||||
assert.equal(isTrustedUpdateRendererUrl('http://127.0.0.1:5173/settings.html', {
|
||||
appPath,
|
||||
rendererUrl: 'http://localhost:5173'
|
||||
}), false)
|
||||
})
|
||||
|
||||
test('installer adapter uses spawn, current key and NSIS arguments', async () => {
|
||||
assert.equal(NSIS_UNINSTALL_KEY.includes('8a4ee941-d893-569b-8bcb-2a55d270c04b'), true)
|
||||
const root = 'C:\\Users\\Test\\AppData\\Local\\YCode Custom\\Install Dir'
|
||||
const executable = `${root}\\YCode.exe`
|
||||
const cacheDirectory = join(await mkdtemp(join(tmpdir(), 'ycode-update-adapter-')), 'cache')
|
||||
const calls: Array<{ command: string; args: readonly string[]; detached: boolean; stdio: string }> = []
|
||||
const child = {
|
||||
once(event: 'spawn' | 'error', listener: (() => void) | ((error: Error) => void)) {
|
||||
if (event === 'spawn') queueMicrotask(() => (listener as () => void)())
|
||||
return child
|
||||
},
|
||||
unref() {}
|
||||
}
|
||||
const baseOptions = {
|
||||
platform: 'win32',
|
||||
isPackaged: true,
|
||||
execPath: executable,
|
||||
appPath: `${root}\\resources\\app.asar`,
|
||||
cacheDirectory,
|
||||
processId: 4321,
|
||||
resolveRealPath: async value => value,
|
||||
readStat: async () => ({ isFile: () => true }),
|
||||
queryRegistryValue: async name => {
|
||||
if (name === 'UninstallString') return `"${root}\\Uninstall YCode.exe" /currentuser`
|
||||
if (name === 'DisplayIcon') return `${executable},0`
|
||||
return null
|
||||
},
|
||||
verifyFile: async () => undefined,
|
||||
spawn: (command, args, options) => {
|
||||
calls.push({ command, args, detached: options.detached, stdio: options.stdio })
|
||||
return child
|
||||
}
|
||||
} as const
|
||||
const adapter = createNsisInstaller({
|
||||
...baseOptions,
|
||||
readDiskStats: async () => ({ bsize: 4096, bavail: 1_000_000 })
|
||||
})
|
||||
const environment = await adapter.validateEnvironment(1024)
|
||||
assert.equal(environment.installationDirectory, root)
|
||||
const cached: CachedInstaller = {
|
||||
version: '1.0.2',
|
||||
path: join(cacheDirectory, 'YCode-1.0.2-Setup.exe'),
|
||||
partPath: join(cacheDirectory, 'YCode-1.0.2-Setup.exe.part'),
|
||||
size: 10,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
await adapter.launch(cached, environment)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].detached, true)
|
||||
assert.equal(calls[0].stdio, 'ignore')
|
||||
assert.deepEqual([...calls[0].args], [
|
||||
'/S',
|
||||
'--updated',
|
||||
'--force-run',
|
||||
'/currentuser',
|
||||
'/YCODEWAITPID=4321',
|
||||
`/D=${root}`
|
||||
])
|
||||
assert.equal(calls[0].args.at(-1), `/D=${root}`)
|
||||
|
||||
const lowSpace = createNsisInstaller({
|
||||
...baseOptions,
|
||||
readDiskStats: async () => ({ bsize: 1, bavail: 1 })
|
||||
})
|
||||
await assert.rejects(
|
||||
() => lowSpace.validateEnvironment(1024),
|
||||
(error: unknown) => error instanceof UpdateError && error.code === 'installer-space-check'
|
||||
)
|
||||
|
||||
const directoryMarker = createNsisInstaller({
|
||||
...baseOptions,
|
||||
readStat: async value => ({ isFile: () => !value.endsWith('app.asar') })
|
||||
})
|
||||
await assert.rejects(
|
||||
() => directoryMarker.validateEnvironment(0),
|
||||
(error: unknown) => error instanceof UpdateError
|
||||
&& error.code === 'installer-marker'
|
||||
&& /app\.asar/.test(error.message)
|
||||
)
|
||||
})
|
||||
|
||||
test('HTTP manifest limits, same-origin redirects and cross-origin rejection', async () => {
|
||||
const first = await createHttpFixture()
|
||||
const second = await createHttpFixture()
|
||||
const root = await mkdtemp(join(tmpdir(), 'ycode-update-http-'))
|
||||
try {
|
||||
const bytes = makeBytes(256 * 1024, 11)
|
||||
first.latest = manifestFor(first.origin, '1.0.1', bytes)
|
||||
first.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
|
||||
first.redirect = 'same-origin'
|
||||
const redirected = createManagerFixture(root, first.origin, { identity: 'redirect' })
|
||||
const redirectedState = await redirected.manager.check()
|
||||
assert.equal(redirectedState.status, 'available')
|
||||
|
||||
first.redirect = 'cross-origin'
|
||||
first.crossOrigin = second.origin
|
||||
second.latest = first.latest
|
||||
const cross = createManagerFixture(root, first.origin, { identity: 'cross' })
|
||||
const crossState = await cross.manager.check()
|
||||
assert.equal(crossState.status, 'error')
|
||||
assert.match(crossState.error ?? '', /跨源/)
|
||||
|
||||
first.redirect = 'none'
|
||||
first.oversizeManifest = true
|
||||
const oversized = createManagerFixture(root, first.origin, { identity: 'oversize' })
|
||||
const oversizedState = await oversized.manager.check()
|
||||
assert.equal(oversizedState.status, 'error')
|
||||
assert.match(oversizedState.error ?? '', /大小限制/)
|
||||
} finally {
|
||||
await first.close()
|
||||
await second.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('automatic download, dismissal and up-to-date idle reset', async () => {
|
||||
const http = await createHttpFixture()
|
||||
const root = await mkdtemp(join(tmpdir(), 'ycode-update-auto-'))
|
||||
try {
|
||||
const bytes = makeBytes(192 * 1024, 17)
|
||||
http.latest = manifestFor(http.origin, '1.0.1', bytes)
|
||||
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
|
||||
const auto = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'auto' })
|
||||
const checked = await auto.manager.checkAndDownload(false)
|
||||
assert.equal(checked.status, 'downloading')
|
||||
assert.equal(checked.version, '1.0.1')
|
||||
assert.equal(checked.lastExplicit, false)
|
||||
await waitFor(async () => (await auto.manager.getState()).status === 'ready')
|
||||
assert.equal((await auto.manager.getState()).progress, 100)
|
||||
assert.equal((await auto.manager.getState()).lastExplicit, false)
|
||||
http.oversizeManifest = true
|
||||
const retainedReady = await auto.manager.check(false)
|
||||
assert.equal(retainedReady.status, 'ready')
|
||||
assert.equal(retainedReady.lastExplicit, false)
|
||||
http.oversizeManifest = false
|
||||
const dismissed = await auto.manager.dismiss()
|
||||
assert.equal(dismissed.dismissedVersion, '1.0.1')
|
||||
assert.equal(dismissed.lastExplicit, true)
|
||||
const persisted = await auto.persistence.load()
|
||||
assert.equal(persisted.dismissedVersion, '1.0.1')
|
||||
|
||||
const second = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'dismissed' })
|
||||
await second.persistence.save({ schemaVersion: 1, dismissedVersion: '1.0.1' })
|
||||
const before = http.requests.length
|
||||
const dismissedCheck = await second.manager.checkAndDownload()
|
||||
await sleep(80)
|
||||
assert.equal(dismissedCheck.dismissedVersion, '1.0.1')
|
||||
assert.equal(dismissedCheck.lastExplicit, true)
|
||||
assert.equal(http.requests.length - before, 1)
|
||||
|
||||
http.latest = manifestFor(http.origin, '0.0.1', bytes)
|
||||
const current = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'current' })
|
||||
const upToDate = await current.manager.check()
|
||||
assert.equal(upToDate.status, 'up-to-date')
|
||||
await sleep(60)
|
||||
assert.equal((await current.manager.getState()).status, 'idle')
|
||||
|
||||
http.latest = manifestFor(http.origin, '1.0.1', bytes)
|
||||
const explicit = createManagerFixture(root, http.origin, { identity: 'explicit-reset' })
|
||||
assert.equal((await explicit.manager.check(true)).lastExplicit, true)
|
||||
http.oversizeManifest = true
|
||||
const backgroundFailure = await explicit.manager.check(false)
|
||||
assert.equal(backgroundFailure.status, 'error')
|
||||
assert.equal(backgroundFailure.lastExplicit, false)
|
||||
http.oversizeManifest = false
|
||||
} finally {
|
||||
await http.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('download interruption resumes with valid Range and fixed cache filename', async () => {
|
||||
const http = await createHttpFixture()
|
||||
const root = await mkdtemp(join(tmpdir(), 'ycode-update-resume-'))
|
||||
try {
|
||||
const bytes = makeBytes(2 * 1024 * 1024, 23)
|
||||
http.latest = manifestFor(http.origin, '1.0.1', bytes)
|
||||
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
|
||||
const fixture = createManagerFixture(root, http.origin, { identity: 'resume' })
|
||||
assert.equal((await fixture.manager.check()).status, 'available')
|
||||
http.interruptNextDownload = true
|
||||
const interrupted = await fixture.manager.download()
|
||||
assert.equal(interrupted.status, 'error')
|
||||
const partInfo = await stat(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe.part'))
|
||||
assert.ok(partInfo.size > 0)
|
||||
|
||||
const resumed = await fixture.manager.download()
|
||||
assert.equal(resumed.status, 'ready')
|
||||
assert.ok(http.downloadRanges.some(range => range?.startsWith('bytes=')))
|
||||
const cached = await readFile(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe'))
|
||||
assert.equal(sha256(cached), sha256(bytes))
|
||||
} finally {
|
||||
await http.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('concurrent download is single-flight and release switch aborts stale bytes', async () => {
|
||||
const http = await createHttpFixture()
|
||||
const root = await mkdtemp(join(tmpdir(), 'ycode-update-race-'))
|
||||
try {
|
||||
const firstBytes = makeBytes(4 * 1024 * 1024, 31)
|
||||
const secondBytes = makeBytes(384 * 1024, 47)
|
||||
http.latest = manifestFor(http.origin, '1.0.1', firstBytes)
|
||||
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', firstBytes)
|
||||
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', secondBytes)
|
||||
http.chunkSize = 32 * 1024
|
||||
http.chunkDelayMs = 15
|
||||
const fixture = createManagerFixture(root, http.origin, { identity: 'race' })
|
||||
assert.equal((await fixture.manager.check()).status, 'available')
|
||||
const staleDownload = fixture.manager.download()
|
||||
await waitFor(() => http.downloadStarted > 0)
|
||||
|
||||
http.latest = manifestFor(http.origin, '1.0.2', secondBytes)
|
||||
const switched = await fixture.manager.check()
|
||||
assert.equal(switched.status, 'available')
|
||||
assert.equal(switched.version, '1.0.2')
|
||||
const staleResult = await staleDownload
|
||||
assert.notEqual(staleResult.status, 'ready')
|
||||
assert.equal((await fixture.manager.getState()).version, '1.0.2')
|
||||
|
||||
http.chunkDelayMs = 0
|
||||
const rangeCount = http.downloadRanges.length
|
||||
const [one, two] = await Promise.all([fixture.manager.download(), fixture.manager.download()])
|
||||
assert.equal(one.status, 'ready')
|
||||
assert.equal(two.status, 'ready')
|
||||
assert.equal(http.downloadRanges.length - rangeCount, 1)
|
||||
const cached = await readFile(join(fixture.cacheDirectory, 'YCode-1.0.2-Setup.exe'))
|
||||
assert.equal(sha256(cached), sha256(secondBytes))
|
||||
} finally {
|
||||
await http.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('install cancellation, launch failure marker cleanup, and success ordering', async () => {
|
||||
const http = await createHttpFixture()
|
||||
const root = await mkdtemp(join(tmpdir(), 'ycode-update-install-'))
|
||||
try {
|
||||
const bytes = makeBytes(256 * 1024, 61)
|
||||
http.latest = manifestFor(http.origin, '1.0.2', bytes)
|
||||
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
|
||||
const fixture = createManagerFixture(root, http.origin, { identity: 'install' })
|
||||
assert.equal((await fixture.manager.check()).status, 'available')
|
||||
|
||||
fixture.controls.beforeResult = false
|
||||
const cancelled = await fixture.manager.install()
|
||||
assert.equal(cancelled.status, 'ready')
|
||||
assert.equal(fixture.installer.launchCount, 0)
|
||||
assert.equal(fixture.controls.failedCalls, 0)
|
||||
|
||||
assert.equal((await fixture.manager.download()).status, 'ready')
|
||||
fixture.controls.beforeResult = true
|
||||
fixture.installer.launchError = new Error('spawn failed')
|
||||
const failed = await fixture.manager.install()
|
||||
assert.equal(failed.status, 'error')
|
||||
assert.equal(fixture.controls.failedCalls, 1)
|
||||
const cleared = await fixture.persistence.load()
|
||||
assert.equal(cleared.unfinishedVersion, undefined)
|
||||
assert.equal(cleared.target, undefined)
|
||||
|
||||
assert.equal((await fixture.manager.check()).status, 'ready')
|
||||
fixture.installer.launchError = null
|
||||
let markerAtLaunch: string | undefined
|
||||
fixture.installer.onLaunch = async () => {
|
||||
markerAtLaunch = (await fixture.persistence.load()).unfinishedVersion
|
||||
}
|
||||
const installed = await fixture.manager.install()
|
||||
assert.equal(installed.status, 'installing')
|
||||
assert.equal(markerAtLaunch, '1.0.2')
|
||||
assert.equal(fixture.installer.validateCount >= 2, true)
|
||||
assert.equal(fixture.installer.verifyCount >= 2, true)
|
||||
assert.equal(fixture.installer.launchCount, 2)
|
||||
assert.equal(fixture.controls.quitCalls, 1)
|
||||
const retained = await fixture.persistence.load()
|
||||
assert.equal(retained.unfinishedVersion, '1.0.2')
|
||||
assert.equal(retained.target?.sha256, sha256(bytes))
|
||||
const afterCommittedDismiss = await fixture.manager.dismiss()
|
||||
assert.equal(afterCommittedDismiss.status, 'installing')
|
||||
assert.equal(afterCommittedDismiss.dismissedVersion, undefined)
|
||||
const afterCommittedInstall = await fixture.manager.install()
|
||||
assert.equal(afterCommittedInstall.status, 'installing')
|
||||
assert.equal(fixture.installer.launchCount, 2)
|
||||
assert.equal(fixture.controls.beforeCalls, 3)
|
||||
} finally {
|
||||
await http.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('confirmation pending blocks dismiss and repeated install', async () => {
|
||||
const http = await createHttpFixture()
|
||||
const root = await mkdtemp(join(tmpdir(), 'ycode-update-confirm-lock-'))
|
||||
try {
|
||||
const bytes = makeBytes(128 * 1024, 67)
|
||||
http.latest = manifestFor(http.origin, '1.0.2', bytes)
|
||||
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
|
||||
const fixture = createManagerFixture(root, http.origin, { identity: 'confirm-lock' })
|
||||
assert.equal((await fixture.manager.check()).status, 'available')
|
||||
assert.equal((await fixture.manager.download()).status, 'ready')
|
||||
|
||||
let releaseConfirmation!: (accepted: boolean) => void
|
||||
fixture.controls.beforeGate = new Promise(resolve => {
|
||||
releaseConfirmation = resolve
|
||||
})
|
||||
const firstInstall = fixture.manager.install()
|
||||
await waitFor(() => fixture.controls.beforeCalls === 1)
|
||||
|
||||
const dismissed = await fixture.manager.dismiss()
|
||||
assert.equal(dismissed.status, 'ready')
|
||||
assert.equal(dismissed.dismissedVersion, undefined)
|
||||
const repeatedInstall = fixture.manager.install()
|
||||
assert.equal(fixture.installer.launchCount, 0)
|
||||
assert.equal(fixture.controls.beforeCalls, 1)
|
||||
|
||||
releaseConfirmation(false)
|
||||
const [firstState, repeatedState] = await Promise.all([firstInstall, repeatedInstall])
|
||||
assert.equal(firstState.status, 'ready')
|
||||
assert.equal(repeatedState.status, 'ready')
|
||||
assert.equal(fixture.installer.launchCount, 0)
|
||||
assert.equal(fixture.controls.failedCalls, 0)
|
||||
assert.equal((await fixture.persistence.load()).dismissedVersion, undefined)
|
||||
} finally {
|
||||
await http.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('committed installer blocks recovery after quit failure', async () => {
|
||||
const http = await createHttpFixture()
|
||||
const root = await mkdtemp(join(tmpdir(), 'ycode-update-committed-'))
|
||||
try {
|
||||
const bytes = makeBytes(128 * 1024, 73)
|
||||
http.latest = manifestFor(http.origin, '1.0.2', bytes)
|
||||
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
|
||||
const fixture = createManagerFixture(root, http.origin, { identity: 'committed' })
|
||||
assert.equal((await fixture.manager.check()).status, 'available')
|
||||
assert.equal((await fixture.manager.download()).status, 'ready')
|
||||
|
||||
fixture.controls.quitError = new Error('simulated quit failure')
|
||||
const committed = await fixture.manager.install()
|
||||
assert.equal(committed.status, 'installing')
|
||||
assert.match(committed.error ?? '', /必须退出 YCode/)
|
||||
assert.equal(fixture.installer.launchCount, 1)
|
||||
assert.equal(fixture.controls.beforeCalls, 1)
|
||||
assert.equal(fixture.controls.quitCalls, 1)
|
||||
assert.equal(fixture.controls.failedCalls, 0)
|
||||
const retained = await fixture.persistence.load()
|
||||
assert.equal(retained.unfinishedVersion, '1.0.2')
|
||||
assert.equal(retained.target?.version, '1.0.2')
|
||||
|
||||
const requestCount = http.requests.length
|
||||
const dismissed = await fixture.manager.dismiss()
|
||||
const checked = await fixture.manager.check()
|
||||
const downloaded = await fixture.manager.download()
|
||||
const checkedAndDownloaded = await fixture.manager.checkAndDownload()
|
||||
const repeated = await fixture.manager.install()
|
||||
for (const state of [dismissed, checked, downloaded, checkedAndDownloaded, repeated]) {
|
||||
assert.equal(state.status, 'installing')
|
||||
assert.match(state.error ?? '', /必须退出 YCode/)
|
||||
}
|
||||
assert.equal(http.requests.length, requestCount)
|
||||
assert.equal(fixture.installer.launchCount, 1)
|
||||
assert.equal(fixture.controls.beforeCalls, 1)
|
||||
assert.equal(fixture.controls.quitCalls, 1)
|
||||
assert.equal(fixture.controls.failedCalls, 0)
|
||||
assert.equal((await fixture.persistence.load()).unfinishedVersion, '1.0.2')
|
||||
} finally {
|
||||
await http.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('corrupt cache is revalidated and rejected', async () => {
|
||||
const http = await createHttpFixture()
|
||||
const root = await mkdtemp(join(tmpdir(), 'ycode-update-cache-'))
|
||||
try {
|
||||
const bytes = makeBytes(128 * 1024, 71)
|
||||
http.latest = manifestFor(http.origin, '1.0.1', bytes)
|
||||
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
|
||||
const fixture = createManagerFixture(root, http.origin, { identity: 'cache' })
|
||||
assert.equal((await fixture.manager.check()).status, 'available')
|
||||
assert.equal((await fixture.manager.download()).status, 'ready')
|
||||
await writeFile(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe'), Buffer.from('tampered'))
|
||||
const checked = await fixture.manager.check()
|
||||
assert.equal(checked.status, 'available')
|
||||
const retried = await fixture.manager.download()
|
||||
assert.equal(retried.status, 'ready')
|
||||
} finally {
|
||||
await http.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('unfinished marker clears after target version is installed', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ycode-update-marker-'))
|
||||
try {
|
||||
const persistence = new FileUpdatePersistence(join(root, 'state.json'))
|
||||
await persistence.save({
|
||||
schemaVersion: 1,
|
||||
unfinishedVersion: '1.0.0',
|
||||
target: {
|
||||
version: '1.0.0',
|
||||
size: 10,
|
||||
sha256: 'a'.repeat(64),
|
||||
manifestUrl: 'http://127.0.0.1/releases/latest.json'
|
||||
}
|
||||
})
|
||||
const manager = new UpdateManager({
|
||||
currentVersion: '1.0.0',
|
||||
manifestUrl: 'http://127.0.0.1/releases/latest.json',
|
||||
cacheDirectory: join(root, 'cache'),
|
||||
logFile: join(root, 'updater.log'),
|
||||
persistence,
|
||||
publicKey: async () => publicPem,
|
||||
supported: true,
|
||||
installer: new FakeInstaller(),
|
||||
beforeInstall: async () => false,
|
||||
quitForUpdate: async () => undefined
|
||||
})
|
||||
const state = await manager.getState()
|
||||
assert.equal(state.unfinishedVersion, undefined)
|
||||
const persisted = await persistence.load()
|
||||
assert.equal(persisted.target, undefined)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('log redaction removes URL credentials and query secrets', () => {
|
||||
const sanitized = sanitizeLogText('GET http://user:password@example.test/a?token=secret Authorization: abc123')
|
||||
assert.equal(sanitized.includes('password'), false)
|
||||
assert.equal(sanitized.includes('secret'), false)
|
||||
assert.equal(sanitized.includes('abc123'), false)
|
||||
})
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let failures = 0
|
||||
for (const [name, run] of tests) {
|
||||
try {
|
||||
await run()
|
||||
console.log(`ok - ${name}`)
|
||||
} catch (error) {
|
||||
failures += 1
|
||||
console.error(`not ok - ${name}`)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
console.log(`\n${tests.length - failures}/${tests.length} updater backend tests passed`)
|
||||
if (failures > 0) process.exitCode = 1
|
||||
}
|
||||
|
||||
void main()
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { assertRuntimeAvailable, pauseRuntimeStarts, resumeRuntimeStarts, withRuntimeStart } from '../src/main/update-maintenance'
|
||||
import { createUpdateLifecycle } from '../src/main/update-lifecycle'
|
||||
|
||||
let release!: () => void
|
||||
const running = withRuntimeStart(() => new Promise<void>(resolve => { release = resolve }))
|
||||
await Promise.resolve()
|
||||
let paused = false
|
||||
const pendingPause = pauseRuntimeStarts().then(() => { paused = true })
|
||||
assert.throws(assertRuntimeAvailable, /准备重启/)
|
||||
assert.throws(() => withRuntimeStart(async () => undefined), /准备重启/)
|
||||
await Promise.resolve()
|
||||
assert.equal(paused, false)
|
||||
release()
|
||||
await running
|
||||
await pendingPause
|
||||
assert.equal(paused, true)
|
||||
resumeRuntimeStarts()
|
||||
assert.doesNotThrow(assertRuntimeAvailable)
|
||||
await assert.rejects(withRuntimeStart(async () => { throw new Error('failed start') }), /failed start/)
|
||||
await pauseRuntimeStarts()
|
||||
resumeRuntimeStarts()
|
||||
assert.equal(await withRuntimeStart(async () => 42), 42)
|
||||
console.log('PASS update maintenance: in-flight start drain, new work rejection, resume, rejected-start cleanup')
|
||||
|
||||
let resumed = 0
|
||||
let finishDirectQuiesce!: () => void
|
||||
let directQuiesceStarted!: () => void
|
||||
const directQuiesceStart = new Promise<void>(resolve => { directQuiesceStarted = resolve })
|
||||
let directQuiesceCalls = 0
|
||||
const directLifecycle = createUpdateLifecycle({
|
||||
quiesce: () => {
|
||||
directQuiesceCalls += 1
|
||||
directQuiesceStarted()
|
||||
return new Promise<void>(resolve => { finishDirectQuiesce = resolve })
|
||||
},
|
||||
restore: () => { throw new Error('successful quiesce must not restore') },
|
||||
shutdown: async () => undefined,
|
||||
exit: () => undefined,
|
||||
log: () => undefined
|
||||
})
|
||||
const directInstall = directLifecycle.beforeInstall()
|
||||
await directQuiesceStart
|
||||
assert.equal(directQuiesceCalls, 1, 'install starts quiesce without a confirmation port')
|
||||
let directResolved = false
|
||||
void directInstall.then(() => { directResolved = true })
|
||||
await Promise.resolve()
|
||||
assert.equal(directResolved, false, 'beforeInstall waits for quiesce completion')
|
||||
await assert.rejects(directLifecycle.beforeInstall(), /上次更新准备仍在结束中/)
|
||||
assert.equal(directQuiesceCalls, 1, 'concurrent beforeInstall must not start quiesce twice')
|
||||
finishDirectQuiesce()
|
||||
assert.equal(await directInstall, true)
|
||||
|
||||
let finishQuiesce!: () => void
|
||||
const deferredLifecycle = createUpdateLifecycle({
|
||||
quiesce: () => new Promise<void>(resolve => { finishQuiesce = resolve }),
|
||||
restore: () => { resumed += 1 },
|
||||
shutdown: async () => undefined,
|
||||
exit: () => undefined,
|
||||
log: () => undefined,
|
||||
prepareTimeoutMs: 2
|
||||
})
|
||||
await assert.rejects(deferredLifecycle.beforeInstall(), /清理尚未结束/)
|
||||
deferredLifecycle.installFailed()
|
||||
assert.equal(resumed, 0, 'timeout must not resume while old quiesce is still running')
|
||||
finishQuiesce()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
assert.equal(resumed, 1, 'runtime resumes only after timed-out quiesce settles')
|
||||
|
||||
let exited = 0
|
||||
let restarts = 0
|
||||
let shutdowns = 0
|
||||
const committedLifecycle = createUpdateLifecycle({
|
||||
quiesce: async () => undefined,
|
||||
restore: () => { restarts += 1 },
|
||||
shutdown: async () => { shutdowns += 1 },
|
||||
exit: () => { exited += 1 },
|
||||
log: () => undefined
|
||||
})
|
||||
assert.equal(await committedLifecycle.beforeInstall(), true)
|
||||
await committedLifecycle.quitForUpdate()
|
||||
committedLifecycle.installFailed()
|
||||
assert.deepEqual([shutdowns, exited, restarts], [1, 1, 0], 'committed installer exit must never restore work')
|
||||
await assert.rejects(committedLifecycle.beforeInstall(), /安装器已启动/)
|
||||
const hungShutdown = createUpdateLifecycle({
|
||||
quiesce: async () => undefined,
|
||||
restore: () => { throw new Error('must not restore committed installer') },
|
||||
shutdown: () => new Promise<void>(() => undefined),
|
||||
exit: () => { exited += 1 },
|
||||
log: () => undefined,
|
||||
quitTimeoutMs: 2
|
||||
})
|
||||
await hungShutdown.quitForUpdate()
|
||||
assert.equal(exited, 2, 'hung shutdown is bounded and still releases the installer PID')
|
||||
console.log('PASS update lifecycle: direct quiesce, concurrent barrier, timeout recovery, post-launch no-resume commit')
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import crypto from 'node:crypto'
|
||||
import fsp from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import {
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_PUBLIC_KEY,
|
||||
REPO_ROOT,
|
||||
generateEd25519KeyPair,
|
||||
normalizeBaseUrl,
|
||||
readPublicKey,
|
||||
signPayload
|
||||
} from './lib.mjs'
|
||||
|
||||
async function main() {
|
||||
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-contract-selftest-'))
|
||||
try {
|
||||
const bundlePath = path.join(temporary, 'manifest.mjs')
|
||||
await build({
|
||||
entryPoints: [path.join(REPO_ROOT, 'src', 'main', 'updates', 'manifest.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
outfile: bundlePath,
|
||||
logLevel: 'silent'
|
||||
})
|
||||
const backend = await import(`${pathToFileURL(bundlePath).href}?v=${Date.now()}`)
|
||||
assert.equal(typeof backend.verifySignedManifest, 'function')
|
||||
|
||||
const trustAnchor = await readPublicKey(DEFAULT_PUBLIC_KEY)
|
||||
const trustKey = crypto.createPublicKey(trustAnchor)
|
||||
assert.equal(trustKey.asymmetricKeyType, 'ed25519')
|
||||
|
||||
const keys = generateEd25519KeyPair()
|
||||
const baseUrl = normalizeBaseUrl(DEFAULT_BASE_URL)
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
version: '9.9.9',
|
||||
publishedAt: '2026-10-20T10:00:00.000Z',
|
||||
notes: { zh: '契约自测', en: 'Contract self-test' },
|
||||
platforms: {
|
||||
'windows-x64': {
|
||||
url: `${baseUrl}versions/9.9.9/YCode-9.9.9-Setup-x64.exe`,
|
||||
size: 123,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
}
|
||||
}
|
||||
const document = {
|
||||
payload,
|
||||
signature: signPayload(payload, keys.privatePem)
|
||||
}
|
||||
const raw = Buffer.from(`${JSON.stringify(document)}\n`, 'utf8')
|
||||
const verified = backend.verifySignedManifest(raw, {
|
||||
manifestUrl: `${baseUrl}latest.json`,
|
||||
publicKeyPem: keys.publicPem
|
||||
})
|
||||
assert.equal(verified.payload.version, payload.version)
|
||||
assert.equal(verified.asset.url, payload.platforms['windows-x64'].url)
|
||||
|
||||
const tampered = structuredClone(document)
|
||||
tampered.payload.notes.en = 'tampered'
|
||||
assert.throws(
|
||||
() => backend.verifySignedManifest(Buffer.from(JSON.stringify(tampered), 'utf8'), {
|
||||
manifestUrl: `${baseUrl}latest.json`,
|
||||
publicKeyPem: keys.publicPem
|
||||
}),
|
||||
/签名验证失败/
|
||||
)
|
||||
|
||||
console.log('Release manifest matches update_backend parser/signature/path policy.')
|
||||
console.log('Repository public key is a valid Ed25519 SPKI PEM trust anchor.')
|
||||
} finally {
|
||||
await fsp.rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import path from 'node:path'
|
||||
import {
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_PUBLIC_KEY,
|
||||
DEFAULT_SSH_HOST,
|
||||
normalizeBaseUrl,
|
||||
parseOptions,
|
||||
resolvePath,
|
||||
usageError
|
||||
} from './lib.mjs'
|
||||
import {
|
||||
publishLocalRelease,
|
||||
publishViaSsh,
|
||||
readLatestViaSsh,
|
||||
readLocalLatest,
|
||||
validateReleaseBundle
|
||||
} from './transport.mjs'
|
||||
|
||||
const usage = `Usage:
|
||||
node scripts/updates/deploy.mjs --manifest <latest.json> --installer <YCode-Setup.exe>
|
||||
[--transport ssh|local] [--base-url <url>] [--public-key <path>]
|
||||
[--ssh-host local001] [--local-root <dir>] [--dry-run]
|
||||
|
||||
The SSH transport uploads to /srv/ycode-staging, verifies the current signed latest.json,
|
||||
installs an immutable version directory, then atomically replaces latest.json.
|
||||
The local transport is for temporary fake-transport tests only.
|
||||
`
|
||||
|
||||
async function main() {
|
||||
const options = parseOptions(process.argv.slice(2), {
|
||||
values: ['manifest', 'installer', 'transport', 'base-url', 'public-key', 'ssh-host', 'local-root'],
|
||||
booleans: ['dry-run']
|
||||
})
|
||||
if (!options.manifest || !options.installer) throw new Error('--manifest and --installer are required')
|
||||
|
||||
const transport = options.transport || 'ssh'
|
||||
if (transport !== 'ssh' && transport !== 'local') throw new Error('--transport must be ssh or local')
|
||||
if (transport === 'local' && !options['local-root']) throw new Error('--local-root is required for local transport')
|
||||
|
||||
const baseUrl = normalizeBaseUrl(options['base-url'] || DEFAULT_BASE_URL)
|
||||
const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
|
||||
const release = await validateReleaseBundle({
|
||||
manifestPath: resolvePath(options.manifest),
|
||||
installerPath: resolvePath(options.installer),
|
||||
publicKeyPath,
|
||||
baseUrl
|
||||
})
|
||||
|
||||
let current
|
||||
if (transport === 'ssh') {
|
||||
current = await readLatestViaSsh(options['ssh-host'] || DEFAULT_SSH_HOST, release.publicKeyPem, baseUrl)
|
||||
} else {
|
||||
current = await readLocalLatest(resolvePath(options['local-root']), release.publicKeyPem, baseUrl)
|
||||
}
|
||||
|
||||
if (options['dry-run']) {
|
||||
console.log(`Verified ${release.document.payload.version} for ${transport} publication.`)
|
||||
console.log(`Current latest: ${current.document?.payload.version || 'none'}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (transport === 'ssh') {
|
||||
await publishViaSsh({
|
||||
host: options['ssh-host'] || DEFAULT_SSH_HOST,
|
||||
release,
|
||||
current
|
||||
})
|
||||
} else {
|
||||
await publishLocalRelease({
|
||||
root: path.resolve(options['local-root']),
|
||||
release,
|
||||
current
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`Published ${release.document.payload.version} via ${transport}.`)
|
||||
}
|
||||
|
||||
main().catch((error) => usageError(usage, error))
|
||||
@@ -0,0 +1,19 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$item = Get-Item -LiteralPath $Path
|
||||
|
||||
if (-not $item.PSIsContainer -and $item.Extension -ieq '.exe') {
|
||||
[pscustomobject]@{
|
||||
productVersion = [string]$item.VersionInfo.ProductVersion
|
||||
fileVersion = [string]$item.VersionInfo.FileVersion
|
||||
productName = [string]$item.VersionInfo.ProductName
|
||||
fileDescription = [string]$item.VersionInfo.FileDescription
|
||||
} | ConvertTo-Json -Compress
|
||||
exit 0
|
||||
}
|
||||
|
||||
throw "Not an executable file: $Path"
|
||||
@@ -0,0 +1,81 @@
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
DEFAULT_PRIVATE_KEY,
|
||||
DEFAULT_PUBLIC_KEY,
|
||||
assertOutsideRepo,
|
||||
generateEd25519KeyPair,
|
||||
parseOptions,
|
||||
publicKeyFingerprint,
|
||||
resolvePath,
|
||||
runProcess,
|
||||
usageError,
|
||||
writeFileAtomic
|
||||
} from './lib.mjs'
|
||||
|
||||
const usage = `Usage:
|
||||
node scripts/updates/init-key.mjs [--private-key <path>] [--public-key <path>] [--force]
|
||||
|
||||
The private key must remain outside the repository. The default private key is:
|
||||
${DEFAULT_PRIVATE_KEY}
|
||||
`
|
||||
|
||||
function secureWindowsAcl(filename, isDirectory = false) {
|
||||
if (process.platform !== 'win32') return
|
||||
const identity = runProcess('whoami', []).trim()
|
||||
if (!identity) throw new Error('Unable to determine the current Windows identity')
|
||||
const permission = isDirectory ? `${identity}:(OI)(CI)F` : `${identity}:(F)`
|
||||
runProcess('icacls', [filename, '/inheritance:r', '/grant:r', permission])
|
||||
}
|
||||
|
||||
async function pathExists(filename) {
|
||||
try {
|
||||
await fsp.access(filename)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseOptions(process.argv.slice(2), {
|
||||
values: ['private-key', 'public-key'],
|
||||
booleans: ['force']
|
||||
})
|
||||
const privateKeyPath = await assertOutsideRepo(
|
||||
resolvePath(options['private-key'] || DEFAULT_PRIVATE_KEY),
|
||||
'Private key'
|
||||
)
|
||||
const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
|
||||
|
||||
if (!options.force) {
|
||||
if (await pathExists(privateKeyPath)) throw new Error(`Refusing to overwrite existing private key: ${privateKeyPath}`)
|
||||
if (await pathExists(publicKeyPath)) throw new Error(`Refusing to overwrite existing public key: ${publicKeyPath}`)
|
||||
}
|
||||
|
||||
const keyPair = generateEd25519KeyPair()
|
||||
const directory = path.dirname(privateKeyPath)
|
||||
await fsp.mkdir(directory, { recursive: true, mode: 0o700 })
|
||||
try {
|
||||
await fsp.chmod(directory, 0o700)
|
||||
} catch {
|
||||
// Windows ACLs are applied below.
|
||||
}
|
||||
secureWindowsAcl(directory, true)
|
||||
|
||||
await writeFileAtomic(privateKeyPath, keyPair.privatePem, 0o600)
|
||||
try {
|
||||
await fsp.chmod(privateKeyPath, 0o600)
|
||||
} catch {
|
||||
// Windows ACLs are applied below.
|
||||
}
|
||||
secureWindowsAcl(privateKeyPath)
|
||||
await writeFileAtomic(publicKeyPath, keyPair.publicPem, 0o644)
|
||||
|
||||
console.log(`Private key: ${privateKeyPath}`)
|
||||
console.log(`Public key: ${publicKeyPath}`)
|
||||
console.log(`Public key SHA-256 fingerprint: ${publicKeyFingerprint(keyPair.publicPem)}`)
|
||||
console.log('Private key material was not printed. Back up the private key separately before rotating it.')
|
||||
}
|
||||
|
||||
main().catch((error) => usageError(usage, error))
|
||||
@@ -0,0 +1,538 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
export const DEFAULT_BASE_URL = 'http://192.168.10.6/ycode/releases/'
|
||||
export const DEFAULT_SSH_HOST = 'local001'
|
||||
export const DEFAULT_PRIVATE_KEY = path.join(os.homedir(), '.ycode-release', 'update-signing.key')
|
||||
export const DEFAULT_PUBLIC_KEY = path.join(REPO_ROOT, 'resources', 'updates', 'public-key.pem')
|
||||
export const BASELINE_VERSION = '0.0.1'
|
||||
export const MAX_INSTALLER_BYTES = 4 * 1024 * 1024 * 1024
|
||||
export const MIN_PUBLIC_INSTALLER_BYTES = 20 * 1024 * 1024
|
||||
export const SERVER_PUBLIC_ROOT = '/srv/ycode'
|
||||
export const SERVER_STAGING_ROOT = '/srv/ycode-staging'
|
||||
export const SERVER_RELEASES_ROOT = '/srv/ycode/releases'
|
||||
export const NGINX_SITE_LINK = '/etc/nginx/sites-enabled/default'
|
||||
export const NGINX_SNIPPET = '/etc/nginx/snippets/ycode-releases.conf'
|
||||
export const NGINX_MARKER = '# YCode updater releases (managed by scripts/updates)'
|
||||
|
||||
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/
|
||||
const SHA256_RE = /^[a-f0-9]{64}$/
|
||||
const EXE_NAME_RE = /^[A-Za-z0-9._-]+\.exe$/
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
export function assert(condition, message) {
|
||||
if (!condition) fail(message)
|
||||
}
|
||||
|
||||
export function parseOptions(argv, { values = [], booleans = [] } = {}) {
|
||||
const valueNames = new Set(values)
|
||||
const booleanNames = new Set(booleans)
|
||||
const options = {}
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index]
|
||||
if (!argument.startsWith('--')) fail(`Unexpected argument: ${argument}`)
|
||||
const equals = argument.indexOf('=')
|
||||
const name = argument.slice(2, equals === -1 ? undefined : equals)
|
||||
|
||||
if (booleanNames.has(name)) {
|
||||
assert(equals === -1, `--${name} does not accept a value`)
|
||||
options[name] = true
|
||||
continue
|
||||
}
|
||||
assert(valueNames.has(name), `Unknown option: --${name}`)
|
||||
|
||||
const value = equals === -1 ? argv[++index] : argument.slice(equals + 1)
|
||||
assert(value !== undefined && value !== '', `Missing value for --${name}`)
|
||||
options[name] = value
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
export function normalizeBaseUrl(value = DEFAULT_BASE_URL) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
fail(`Invalid base URL: ${value}`)
|
||||
}
|
||||
assert(url.protocol === 'http:' || url.protocol === 'https:', 'Base URL must use HTTP or HTTPS')
|
||||
assert(!url.username && !url.password, 'Base URL must not contain credentials')
|
||||
assert(!url.search && !url.hash, 'Base URL must not contain a query or fragment')
|
||||
if (!url.pathname.endsWith('/')) url.pathname += '/'
|
||||
return url.href
|
||||
}
|
||||
|
||||
export function artifactUrlFor(baseUrl, version, filename) {
|
||||
parseVersion(version)
|
||||
assert(EXE_NAME_RE.test(filename), `Unsafe installer filename: ${filename}`)
|
||||
const base = normalizeBaseUrl(baseUrl)
|
||||
return new URL(`versions/${version}/${filename}`, base).href
|
||||
}
|
||||
|
||||
export function assertHttpUrl(value, label = 'URL') {
|
||||
let url
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
fail(`${label} is invalid`)
|
||||
}
|
||||
assert(url.protocol === 'http:' || url.protocol === 'https:', `${label} must use HTTP or HTTPS`)
|
||||
assert(!url.username && !url.password, `${label} must not contain credentials`)
|
||||
assert(!url.search, `${label} must not contain a query`)
|
||||
assert(!url.hash, `${label} must not contain a fragment`)
|
||||
return url
|
||||
}
|
||||
|
||||
export function parseVersion(version) {
|
||||
assert(typeof version === 'string' && version.length <= 256, 'Version must be a non-empty string')
|
||||
const match = SEMVER_RE.exec(version)
|
||||
assert(match, `Version is not strict SemVer: ${version}`)
|
||||
const prerelease = match[4] ? match[4].split('.') : []
|
||||
for (const identifier of prerelease) {
|
||||
assert(!/^\d+$/.test(identifier) || identifier === '0' || !identifier.startsWith('0'), `Invalid SemVer prerelease: ${version}`)
|
||||
}
|
||||
return {
|
||||
major: BigInt(match[1]),
|
||||
minor: BigInt(match[2]),
|
||||
patch: BigInt(match[3]),
|
||||
prerelease,
|
||||
build: match[5] ? match[5].split('.') : []
|
||||
}
|
||||
}
|
||||
|
||||
function compareIdentifier(left, right) {
|
||||
const leftNumeric = /^\d+$/.test(left)
|
||||
const rightNumeric = /^\d+$/.test(right)
|
||||
if (leftNumeric && rightNumeric) {
|
||||
const a = BigInt(left)
|
||||
const b = BigInt(right)
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
if (leftNumeric) return -1
|
||||
if (rightNumeric) return 1
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
export function compareVersions(left, right) {
|
||||
const a = parseVersion(left)
|
||||
const b = parseVersion(right)
|
||||
for (const key of ['major', 'minor', 'patch']) {
|
||||
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
|
||||
}
|
||||
if (!a.prerelease.length && !b.prerelease.length) return 0
|
||||
if (!a.prerelease.length) return 1
|
||||
if (!b.prerelease.length) return -1
|
||||
|
||||
const length = Math.max(a.prerelease.length, b.prerelease.length)
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (a.prerelease[index] === undefined) return -1
|
||||
if (b.prerelease[index] === undefined) return 1
|
||||
const compared = compareIdentifier(a.prerelease[index], b.prerelease[index])
|
||||
if (compared !== 0) return compared
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function normalizeProductVersion(value) {
|
||||
let normalized = String(value ?? '').trim()
|
||||
if (normalized.startsWith('v')) normalized = normalized.slice(1)
|
||||
const windowsRevision = /^(\d+\.\d+\.\d+)\.0$/.exec(normalized)
|
||||
if (windowsRevision) normalized = windowsRevision[1]
|
||||
return normalized
|
||||
}
|
||||
|
||||
export async function sha256File(filename) {
|
||||
const hash = crypto.createHash('sha256')
|
||||
for await (const chunk of fs.createReadStream(filename)) hash.update(chunk)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
export async function readJson(filename) {
|
||||
try {
|
||||
return JSON.parse(await fsp.readFile(filename, 'utf8'))
|
||||
} catch (error) {
|
||||
fail(`Unable to read JSON ${filename}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeFileAtomic(filename, data, mode = 0o644) {
|
||||
const directory = path.dirname(filename)
|
||||
await fsp.mkdir(directory, { recursive: true })
|
||||
const temporary = path.join(directory, `.${path.basename(filename)}.${crypto.randomBytes(8).toString('hex')}.tmp`)
|
||||
const handle = await fsp.open(temporary, 'wx', mode)
|
||||
try {
|
||||
await handle.writeFile(data)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
try {
|
||||
await fsp.rename(temporary, filename)
|
||||
} catch (error) {
|
||||
await fsp.rm(temporary, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePath(value, base = process.cwd()) {
|
||||
return path.resolve(base, value)
|
||||
}
|
||||
|
||||
export async function assertOutsideRepo(filename, label) {
|
||||
const candidate = path.resolve(filename)
|
||||
const repo = path.resolve(REPO_ROOT)
|
||||
const relative = path.relative(repo, candidate)
|
||||
assert(relative.startsWith('..') || path.isAbsolute(relative), `${label} must be outside the repository: ${candidate}`)
|
||||
return candidate
|
||||
}
|
||||
|
||||
export function generateEd25519KeyPair() {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
|
||||
return {
|
||||
privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
||||
publicPem: publicKey.export({ type: 'spki', format: 'pem' }).toString()
|
||||
}
|
||||
}
|
||||
|
||||
export function createPrivateKey(privateKeyPem) {
|
||||
const key = crypto.createPrivateKey(privateKeyPem)
|
||||
assert(key.asymmetricKeyType === 'ed25519', 'Update signing key must be Ed25519')
|
||||
return key
|
||||
}
|
||||
|
||||
export function createPublicKey(publicKeyPem) {
|
||||
const key = crypto.createPublicKey(publicKeyPem)
|
||||
assert(key.asymmetricKeyType === 'ed25519', 'Update public key must be Ed25519')
|
||||
return key
|
||||
}
|
||||
|
||||
export function signPayload(payload, privateKeyPem) {
|
||||
const bytes = Buffer.from(JSON.stringify(payload), 'utf8')
|
||||
return crypto.sign(null, bytes, createPrivateKey(privateKeyPem)).toString('base64')
|
||||
}
|
||||
|
||||
export function verifyPayloadSignature(payload, signature, publicKeyPem) {
|
||||
assert(typeof signature === 'string', 'Manifest signature must be a string')
|
||||
const signatureBytes = Buffer.from(signature, 'base64')
|
||||
assert(signatureBytes.length === 64, 'Manifest signature must contain 64 bytes')
|
||||
assert(signatureBytes.toString('base64') === signature, 'Manifest signature must use canonical base64')
|
||||
const bytes = Buffer.from(JSON.stringify(payload), 'utf8')
|
||||
return crypto.verify(null, bytes, createPublicKey(publicKeyPem), signatureBytes)
|
||||
}
|
||||
|
||||
export function publicKeyFingerprint(publicKeyPem) {
|
||||
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' })
|
||||
return crypto.createHash('sha256').update(der).digest('hex')
|
||||
}
|
||||
|
||||
function assertExactKeys(value, expected, label) {
|
||||
assert(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`)
|
||||
const actual = Object.keys(value)
|
||||
assert(actual.length === expected.length && expected.every((key) => actual.includes(key)), `${label} has unexpected or missing fields`)
|
||||
}
|
||||
|
||||
export function validateManifestDocument(document, { baseUrl = DEFAULT_BASE_URL } = {}) {
|
||||
assertExactKeys(document, ['payload', 'signature'], 'Manifest')
|
||||
const payload = document.payload
|
||||
assertExactKeys(payload, ['schemaVersion', 'version', 'publishedAt', 'notes', 'platforms'], 'Manifest payload')
|
||||
assert(payload.schemaVersion === 1, 'Unsupported update manifest schemaVersion')
|
||||
parseVersion(payload.version)
|
||||
assert(typeof payload.publishedAt === 'string', 'publishedAt must be a string')
|
||||
const published = new Date(payload.publishedAt)
|
||||
assert(!Number.isNaN(published.valueOf()), 'publishedAt must be an ISO timestamp')
|
||||
assert(published.toISOString() === payload.publishedAt, 'publishedAt must be canonical UTC ISO')
|
||||
|
||||
assertExactKeys(payload.notes, ['zh', 'en'], 'Manifest notes')
|
||||
for (const language of ['zh', 'en']) {
|
||||
assert(typeof payload.notes[language] === 'string' && payload.notes[language].trim(), `Manifest notes.${language} is required`)
|
||||
assert(payload.notes[language].length <= 16_384, `Manifest notes.${language} is too long`)
|
||||
}
|
||||
|
||||
assertExactKeys(payload.platforms, ['windows-x64'], 'Manifest platforms')
|
||||
const platform = payload.platforms['windows-x64']
|
||||
assertExactKeys(platform, ['url', 'size', 'sha256'], 'windows-x64 platform')
|
||||
assert(Number.isSafeInteger(platform.size) && platform.size > 0 && platform.size <= MAX_INSTALLER_BYTES, 'windows-x64 size is invalid')
|
||||
assert(typeof platform.sha256 === 'string' && SHA256_RE.test(platform.sha256), 'windows-x64 sha256 must be lowercase SHA-256 hex')
|
||||
const url = assertHttpUrl(platform.url, 'windows-x64 URL')
|
||||
const filename = path.posix.basename(url.pathname)
|
||||
const expected = artifactUrlFor(baseUrl, payload.version, filename)
|
||||
assert(url.href === expected, 'windows-x64 URL is outside the approved version path')
|
||||
return payload
|
||||
}
|
||||
|
||||
export function verifySignedManifest(document, publicKeyPem, options = {}) {
|
||||
const payload = validateManifestDocument(document, options)
|
||||
assert(verifyPayloadSignature(payload, document.signature, publicKeyPem), 'Update manifest signature is invalid')
|
||||
return payload
|
||||
}
|
||||
|
||||
export function serializeSignedManifest(document, options = {}) {
|
||||
validateManifestDocument(document, options)
|
||||
return `${JSON.stringify(document)}\n`
|
||||
}
|
||||
|
||||
export async function readPublicKey(filename = DEFAULT_PUBLIC_KEY) {
|
||||
try {
|
||||
return await fsp.readFile(filename, 'utf8')
|
||||
} catch (error) {
|
||||
fail(`Unable to read update public key ${filename}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPrivateKey(filename = DEFAULT_PRIVATE_KEY) {
|
||||
try {
|
||||
return await fsp.readFile(filename, 'utf8')
|
||||
} catch (error) {
|
||||
fail(`Unable to read update signing key ${filename}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function runProcess(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
input: options.input,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
maxBuffer: 8 * 1024 * 1024
|
||||
})
|
||||
if (result.error) fail(`Unable to run ${command}: ${result.error.message}`)
|
||||
if (result.status !== 0) {
|
||||
const detail = String(result.stderr || result.stdout || '').trim()
|
||||
fail(`${command} failed with exit code ${result.status}${detail ? `: ${detail}` : ''}`)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
export function validateSshHost(host = DEFAULT_SSH_HOST) {
|
||||
assert(/^[A-Za-z0-9_.@:-]+$/.test(host) && !host.startsWith('-'), 'SSH host contains unsafe characters')
|
||||
return host
|
||||
}
|
||||
|
||||
export function runSsh(host, script) {
|
||||
return runProcess('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', validateSshHost(host), 'bash', '-s'], {
|
||||
input: script
|
||||
})
|
||||
}
|
||||
|
||||
export async function getProductVersion(installerPath) {
|
||||
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'get-exe-version.ps1')
|
||||
assert(fs.existsSync(script), `Missing version helper: ${script}`)
|
||||
const output = runProcess('powershell.exe', [
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
script,
|
||||
'-Path',
|
||||
installerPath
|
||||
])
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(output)
|
||||
} catch {
|
||||
fail('Unable to parse installer version information')
|
||||
}
|
||||
assert(parsed && typeof parsed.productVersion === 'string', 'Installer has no ProductVersion')
|
||||
return parsed
|
||||
}
|
||||
|
||||
export async function readPackageMetadata() {
|
||||
const packagePath = path.join(REPO_ROOT, 'package.json')
|
||||
const pkg = await readJson(packagePath)
|
||||
assert(pkg.name === 'ycode', 'package.json is not the YCode package')
|
||||
assert(pkg.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
|
||||
parseVersion(pkg.version)
|
||||
return { path: packagePath, package: pkg }
|
||||
}
|
||||
|
||||
export async function verifyReleaseInstaller(installerPath, version, { minimumSize = MIN_PUBLIC_INSTALLER_BYTES } = {}) {
|
||||
parseVersion(version)
|
||||
assert(compareVersions(version, BASELINE_VERSION) > 0, `Release version must be newer than the updater-less ${BASELINE_VERSION} baseline`)
|
||||
assert(EXE_NAME_RE.test(path.basename(installerPath)), `Unsafe installer filename: ${path.basename(installerPath)}`)
|
||||
|
||||
let resolved
|
||||
try {
|
||||
resolved = await fsp.realpath(installerPath)
|
||||
} catch (error) {
|
||||
fail(`Unable to resolve installer: ${error.message}`)
|
||||
}
|
||||
const stat = await fsp.stat(resolved)
|
||||
assert(stat.isFile(), 'Installer must be a regular file')
|
||||
assert(stat.size >= minimumSize, `Installer is unexpectedly small (${stat.size} bytes)`)
|
||||
assert(stat.size <= MAX_INSTALLER_BYTES, 'Installer exceeds the 4 GiB limit')
|
||||
|
||||
const product = await getProductVersion(resolved)
|
||||
const productVersion = normalizeProductVersion(product.productVersion)
|
||||
assert(productVersion === version, `Installer ProductVersion ${product.productVersion} does not match ${version}`)
|
||||
const { path: packagePath, package: pkg } = await readPackageMetadata()
|
||||
assert(normalizeProductVersion(pkg.version) === version, `package.json version ${pkg.version} does not match ${version}`)
|
||||
|
||||
return {
|
||||
path: resolved,
|
||||
filename: path.basename(resolved),
|
||||
size: stat.size,
|
||||
sha256: await sha256File(resolved),
|
||||
productVersion,
|
||||
packagePath
|
||||
}
|
||||
}
|
||||
|
||||
export function buildManifestPayload({ version, publishedAt, notes, installer, baseUrl = DEFAULT_BASE_URL }) {
|
||||
const url = artifactUrlFor(baseUrl, version, installer.filename)
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
version,
|
||||
publishedAt,
|
||||
notes: {
|
||||
zh: notes.zh,
|
||||
en: notes.en
|
||||
},
|
||||
platforms: {
|
||||
'windows-x64': {
|
||||
url,
|
||||
size: installer.size,
|
||||
sha256: installer.sha256
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createSignedManifest({ payload, privateKeyPem, publicKeyPem, baseUrl = DEFAULT_BASE_URL }) {
|
||||
validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, { baseUrl })
|
||||
const signature = signPayload(payload, privateKeyPem)
|
||||
const document = { payload, signature }
|
||||
assert(verifyPayloadSignature(payload, signature, publicKeyPem), 'Generated manifest does not verify with the configured public key')
|
||||
validateManifestDocument(document, { baseUrl })
|
||||
return document
|
||||
}
|
||||
|
||||
export function canonicalNow(value = new Date()) {
|
||||
return value.toISOString()
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MiB (${value.toLocaleString('en-US')} bytes)`
|
||||
}
|
||||
|
||||
export function renderDownloadPage(payload, options = {}) {
|
||||
validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, options)
|
||||
const platform = payload.platforms['windows-x64']
|
||||
const version = escapeHtml(payload.version)
|
||||
const url = escapeHtml(platform.url)
|
||||
const sha256 = escapeHtml(platform.sha256)
|
||||
const publishedAt = escapeHtml(payload.publishedAt)
|
||||
const zhNotes = escapeHtml(payload.notes.zh)
|
||||
const enNotes = escapeHtml(payload.notes.en)
|
||||
const size = escapeHtml(formatBytes(platform.size))
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
||||
<title>YCode v${version} 下载</title>
|
||||
<style>
|
||||
:root { color-scheme: light; --ink:#17201b; --muted:#667069; --paper:#f4f1e8; --card:#fffdf7; --line:#d9d4c6; --accent:#176b4d; --accent-dark:#0d4d36; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; min-height:100vh; background:radial-gradient(circle at top left, #e2eadf 0, transparent 34rem), var(--paper); color:var(--ink); font-family:"Segoe UI Variable","Microsoft YaHei UI","Noto Sans CJK SC",sans-serif; }
|
||||
main { width:min(760px, calc(100% - 32px)); margin:0 auto; padding:clamp(48px,9vw,96px) 0; }
|
||||
.eyebrow { margin:0 0 16px; color:var(--accent-dark); font-size:12px; font-weight:700; letter-spacing:.18em; text-transform:uppercase; }
|
||||
h1 { margin:0; font-size:clamp(36px,7vw,64px); line-height:1; letter-spacing:-.045em; }
|
||||
.summary { margin:18px 0 30px; color:var(--muted); font-size:16px; line-height:1.7; }
|
||||
.card { border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 24px 70px rgba(24,43,34,.08); overflow:hidden; }
|
||||
.release { padding:clamp(22px,4vw,34px); border-bottom:1px solid var(--line); }
|
||||
.meta { display:flex; flex-wrap:wrap; gap:8px 18px; margin:0 0 22px; color:var(--muted); font-size:13px; }
|
||||
.download { display:inline-flex; align-items:center; justify-content:center; min-height:46px; padding:0 22px; border-radius:999px; background:var(--accent); color:white; font-weight:700; text-decoration:none; }
|
||||
.download:hover { background:var(--accent-dark); }
|
||||
.hash { display:block; overflow-wrap:anywhere; margin-top:16px; padding:12px 14px; border:1px solid var(--line); border-radius:10px; background:#f8f6ef; color:#39443d; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace; }
|
||||
.notes { padding:clamp(22px,4vw,34px); display:grid; gap:24px; }
|
||||
.notes h2 { margin:0 0 8px; font-size:14px; letter-spacing:.04em; }
|
||||
.notes p { margin:0; color:#3e4841; line-height:1.75; white-space:pre-wrap; }
|
||||
.footer { margin-top:20px; color:var(--muted); font-size:12px; line-height:1.7; }
|
||||
code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p class="eyebrow">YCode / Windows x64</p>
|
||||
<h1>v${version}</h1>
|
||||
<p class="summary">此页面仅从 Ed25519 验签通过的更新清单生成,不加载脚本或第三方资源。</p>
|
||||
<section class="card">
|
||||
<div class="release">
|
||||
<div class="meta">
|
||||
<span>发布时间 ${publishedAt}</span>
|
||||
<span>${size}</span>
|
||||
</div>
|
||||
<a class="download" href="${url}" rel="noopener">下载 Windows x64 安装包</a>
|
||||
<code class="hash">SHA-256 ${sha256}</code>
|
||||
</div>
|
||||
<div class="notes">
|
||||
<div>
|
||||
<h2>更新说明</h2>
|
||||
<p lang="zh-CN">${zhNotes}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Release notes</h2>
|
||||
<p lang="en">${enNotes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<p class="footer">请核对安装包 SHA-256。更新客户端会独立验证签名、文件大小和哈希。<br>
|
||||
<a href="latest.json">查看签名清单</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
export function renderNoReleasePage() {
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
||||
<title>YCode 下载</title>
|
||||
<style>
|
||||
:root { --paper:#f4f1e8; --card:#fffdf7; --ink:#17201b; --muted:#667069; --line:#d9d4c6; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:24px; background:var(--paper); color:var(--ink); font-family:"Segoe UI Variable","Microsoft YaHei UI",sans-serif; }
|
||||
main { width:min(560px,100%); padding:40px; border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 24px 70px rgba(24,43,34,.08); }
|
||||
h1 { margin:0 0 12px; font-size:clamp(32px,8vw,52px); letter-spacing:-.04em; }
|
||||
p { margin:0; color:var(--muted); line-height:1.75; }
|
||||
</style>
|
||||
</head>
|
||||
<body data-ycode-state="no-release"><main><h1>YCode</h1><p>当前还没有发布正式安装包。此静态页不加载脚本、不提供占位版本;发布完成后再刷新。</p></main></body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
export function usageError(usage, error) {
|
||||
console.error(`${error.message}\n\n${usage}`)
|
||||
process.exitCode = 2
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { REPO_ROOT } from './lib.mjs'
|
||||
|
||||
const filename = path.join(REPO_ROOT, 'build', 'installer.nsh')
|
||||
const source = await fsp.readFile(filename, 'utf8')
|
||||
|
||||
assert.match(source, /OpenProcess\(i 0x100000, i 0, i R1\) p \.R2 \?e/)
|
||||
assert.match(source, /Pop \$R3/)
|
||||
assert.doesNotMatch(source, /GetLastError\(\)/)
|
||||
assert.match(source, /WaitForSingleObject\(p R2, i 120000\)/)
|
||||
assert.match(source, /ElseIf\} \$R3 != 87/)
|
||||
|
||||
const waitIndex = source.indexOf('WaitForSingleObject')
|
||||
const imageCheckIndex = source.indexOf('SetEnvironmentVariable(t "YCODE_UPDATE_TARGET_DIR"')
|
||||
assert.ok(waitIndex >= 0 && imageCheckIndex > waitIndex, 'image check must run after waiting for the parent PID')
|
||||
assert.ok(imageCheckIndex > source.indexOf('${ElseIf} $R3 != 87'), 'image check must run after the OpenProcess branch join')
|
||||
assert.ok(source.includes('t "$INSTDIR\\${APP_EXECUTABLE_FILENAME}"'))
|
||||
|
||||
assert.match(source, /nsExec::ExecToStack/)
|
||||
assert.match(source, /Get-CimInstance Win32_Process/)
|
||||
assert.match(source, /TrimEnd\(\[char\]92\)/)
|
||||
assert.match(source, /AddSeconds\(5\)/)
|
||||
assert.match(source, /Start-Sleep -Milliseconds 500/)
|
||||
assert.match(source, /if \(-not \$\$process\.ExecutablePath\) \{ \$\$found=\$\$true; continue \}/)
|
||||
assert.match(source, /Name -ieq '\$\{APP_EXECUTABLE_FILENAME\}'/)
|
||||
assert.match(source, /StrCmp \$R4 "0" \+3/)
|
||||
|
||||
assert.doesNotMatch(source, /KillProcess|taskkill|Stop-Process|_CHECK_APP_RUNNING/)
|
||||
assert.doesNotMatch(source, /GetProcessInfo/)
|
||||
assert.doesNotMatch(source, /GetLastError\(\)/)
|
||||
assert.doesNotMatch(source, /Name -ieq 'YCode\.exe'/)
|
||||
|
||||
console.log('NSIS updater safety checks passed.')
|
||||
@@ -0,0 +1,71 @@
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_PRIVATE_KEY,
|
||||
DEFAULT_PUBLIC_KEY,
|
||||
assertOutsideRepo,
|
||||
buildManifestPayload,
|
||||
canonicalNow,
|
||||
createSignedManifest,
|
||||
normalizeBaseUrl,
|
||||
parseOptions,
|
||||
readPrivateKey,
|
||||
readPublicKey,
|
||||
resolvePath,
|
||||
serializeSignedManifest,
|
||||
usageError,
|
||||
verifyReleaseInstaller,
|
||||
verifySignedManifest,
|
||||
writeFileAtomic
|
||||
} from './lib.mjs'
|
||||
|
||||
const usage = `Usage:
|
||||
node scripts/updates/prepare-release.mjs --installer <YCode-Setup.exe> --version <x.y.z>
|
||||
--notes-zh <text> --notes-en <text> [--base-url <url>] [--published-at <ISO>]
|
||||
[--private-key <path>] [--public-key <path>] [--output <latest.json>]
|
||||
|
||||
This validates the actual EXE ProductVersion, current package.json version, size and SHA-256,
|
||||
then signs exact JSON.stringify(payload) with Ed25519. It never deploys.
|
||||
`
|
||||
|
||||
async function main() {
|
||||
const options = parseOptions(process.argv.slice(2), {
|
||||
values: ['installer', 'version', 'notes-zh', 'notes-en', 'base-url', 'published-at', 'private-key', 'public-key', 'output']
|
||||
})
|
||||
if (!options.installer || !options.version || !options['notes-zh'] || !options['notes-en']) {
|
||||
throw new Error('--installer, --version, --notes-zh and --notes-en are required')
|
||||
}
|
||||
|
||||
const baseUrl = normalizeBaseUrl(options['base-url'] || DEFAULT_BASE_URL)
|
||||
const installerPath = resolvePath(options.installer)
|
||||
const installer = await verifyReleaseInstaller(installerPath, options.version)
|
||||
const output = resolvePath(options.output || path.join('.tmp', 'updates', options.version, 'latest.json'))
|
||||
const privateKeyPath = await assertOutsideRepo(
|
||||
resolvePath(options['private-key'] || DEFAULT_PRIVATE_KEY),
|
||||
'Private key'
|
||||
)
|
||||
const privateKey = await readPrivateKey(privateKeyPath)
|
||||
const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
|
||||
const publicKey = await readPublicKey(publicKeyPath)
|
||||
const payload = buildManifestPayload({
|
||||
version: options.version,
|
||||
publishedAt: options['published-at'] || canonicalNow(),
|
||||
notes: { zh: options['notes-zh'], en: options['notes-en'] },
|
||||
installer,
|
||||
baseUrl
|
||||
})
|
||||
const document = createSignedManifest({ payload, privateKeyPem: privateKey, publicKeyPem: publicKey, baseUrl })
|
||||
verifySignedManifest(document, publicKey, { baseUrl })
|
||||
await writeFileAtomic(output, serializeSignedManifest(document))
|
||||
await fsp.access(output)
|
||||
|
||||
console.log(`Manifest: ${output}`)
|
||||
console.log(`Version: ${payload.version}`)
|
||||
console.log(`Installer: ${installer.filename}`)
|
||||
console.log(`Size: ${installer.size}`)
|
||||
console.log(`SHA-256: ${installer.sha256}`)
|
||||
console.log(`URL: ${payload.platforms['windows-x64'].url}`)
|
||||
}
|
||||
|
||||
main().catch((error) => usageError(usage, error))
|
||||
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
DEFAULT_SSH_HOST,
|
||||
NGINX_MARKER,
|
||||
NGINX_SITE_LINK,
|
||||
NGINX_SNIPPET,
|
||||
SERVER_PUBLIC_ROOT,
|
||||
SERVER_RELEASES_ROOT,
|
||||
SERVER_STAGING_ROOT,
|
||||
parseOptions,
|
||||
renderNoReleasePage,
|
||||
runSsh,
|
||||
usageError,
|
||||
validateSshHost
|
||||
} from './lib.mjs'
|
||||
|
||||
const usage = `Usage:
|
||||
node scripts/updates/provision-server.mjs [--ssh-host local001]
|
||||
|
||||
Creates /srv/ycode, /srv/ycode/releases/versions and /srv/ycode-staging, backs up the
|
||||
nginx site configuration, installs a YCode-only snippet, validates nginx, then reloads.
|
||||
It never serves an index and never creates a fake latest.json.
|
||||
`
|
||||
|
||||
const snippet = `${NGINX_MARKER}
|
||||
location = /ycode/releases/latest.json {
|
||||
alias ${SERVER_RELEASES_ROOT}/latest.json;
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
limit_except GET { deny all; }
|
||||
}
|
||||
|
||||
location = /ycode/releases/index.html {
|
||||
alias ${SERVER_RELEASES_ROOT}/index.html;
|
||||
default_type text/html;
|
||||
charset utf-8;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
limit_except GET { deny all; }
|
||||
}
|
||||
|
||||
location /ycode/ {
|
||||
alias ${SERVER_PUBLIC_ROOT}/;
|
||||
autoindex off;
|
||||
index index.html;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
limit_except GET { deny all; }
|
||||
}
|
||||
`
|
||||
|
||||
async function main() {
|
||||
const options = parseOptions(process.argv.slice(2), { values: ['ssh-host'] })
|
||||
const host = validateSshHost(options['ssh-host'] || DEFAULT_SSH_HOST)
|
||||
const snippetBase64 = Buffer.from(snippet, 'utf8').toString('base64')
|
||||
const noReleasePageBase64 = Buffer.from(renderNoReleasePage(), 'utf8').toString('base64')
|
||||
const script = `set -euo pipefail
|
||||
export LC_ALL=C
|
||||
|
||||
site_link='${NGINX_SITE_LINK}'
|
||||
snippet='${NGINX_SNIPPET}'
|
||||
site="$(readlink -f "$site_link")"
|
||||
test -f "$site"
|
||||
test -f /etc/nginx/nginx.conf
|
||||
nginx -t >/dev/null 2>&1
|
||||
|
||||
install -d -o root -g www-data -m 0755 '${SERVER_PUBLIC_ROOT}'
|
||||
install -d -o root -g www-data -m 0755 '${SERVER_RELEASES_ROOT}'
|
||||
install -d -o root -g www-data -m 0755 '${SERVER_RELEASES_ROOT}/versions'
|
||||
install -d -o root -g root -m 0700 '${SERVER_STAGING_ROOT}'
|
||||
|
||||
page_tmp="$(mktemp '${SERVER_RELEASES_ROOT}/.index.html.XXXXXX')"
|
||||
printf '%s' '${noReleasePageBase64}' | base64 -d > "$page_tmp"
|
||||
chmod 0644 "$page_tmp"
|
||||
if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
|
||||
if [ ! -f '${SERVER_RELEASES_ROOT}/index.html' ]; then
|
||||
rm -f "$page_tmp"
|
||||
echo "latest.json exists without a generated download page" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'data-ycode-state="no-release"' '${SERVER_RELEASES_ROOT}/index.html'; then
|
||||
rm -f "$page_tmp"
|
||||
echo "latest.json exists while the download page still says no release" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$page_tmp"
|
||||
elif [ ! -f '${SERVER_RELEASES_ROOT}/index.html' ]; then
|
||||
mv -f "$page_tmp" '${SERVER_RELEASES_ROOT}/index.html'
|
||||
else
|
||||
if ! grep -q 'data-ycode-state="no-release"' '${SERVER_RELEASES_ROOT}/index.html'; then
|
||||
rm -f "$page_tmp"
|
||||
echo "download page exists without latest.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$page_tmp"
|
||||
fi
|
||||
|
||||
backup_dir=/var/backups/nginx
|
||||
install -d -o root -g root -m 0700 "$backup_dir"
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
config_backup="$backup_dir/ycode-before-${'$'}{stamp}-$(basename "$site")"
|
||||
snippet_backup="$backup_dir/ycode-before-${'$'}{stamp}-ycode-releases.conf"
|
||||
test ! -e "$config_backup"
|
||||
cp -a "$site" "$config_backup"
|
||||
|
||||
snippet_existed=0
|
||||
if [ -f "$snippet" ]; then
|
||||
cp -a "$snippet" "$snippet_backup"
|
||||
snippet_existed=1
|
||||
fi
|
||||
|
||||
restore() {
|
||||
changed=0
|
||||
cp -a "$config_backup" "$site"
|
||||
if [ "$snippet_existed" -eq 1 ]; then
|
||||
cp -a "$snippet_backup" "$snippet"
|
||||
else
|
||||
rm -f "$snippet"
|
||||
fi
|
||||
}
|
||||
|
||||
changed=0
|
||||
snippet_tmp=
|
||||
cleanup() {
|
||||
rc=$?
|
||||
if [ -n "$snippet_tmp" ]; then
|
||||
rm -f "$snippet_tmp"
|
||||
fi
|
||||
if [ "$rc" -ne 0 ] && [ "$changed" -eq 1 ]; then
|
||||
restore
|
||||
nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
install -d -o root -g root -m 0755 /etc/nginx/snippets
|
||||
snippet_tmp="$(mktemp /etc/nginx/snippets/.ycode-releases.XXXXXX)"
|
||||
printf '%s' '${snippetBase64}' | base64 -d > "$snippet_tmp"
|
||||
chmod 0644 "$snippet_tmp"
|
||||
mv -f "$snippet_tmp" "$snippet"
|
||||
changed=1
|
||||
|
||||
python3 - "$site" <<'PY'
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
site = sys.argv[1]
|
||||
marker = "${NGINX_MARKER}"
|
||||
include = "\tinclude ${NGINX_SNIPPET};"
|
||||
with open(site, "r", encoding="utf-8") as stream:
|
||||
original = stream.read()
|
||||
|
||||
for required in ("location /baocode/ {", "location /baocode/releases/ {"):
|
||||
if original.count(required) != 1:
|
||||
raise SystemExit(f"refusing to change nginx: expected one {required}")
|
||||
|
||||
if marker in original:
|
||||
if include not in original:
|
||||
raise SystemExit("refusing to change nginx: YCode marker exists without include")
|
||||
updated = original
|
||||
else:
|
||||
match = re.search(r"(?m)^([ \\t]*)location /baocode/ \\{", original)
|
||||
if not match:
|
||||
raise SystemExit("refusing to change nginx: baocode anchor not found")
|
||||
indent = match.group(1)
|
||||
insertion = f"{indent}{marker}\\n{indent}include ${NGINX_SNIPPET};\\n\\n"
|
||||
updated = original[:match.start()] + insertion + original[match.start():]
|
||||
|
||||
if updated != original:
|
||||
directory = os.path.dirname(site)
|
||||
descriptor, temporary = tempfile.mkstemp(prefix=".ycode-nginx-", dir=directory)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||
stream.write(updated)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
shutil.copystat(site, temporary)
|
||||
os.replace(temporary, site)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
with open(site, "r", encoding="utf-8") as stream:
|
||||
final = stream.read()
|
||||
for required in ("location /baocode/ {", "location /baocode/releases/ {"):
|
||||
if final.count(required) != 1:
|
||||
raise SystemExit("refusing to change nginx: baocode locations changed")
|
||||
if marker not in final or include not in final:
|
||||
raise SystemExit("nginx include was not installed")
|
||||
PY
|
||||
|
||||
if ! nginx -t; then
|
||||
echo "nginx validation failed; configuration restored" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! systemctl reload nginx; then
|
||||
echo "nginx reload failed; configuration restored" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
status="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1/ycode/releases/latest.json || true)"
|
||||
if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
|
||||
expected_status=200
|
||||
else
|
||||
expected_status=404
|
||||
fi
|
||||
if [ "$status" != "$expected_status" ]; then
|
||||
echo "unexpected /ycode/releases/latest.json status: $status (expected $expected_status)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
page_status="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1/ycode/releases/ || true)"
|
||||
if [ "$page_status" != "200" ]; then
|
||||
echo "unexpected /ycode/releases/ status: $page_status (expected 200)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'site=%s\\n' "$site"
|
||||
printf 'backup=%s\\n' "$config_backup"
|
||||
printf 'snippet=%s\\n' "$snippet"
|
||||
printf 'latest_status=%s\\n' "$status"
|
||||
printf 'page_status=%s\\n' "$page_status"
|
||||
`
|
||||
|
||||
const output = runSsh(host, script)
|
||||
process.stdout.write(output)
|
||||
console.log(`Provisioned ${host}; no release manifest was created.`)
|
||||
}
|
||||
|
||||
main().catch((error) => usageError(usage, error))
|
||||
@@ -0,0 +1,641 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fsp from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
buildManifestPayload,
|
||||
createSignedManifest,
|
||||
generateEd25519KeyPair,
|
||||
normalizeBaseUrl,
|
||||
serializeSignedManifest,
|
||||
writeFileAtomic
|
||||
} from './lib.mjs'
|
||||
import {
|
||||
acquireReleaseLock,
|
||||
defaultReleaseNotes,
|
||||
finalizeReleaseRequest,
|
||||
parseReleaseArgs,
|
||||
releasePaths,
|
||||
resolveVersionChoice,
|
||||
runReleaseWorkflow,
|
||||
updateLockText,
|
||||
updatePackageText,
|
||||
verifyPublishedManifest
|
||||
} from './release-workflow.mjs'
|
||||
|
||||
let passed = 0
|
||||
|
||||
async function test(name, callback) {
|
||||
await callback()
|
||||
passed += 1
|
||||
console.log(`PASS ${name}`)
|
||||
}
|
||||
|
||||
function expectedError(pattern) {
|
||||
return (error) => {
|
||||
assert.match(String(error?.message || error), pattern)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function packageText(version) {
|
||||
return `${JSON.stringify({
|
||||
name: 'ycode',
|
||||
version,
|
||||
private: true,
|
||||
scripts: { build: 'fixture-build' },
|
||||
build: { appId: 'com.ycode.app', productName: 'YCode' }
|
||||
}, null, 2)}\n`
|
||||
}
|
||||
|
||||
function lockText(version) {
|
||||
return `${JSON.stringify({
|
||||
name: 'ycode',
|
||||
version,
|
||||
lockfileVersion: 3,
|
||||
requires: true,
|
||||
packages: {
|
||||
'': {
|
||||
name: 'ycode',
|
||||
version,
|
||||
private: true,
|
||||
dependencies: { fixture: '1.0.0' }
|
||||
},
|
||||
'node_modules/fixture': {
|
||||
version: '1.0.0'
|
||||
}
|
||||
}
|
||||
}, null, 2)}\n`
|
||||
}
|
||||
|
||||
function signedManifest({ version, keys, baseUrl }) {
|
||||
const payload = buildManifestPayload({
|
||||
version,
|
||||
publishedAt: '2026-10-20T10:00:00.000Z',
|
||||
notes: defaultReleaseNotes(version),
|
||||
installer: {
|
||||
filename: `YCode-${version}-Setup-x64.exe`,
|
||||
size: 1,
|
||||
sha256: 'a'.repeat(64)
|
||||
},
|
||||
baseUrl
|
||||
})
|
||||
const document = createSignedManifest({
|
||||
payload,
|
||||
privateKeyPem: keys.privatePem,
|
||||
publicKeyPem: keys.publicPem,
|
||||
baseUrl
|
||||
})
|
||||
return {
|
||||
document,
|
||||
text: serializeSignedManifest(document, { baseUrl })
|
||||
}
|
||||
}
|
||||
|
||||
async function makeRepository({ version = '1.0.0' } = {}) {
|
||||
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-selftest-'))
|
||||
await fsp.writeFile(path.join(root, 'package.json'), packageText(version), 'utf8')
|
||||
await fsp.writeFile(path.join(root, 'package-lock.json'), lockText(version), 'utf8')
|
||||
return root
|
||||
}
|
||||
|
||||
function requestFor({ version, notes, privateKeyPath, publicKeyPath, resume = false }) {
|
||||
return {
|
||||
help: false,
|
||||
resume,
|
||||
version,
|
||||
notes: notes || defaultReleaseNotes(version),
|
||||
sshHost: 'fake-local001',
|
||||
baseUrl: normalizeBaseUrl('http://127.0.0.1/ycode/releases/'),
|
||||
privateKeyPath,
|
||||
publicKeyPath
|
||||
}
|
||||
}
|
||||
|
||||
function makeDependencies({
|
||||
root,
|
||||
keys,
|
||||
current,
|
||||
events,
|
||||
overrides = {}
|
||||
}) {
|
||||
const paths = releasePaths(root, current?.version || '9.9.9')
|
||||
const deps = {
|
||||
root,
|
||||
pathExists: (filename) => fsp.access(filename).then(() => true, () => false),
|
||||
realpath: (filename) => fsp.realpath(filename),
|
||||
readFile: (filename, encoding) => fsp.readFile(filename, encoding),
|
||||
writeFileAtomic,
|
||||
readPrivateKey: async () => keys.privatePem,
|
||||
readPublicKey: async () => keys.publicPem,
|
||||
validateBundle: async ({ manifestPath }) => {
|
||||
events.push('bundle-validate')
|
||||
const document = JSON.parse(await fsp.readFile(manifestPath, 'utf8'))
|
||||
return { document }
|
||||
},
|
||||
acquireReleaseLock: async () => {
|
||||
events.push('lock-acquire')
|
||||
return async () => {
|
||||
events.push('lock-release')
|
||||
}
|
||||
},
|
||||
checkToolchain: async () => {
|
||||
events.push('toolchain-check')
|
||||
},
|
||||
checkSsh: async () => {
|
||||
events.push('ssh-check')
|
||||
},
|
||||
readLatest: async () => {
|
||||
events.push('server-read')
|
||||
return current ? { raw: current.text, document: current.document } : { raw: null, document: null }
|
||||
},
|
||||
runNpm: async (args) => {
|
||||
events.push(`npm:${args.join(' ')}`)
|
||||
},
|
||||
runNode: async (args) => {
|
||||
events.push(`node:${args.join(' ')}`)
|
||||
if (args.some((value) => String(value).endsWith('prepare-release.mjs'))) {
|
||||
const version = args[args.indexOf('--version') + 1]
|
||||
const output = args[args.indexOf('--output') + 1]
|
||||
await writeFileAtomic(output, signedManifest({ version, keys, baseUrl: 'http://127.0.0.1/ycode/releases/' }).text)
|
||||
}
|
||||
},
|
||||
verifyHttp: async () => {
|
||||
events.push('http-verify')
|
||||
},
|
||||
logWarning: (message) => events.push(`warning:${message}`),
|
||||
...overrides
|
||||
}
|
||||
deps.paths = paths
|
||||
return deps
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const temporaryDirectories = []
|
||||
let signingKeyProbePath
|
||||
try {
|
||||
const keyProbeDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-key-probe-'))
|
||||
temporaryDirectories.push(keyProbeDirectory)
|
||||
signingKeyProbePath = path.join(keyProbeDirectory, 'signing.key')
|
||||
await fsp.writeFile(signingKeyProbePath, 'fixture key location only', 'utf8')
|
||||
|
||||
await test('stable version input rejects ambiguous and non-release versions', async () => {
|
||||
for (const value of ['01.0.0', '1.01.0', '1.0.01', '1.0.0-rc.1', '1.0.0+build', 'v1.0.0', '1.0']) {
|
||||
await assert.rejects(() => resolveVersionChoice({ version: value, prompt: async () => '' }), /stable x\.y\.z/)
|
||||
}
|
||||
for (const value of ['65536.0.0', '1.65536.0', '1.0.65536']) {
|
||||
await assert.rejects(() => resolveVersionChoice({ version: value, prompt: async () => '' }), /Windows EXE metadata/)
|
||||
}
|
||||
assert.equal(
|
||||
await resolveVersionChoice({ version: '1.2.3', prompt: async () => assert.fail('prompt should not run') }),
|
||||
'1.2.3'
|
||||
)
|
||||
})
|
||||
|
||||
await test('blank interactive version cancels without starting a release', async () => {
|
||||
assert.equal(await resolveVersionChoice({ prompt: async () => ' ' }), null)
|
||||
})
|
||||
|
||||
await test('CLI defaults and unknown option handling are deterministic', async () => {
|
||||
const parsed = parseReleaseArgs(['--version', '1.2.3'])
|
||||
assert.equal(parsed.version, '1.2.3')
|
||||
assert.deepEqual(parsed.notes, {})
|
||||
const finalized = finalizeReleaseRequest(parsed, parsed.version)
|
||||
assert.deepEqual(finalized.notes, {
|
||||
zh: 'YCode 1.2.3 更新',
|
||||
en: 'YCode 1.2.3 update'
|
||||
})
|
||||
assert.equal(parsed.sshHost, 'local001')
|
||||
await assert.rejects(async () => parseReleaseArgs(['--dry-run']), /Unknown option/)
|
||||
await assert.rejects(async () => parseReleaseArgs(['--public-key', 'other.pem']), /Unknown option/)
|
||||
await assert.rejects(async () => parseReleaseArgs(['--resume']), /requires an explicit --version/)
|
||||
assert.equal(parseReleaseArgs(['--help']).help, true)
|
||||
})
|
||||
|
||||
await test('interactive version supplies notes from the actual input version', async () => {
|
||||
const parsed = parseReleaseArgs([])
|
||||
const version = await resolveVersionChoice({ version: parsed.version, prompt: async () => '0.0.6' })
|
||||
const finalized = finalizeReleaseRequest(parsed, version)
|
||||
assert.deepEqual(finalized.notes, {
|
||||
zh: 'YCode 0.0.6 更新',
|
||||
en: 'YCode 0.0.6 update'
|
||||
})
|
||||
})
|
||||
|
||||
await test('package and lock version updates preserve unrelated JSON content', async () => {
|
||||
const nextPackage = updatePackageText(packageText('1.0.0'), '1.0.1')
|
||||
const nextLock = updateLockText(lockText('1.0.0'), '1.0.1')
|
||||
const pkg = JSON.parse(nextPackage)
|
||||
const lock = JSON.parse(nextLock)
|
||||
assert.equal(pkg.version, '1.0.1')
|
||||
assert.equal(pkg.scripts.build, 'fixture-build')
|
||||
assert.equal(pkg.build.appId, 'com.ycode.app')
|
||||
assert.equal(lock.version, '1.0.1')
|
||||
assert.equal(lock.packages[''].version, '1.0.1')
|
||||
assert.equal(lock.packages['node_modules/fixture'].version, '1.0.0')
|
||||
})
|
||||
|
||||
const normalRoot = await makeRepository({ version: '1.0.0' })
|
||||
temporaryDirectories.push(normalRoot)
|
||||
await test('normal release follows the required stage and command order', async () => {
|
||||
const keys = generateEd25519KeyPair()
|
||||
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
|
||||
const current = signedManifest({ version: '1.0.0', keys, baseUrl })
|
||||
const events = []
|
||||
const deps = makeDependencies({
|
||||
root: normalRoot,
|
||||
keys,
|
||||
current: { ...current, version: '1.0.0' },
|
||||
events
|
||||
})
|
||||
const request = requestFor({
|
||||
version: '1.0.1',
|
||||
privateKeyPath: signingKeyProbePath,
|
||||
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
|
||||
})
|
||||
const result = await runReleaseWorkflow(request, deps)
|
||||
assert.equal(result.version, '1.0.1')
|
||||
const paths = releasePaths(normalRoot, '1.0.1')
|
||||
assert.deepEqual(events.slice(0, 7), [
|
||||
'lock-acquire',
|
||||
'toolchain-check',
|
||||
'ssh-check',
|
||||
'server-read',
|
||||
'npm:run test:updates',
|
||||
'npm:run build',
|
||||
`node:${paths.builderCli} --win nsis --x64 --publish never --config.directories.output=release/publish/1.0.1`
|
||||
])
|
||||
assert.match(events[7], /prepare-release\.mjs/)
|
||||
assert.match(events[8], /deploy\.mjs.*--dry-run/)
|
||||
assert.match(events[9], /deploy\.mjs/)
|
||||
assert.doesNotMatch(events[9], /--dry-run/)
|
||||
assert.deepEqual(events.slice(10), ['http-verify', 'lock-release'])
|
||||
assert.equal(JSON.parse(await fsp.readFile(path.join(normalRoot, 'package.json'), 'utf8')).version, '1.0.1')
|
||||
assert.equal(JSON.parse(await fsp.readFile(path.join(normalRoot, 'package-lock.json'), 'utf8')).version, '1.0.1')
|
||||
})
|
||||
|
||||
await test('server/latest and repository versions are rejected before build', async () => {
|
||||
for (const scenario of [
|
||||
{ repo: '1.0.0', server: '1.0.0', target: '1.0.0', pattern: /strictly newer/ },
|
||||
{ repo: '1.0.0', server: '0.9.0', target: '0.9.9', pattern: /repository version is newer/ }
|
||||
]) {
|
||||
const root = await makeRepository({ version: scenario.repo })
|
||||
temporaryDirectories.push(root)
|
||||
const keys = generateEd25519KeyPair()
|
||||
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
|
||||
const server = signedManifest({ version: scenario.server, keys, baseUrl })
|
||||
const events = []
|
||||
const deps = makeDependencies({
|
||||
root,
|
||||
keys,
|
||||
current: { ...server, version: scenario.server },
|
||||
events
|
||||
})
|
||||
const before = await fsp.readFile(path.join(root, 'package.json'), 'utf8')
|
||||
await assert.rejects(
|
||||
runReleaseWorkflow(
|
||||
requestFor({
|
||||
version: scenario.target,
|
||||
privateKeyPath: signingKeyProbePath,
|
||||
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
|
||||
}),
|
||||
deps
|
||||
),
|
||||
expectedError(scenario.pattern)
|
||||
)
|
||||
assert.equal(await fsp.readFile(path.join(root, 'package.json'), 'utf8'), before)
|
||||
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
|
||||
assert.equal(events.at(-1), 'lock-release')
|
||||
}
|
||||
})
|
||||
|
||||
await test('missing signing key fails in preflight without build or deploy', async () => {
|
||||
const root = await makeRepository({ version: '1.0.0' })
|
||||
temporaryDirectories.push(root)
|
||||
const keys = generateEd25519KeyPair()
|
||||
const events = []
|
||||
const deps = makeDependencies({
|
||||
root,
|
||||
keys,
|
||||
current: null,
|
||||
events,
|
||||
overrides: {
|
||||
pathExists: async (filename) =>
|
||||
filename === path.join(os.tmpdir(), 'missing-signing.key')
|
||||
? false
|
||||
: fsp.access(filename).then(() => true, () => false),
|
||||
readPrivateKey: async () => {
|
||||
throw new Error('Unable to read update signing key')
|
||||
}
|
||||
}
|
||||
})
|
||||
await assert.rejects(
|
||||
runReleaseWorkflow(
|
||||
requestFor({
|
||||
version: '1.0.1',
|
||||
privateKeyPath: path.join(os.tmpdir(), 'missing-signing.key'),
|
||||
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
|
||||
}),
|
||||
deps
|
||||
),
|
||||
(error) => error.stage === 'preflight' && /Signing key/.test(error.message)
|
||||
)
|
||||
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
|
||||
assert.equal(events.at(-1), 'lock-release')
|
||||
})
|
||||
|
||||
await test('mismatched private and public keys fail before any external stage', async () => {
|
||||
const root = await makeRepository({ version: '1.0.0' })
|
||||
temporaryDirectories.push(root)
|
||||
const privateKeys = generateEd25519KeyPair()
|
||||
const publicKeys = generateEd25519KeyPair()
|
||||
const events = []
|
||||
const deps = makeDependencies({
|
||||
root,
|
||||
keys: privateKeys,
|
||||
current: null,
|
||||
events,
|
||||
overrides: {
|
||||
readPublicKey: async () => publicKeys.publicPem
|
||||
}
|
||||
})
|
||||
await assert.rejects(
|
||||
runReleaseWorkflow(
|
||||
requestFor({
|
||||
version: '1.0.1',
|
||||
privateKeyPath: signingKeyProbePath,
|
||||
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
|
||||
}),
|
||||
deps
|
||||
),
|
||||
(error) => error.stage === 'preflight' && /does not match/.test(error.message)
|
||||
)
|
||||
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:') || event === 'ssh-check'))
|
||||
assert.equal(events.at(-1), 'lock-release')
|
||||
})
|
||||
|
||||
await test('private key canonical path cannot resolve into the repository', async () => {
|
||||
const root = await makeRepository({ version: '1.0.0' })
|
||||
temporaryDirectories.push(root)
|
||||
const keys = generateEd25519KeyPair()
|
||||
const events = []
|
||||
const deps = makeDependencies({
|
||||
root,
|
||||
keys,
|
||||
current: null,
|
||||
events,
|
||||
overrides: {
|
||||
realpath: async () => path.join(root, 'linked-signing.key')
|
||||
}
|
||||
})
|
||||
await assert.rejects(
|
||||
runReleaseWorkflow(
|
||||
requestFor({
|
||||
version: '1.0.1',
|
||||
privateKeyPath: signingKeyProbePath,
|
||||
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
|
||||
}),
|
||||
deps
|
||||
),
|
||||
(error) => error.stage === 'preflight' && /resolves inside the repository/.test(error.message)
|
||||
)
|
||||
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
|
||||
assert.equal(events.at(-1), 'lock-release')
|
||||
})
|
||||
|
||||
await test('a repository path beginning with ..foo is not treated as external', async () => {
|
||||
const root = await makeRepository({ version: '1.0.0' })
|
||||
temporaryDirectories.push(root)
|
||||
const keys = generateEd25519KeyPair()
|
||||
const events = []
|
||||
const deps = makeDependencies({
|
||||
root,
|
||||
keys,
|
||||
current: null,
|
||||
events
|
||||
})
|
||||
const nestedKey = path.join(root, '..foo', 'signing.key')
|
||||
await fsp.mkdir(path.dirname(nestedKey), { recursive: true })
|
||||
await fsp.writeFile(nestedKey, 'fixture inside-repository key', 'utf8')
|
||||
await assert.rejects(
|
||||
runReleaseWorkflow(
|
||||
requestFor({
|
||||
version: '1.0.1',
|
||||
privateKeyPath: nestedKey,
|
||||
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
|
||||
}),
|
||||
deps
|
||||
),
|
||||
(error) => error.stage === 'preflight' && /must be outside the repository/.test(error.message)
|
||||
)
|
||||
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:') || event === 'ssh-check'))
|
||||
assert.equal(events.at(-1), 'lock-release')
|
||||
})
|
||||
|
||||
await test('a failed build stops before signing and deploy while retaining local version', async () => {
|
||||
const root = await makeRepository({ version: '1.0.0' })
|
||||
temporaryDirectories.push(root)
|
||||
const keys = generateEd25519KeyPair()
|
||||
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
|
||||
const current = signedManifest({ version: '1.0.0', keys, baseUrl })
|
||||
const events = []
|
||||
const deps = makeDependencies({
|
||||
root,
|
||||
keys,
|
||||
current: { ...current, version: '1.0.0' },
|
||||
events,
|
||||
overrides: {
|
||||
runNpm: async (args) => {
|
||||
events.push(`npm:${args.join(' ')}`)
|
||||
if (args.join(' ') === 'run build') throw new Error('fixture build failure')
|
||||
}
|
||||
}
|
||||
})
|
||||
await assert.rejects(
|
||||
runReleaseWorkflow(
|
||||
requestFor({
|
||||
version: '1.0.1',
|
||||
privateKeyPath: signingKeyProbePath,
|
||||
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
|
||||
}),
|
||||
deps
|
||||
),
|
||||
(error) => error.stage === 'build' && /fixture build failure/.test(error.message)
|
||||
)
|
||||
assert(!events.some((event) => event.includes('prepare-release') || event.includes('deploy.mjs')))
|
||||
assert.equal(JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8')).version, '1.0.1')
|
||||
assert.equal(events.at(-1), 'lock-release')
|
||||
})
|
||||
|
||||
await test('resume revalidates the existing bundle and never rebuilds or re-signs', async () => {
|
||||
const root = await makeRepository({ version: '1.0.1' })
|
||||
temporaryDirectories.push(root)
|
||||
const keys = generateEd25519KeyPair()
|
||||
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
|
||||
const local = signedManifest({ version: '1.0.1', keys, baseUrl })
|
||||
const paths = releasePaths(root, '1.0.1')
|
||||
await fsp.mkdir(paths.outputDir, { recursive: true })
|
||||
await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
|
||||
await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
|
||||
const events = []
|
||||
const deps = makeDependencies({
|
||||
root,
|
||||
keys,
|
||||
current: { ...local, version: '1.0.1' },
|
||||
events
|
||||
})
|
||||
await runReleaseWorkflow(
|
||||
requestFor({
|
||||
version: '1.0.1',
|
||||
resume: true,
|
||||
privateKeyPath: signingKeyProbePath,
|
||||
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
|
||||
}),
|
||||
deps
|
||||
)
|
||||
assert(events.includes('bundle-validate'))
|
||||
assert(!events.some((event) => event.startsWith('npm:') || event.includes('prepare-release')))
|
||||
assert.equal(events.filter((event) => event.includes('deploy.mjs')).length, 2)
|
||||
assert.equal(events.at(-2), 'http-verify')
|
||||
assert.equal(events.at(-1), 'lock-release')
|
||||
})
|
||||
|
||||
await test('HTTP verification failure is reported as a failed release', async () => {
|
||||
const root = await makeRepository({ version: '1.0.1' })
|
||||
temporaryDirectories.push(root)
|
||||
const keys = generateEd25519KeyPair()
|
||||
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
|
||||
const local = signedManifest({ version: '1.0.1', keys, baseUrl })
|
||||
const paths = releasePaths(root, '1.0.1')
|
||||
await fsp.mkdir(paths.outputDir, { recursive: true })
|
||||
await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
|
||||
await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
|
||||
const events = []
|
||||
const deps = makeDependencies({
|
||||
root,
|
||||
keys,
|
||||
current: null,
|
||||
events,
|
||||
overrides: {
|
||||
verifyHttp: async () => {
|
||||
events.push('http-verify')
|
||||
throw new Error('fixture HTTP mismatch')
|
||||
}
|
||||
}
|
||||
})
|
||||
await assert.rejects(
|
||||
runReleaseWorkflow(
|
||||
requestFor({
|
||||
version: '1.0.1',
|
||||
resume: true,
|
||||
privateKeyPath: signingKeyProbePath,
|
||||
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
|
||||
}),
|
||||
deps
|
||||
),
|
||||
(error) => error.stage === 'verify-http' && /HTTP mismatch/.test(error.message)
|
||||
)
|
||||
assert(events.some((event) => event.includes('deploy.mjs')))
|
||||
assert.equal(events.at(-1), 'lock-release')
|
||||
})
|
||||
|
||||
await test('resume permits the same server version but rejects a newer server latest', async () => {
|
||||
const root = await makeRepository({ version: '1.0.1' })
|
||||
temporaryDirectories.push(root)
|
||||
const keys = generateEd25519KeyPair()
|
||||
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
|
||||
const local = signedManifest({ version: '1.0.1', keys, baseUrl })
|
||||
const newer = signedManifest({ version: '1.0.2', keys, baseUrl })
|
||||
const paths = releasePaths(root, '1.0.1')
|
||||
await fsp.mkdir(paths.outputDir, { recursive: true })
|
||||
await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
|
||||
await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
|
||||
const events = []
|
||||
const deps = makeDependencies({
|
||||
root,
|
||||
keys,
|
||||
current: { ...newer, version: '1.0.2' },
|
||||
events
|
||||
})
|
||||
await assert.rejects(
|
||||
runReleaseWorkflow(
|
||||
requestFor({
|
||||
version: '1.0.1',
|
||||
resume: true,
|
||||
privateKeyPath: signingKeyProbePath,
|
||||
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
|
||||
}),
|
||||
deps
|
||||
),
|
||||
(error) => error.stage === 'preflight' && /server latest is newer/.test(error.message)
|
||||
)
|
||||
assert(!events.some((event) => event.includes('deploy.mjs')))
|
||||
assert.equal(events.at(-1), 'lock-release')
|
||||
})
|
||||
|
||||
await test('release lock is mutually exclusive and released explicitly', async () => {
|
||||
const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-lock-selftest-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const lockPath = path.join(directory, '.tmp', 'updates', 'release.lock')
|
||||
const release = await acquireReleaseLock(lockPath, { version: '1.0.1' })
|
||||
await assert.rejects(acquireReleaseLock(lockPath, { version: '1.0.2' }), /already exists/)
|
||||
assert.equal(await fsp.readFile(lockPath, 'utf8').then(() => true, () => false), true)
|
||||
await release()
|
||||
assert.equal(await fsp.readFile(lockPath, 'utf8').then(() => true, () => false), false)
|
||||
const second = await acquireReleaseLock(lockPath, { version: '1.0.2' })
|
||||
await second()
|
||||
})
|
||||
|
||||
await test('HTTP verification checks no-cache request, exact bytes and signature', async () => {
|
||||
const keys = generateEd25519KeyPair()
|
||||
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
|
||||
const local = signedManifest({ version: '1.0.2', keys, baseUrl })
|
||||
let requestOptions
|
||||
const response = new Response(local.text, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json', 'content-length': String(Buffer.byteLength(local.text)) }
|
||||
})
|
||||
const document = await verifyPublishedManifest({
|
||||
baseUrl,
|
||||
expectedManifestText: local.text,
|
||||
publicKeyPem: keys.publicPem,
|
||||
version: '1.0.2',
|
||||
fetchImpl: async (_url, options) => {
|
||||
requestOptions = options
|
||||
return response
|
||||
}
|
||||
})
|
||||
assert.equal(document.payload.version, '1.0.2')
|
||||
assert.equal(requestOptions.cache, 'no-store')
|
||||
assert.equal(requestOptions.credentials, 'omit')
|
||||
assert.equal(requestOptions.redirect, 'error')
|
||||
assert.equal(requestOptions.headers.pragma, 'no-cache')
|
||||
|
||||
await assert.rejects(
|
||||
verifyPublishedManifest({
|
||||
baseUrl,
|
||||
expectedManifestText: local.text,
|
||||
publicKeyPem: keys.publicPem,
|
||||
version: '1.0.2',
|
||||
fetchImpl: async () =>
|
||||
new Response(local.text.replace('YCode 1.0.2', 'YCode 1.0.3'), {
|
||||
status: 200,
|
||||
headers: { 'content-length': String(Buffer.byteLength(local.text)) }
|
||||
})
|
||||
}),
|
||||
/does not exactly match/
|
||||
)
|
||||
})
|
||||
|
||||
console.log(`\n${passed} release workflow self-tests passed. No SSH, network HTTP, build, installer, signing, deployment, repository version change or production release was performed.`)
|
||||
} finally {
|
||||
for (const directory of temporaryDirectories) {
|
||||
await fsp.rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,591 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_PRIVATE_KEY,
|
||||
DEFAULT_PUBLIC_KEY,
|
||||
DEFAULT_SSH_HOST,
|
||||
assert,
|
||||
assertOutsideRepo,
|
||||
compareVersions,
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
normalizeBaseUrl,
|
||||
parseVersion,
|
||||
verifySignedManifest
|
||||
} from './lib.mjs'
|
||||
|
||||
export const STABLE_VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
|
||||
export const RELEASE_LOCK_RELATIVE = path.join('.tmp', 'updates', 'release.lock')
|
||||
export const MAX_HTTP_MANIFEST_BYTES = 1024 * 1024
|
||||
|
||||
const CLI_VALUE_OPTIONS = new Set([
|
||||
'version',
|
||||
'notes-zh',
|
||||
'notes-en',
|
||||
'ssh-host',
|
||||
'base-url',
|
||||
'private-key'
|
||||
])
|
||||
|
||||
export class ReleaseError extends Error {
|
||||
constructor(stage, message, options = {}) {
|
||||
super(message, options)
|
||||
this.name = 'ReleaseError'
|
||||
this.stage = stage
|
||||
}
|
||||
}
|
||||
|
||||
export function toReleaseError(stage, error) {
|
||||
if (error instanceof ReleaseError) return error
|
||||
return new ReleaseError(stage, String(error?.message || error), { cause: error })
|
||||
}
|
||||
|
||||
async function runStage(stage, action) {
|
||||
try {
|
||||
return await action()
|
||||
} catch (error) {
|
||||
throw toReleaseError(stage, error)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateStableVersion(value) {
|
||||
const version = String(value ?? '')
|
||||
assert(
|
||||
STABLE_VERSION_RE.test(version),
|
||||
'Release version must be a stable x.y.z version without a leading zero, prerelease or build metadata'
|
||||
)
|
||||
parseVersion(version)
|
||||
for (const segment of version.split('.').map(BigInt)) {
|
||||
assert(segment <= 65_535n, 'Release version segments must be between 0 and 65535 for Windows EXE metadata')
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
export function defaultReleaseNotes(version) {
|
||||
validateStableVersion(version)
|
||||
return {
|
||||
zh: `YCode ${version} 更新`,
|
||||
en: `YCode ${version} update`
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveVersionChoice({ version, prompt }) {
|
||||
if (version !== undefined) return validateStableVersion(version)
|
||||
const answer = await prompt()
|
||||
if (!answer || !answer.trim()) return null
|
||||
return validateStableVersion(answer.trim())
|
||||
}
|
||||
|
||||
export function parseReleaseArgs(argv, { cwd = process.cwd() } = {}) {
|
||||
const raw = {}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index]
|
||||
if (!argument.startsWith('--')) throw new Error(`Unexpected argument: ${argument}`)
|
||||
const equals = argument.indexOf('=')
|
||||
const name = argument.slice(2, equals === -1 ? undefined : equals)
|
||||
if (name === 'help' || name === 'resume') {
|
||||
assert(equals === -1, `--${name} does not accept a value`)
|
||||
assert(!(name in raw), `Option --${name} was provided more than once`)
|
||||
raw[name] = true
|
||||
continue
|
||||
}
|
||||
assert(CLI_VALUE_OPTIONS.has(name), `Unknown option: --${name}`)
|
||||
assert(!(name in raw), `Option --${name} was provided more than once`)
|
||||
const value = equals === -1 ? argv[++index] : argument.slice(equals + 1)
|
||||
assert(value !== undefined && value !== '', `Missing value for --${name}`)
|
||||
raw[name] = value
|
||||
}
|
||||
|
||||
if (raw.help) {
|
||||
return { help: true }
|
||||
}
|
||||
if (raw.resume) {
|
||||
assert(raw.version, '--resume requires an explicit --version')
|
||||
assert(!raw['notes-zh'] && !raw['notes-en'], '--notes-zh and --notes-en cannot be used with --resume')
|
||||
}
|
||||
|
||||
const version = raw.version === undefined ? undefined : validateStableVersion(raw.version)
|
||||
const notes = {}
|
||||
if (raw['notes-zh'] !== undefined) notes.zh = raw['notes-zh'].trim()
|
||||
if (raw['notes-en'] !== undefined) notes.en = raw['notes-en'].trim()
|
||||
if ('zh' in notes) assert(notes.zh, '--notes-zh must not be empty')
|
||||
if ('en' in notes) assert(notes.en, '--notes-en must not be empty')
|
||||
|
||||
return {
|
||||
help: false,
|
||||
resume: raw.resume === true,
|
||||
version,
|
||||
notes,
|
||||
sshHost: raw['ssh-host'] || DEFAULT_SSH_HOST,
|
||||
baseUrl: normalizeBaseUrl(raw['base-url'] || DEFAULT_BASE_URL),
|
||||
privateKeyPath: path.resolve(cwd, raw['private-key'] || DEFAULT_PRIVATE_KEY),
|
||||
publicKeyPath: path.resolve(cwd, DEFAULT_PUBLIC_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
export function finalizeReleaseRequest(parsed, version) {
|
||||
validateStableVersion(version)
|
||||
const defaults = defaultReleaseNotes(version)
|
||||
return {
|
||||
...parsed,
|
||||
version,
|
||||
notes: {
|
||||
zh: parsed.notes?.zh ?? defaults.zh,
|
||||
en: parsed.notes?.en ?? defaults.en
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function releasePaths(root, version) {
|
||||
validateStableVersion(version)
|
||||
const outputDir = path.join(root, 'release', 'publish', version)
|
||||
return {
|
||||
root,
|
||||
packagePath: path.join(root, 'package.json'),
|
||||
lockPath: path.join(root, RELEASE_LOCK_RELATIVE),
|
||||
outputDir,
|
||||
installerPath: path.join(outputDir, `YCode-${version}-Setup-x64.exe`),
|
||||
manifestPath: path.join(outputDir, 'latest.json'),
|
||||
builderCli: path.join(root, 'node_modules', 'electron-builder', 'cli.js'),
|
||||
prepareScript: path.join(root, 'scripts', 'updates', 'prepare-release.mjs'),
|
||||
deployScript: path.join(root, 'scripts', 'updates', 'deploy.mjs')
|
||||
}
|
||||
}
|
||||
|
||||
function isOutside(root, filename) {
|
||||
const relative = path.relative(path.resolve(root), path.resolve(filename))
|
||||
return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
|
||||
}
|
||||
|
||||
async function canonicalPrivateKeyPath({ root, filename, deps }) {
|
||||
const lexical = path.resolve(filename)
|
||||
assert(isOutside(root, lexical), `Private key must be outside the repository: ${lexical}`)
|
||||
await assertOutsideRepo(lexical, 'Private key')
|
||||
if (!(await deps.pathExists(lexical))) throw new Error(`Signing key not found: ${lexical}`)
|
||||
const canonical = await deps.realpath(lexical)
|
||||
assert(isOutside(root, canonical), `Private key resolves inside the repository: ${canonical}`)
|
||||
await assertOutsideRepo(canonical, 'Private key')
|
||||
return canonical
|
||||
}
|
||||
|
||||
export function assertKeyPairMatches(privatePem, publicPem) {
|
||||
const privateKey = createPrivateKey(privatePem)
|
||||
const derived = createPublicKey(privateKey).export({ type: 'spki', format: 'der' })
|
||||
const supplied = createPublicKey(publicPem).export({ type: 'spki', format: 'der' })
|
||||
assert(derived.equals(supplied), 'Signing private key does not match the update public key')
|
||||
}
|
||||
|
||||
function parseJson(text, label) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch (error) {
|
||||
throw new Error(`${label} is not valid JSON: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function jsonFormat(text) {
|
||||
const newline = text.includes('\r\n') ? '\r\n' : '\n'
|
||||
const indentMatch = text.match(/\n([ \t]+)"/)
|
||||
return {
|
||||
indent: indentMatch?.[1] || ' ',
|
||||
newline,
|
||||
trailingNewline: text.endsWith('\n')
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteJson(text, mutate) {
|
||||
const value = parseJson(text, 'JSON document')
|
||||
mutate(value)
|
||||
const format = jsonFormat(text)
|
||||
let serialized = JSON.stringify(value, null, format.indent).replace(/\n/g, format.newline)
|
||||
if (format.trailingNewline) serialized += format.newline
|
||||
return serialized
|
||||
}
|
||||
|
||||
export function updatePackageText(text, version) {
|
||||
validateStableVersion(version)
|
||||
return rewriteJson(text, (document) => {
|
||||
assert(document.name === 'ycode', 'package.json is not the YCode package')
|
||||
assert(document.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
|
||||
document.version = version
|
||||
})
|
||||
}
|
||||
|
||||
export function updateLockText(text, version) {
|
||||
validateStableVersion(version)
|
||||
return rewriteJson(text, (document) => {
|
||||
assert(document && typeof document === 'object', 'package-lock.json must be an object')
|
||||
assert(
|
||||
document.packages && typeof document.packages === 'object',
|
||||
'package-lock.json has no root package entry'
|
||||
)
|
||||
assert(
|
||||
document.packages[''] && typeof document.packages[''] === 'object',
|
||||
'package-lock.json has no packages[""] entry'
|
||||
)
|
||||
document.version = version
|
||||
document.packages[''].version = version
|
||||
})
|
||||
}
|
||||
|
||||
function packageVersions(packageText, lockText) {
|
||||
const pkg = parseJson(packageText, 'package.json')
|
||||
const lock = parseJson(lockText, 'package-lock.json')
|
||||
assert(pkg.name === 'ycode', 'package.json is not the YCode package')
|
||||
assert(pkg.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
|
||||
const version = validateStableVersion(pkg.version)
|
||||
assert(
|
||||
lock.version !== undefined && lock.packages?.['']?.version !== undefined,
|
||||
'package-lock.json must contain top-level and packages[""] versions'
|
||||
)
|
||||
assert(lock.version === version, 'package-lock.json top-level version does not match package.json')
|
||||
assert(lock.packages[''].version === version, 'package-lock.json root version does not match package.json')
|
||||
return { pkg, lock, version }
|
||||
}
|
||||
|
||||
function sameManifest(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
}
|
||||
|
||||
function parseSignedManifest(text, publicKeyPem, baseUrl, label) {
|
||||
const document = parseJson(text, label)
|
||||
verifySignedManifest(document, publicKeyPem, { baseUrl })
|
||||
return document
|
||||
}
|
||||
|
||||
export async function preflightRelease({ request, paths, deps }) {
|
||||
const publicKeyPath = path.resolve(request.publicKeyPath)
|
||||
const privateKeyPath = await canonicalPrivateKeyPath({
|
||||
root: paths.root,
|
||||
filename: request.privateKeyPath,
|
||||
deps
|
||||
})
|
||||
|
||||
const privatePem = await deps.readPrivateKey(privateKeyPath)
|
||||
const publicPem = await deps.readPublicKey(publicKeyPath)
|
||||
assertKeyPairMatches(privatePem, publicPem)
|
||||
|
||||
const packageLockPath = path.join(paths.root, 'package-lock.json')
|
||||
for (const [label, filename] of [
|
||||
['package.json', paths.packagePath],
|
||||
['package-lock.json', packageLockPath]
|
||||
]) {
|
||||
assert(await deps.pathExists(filename), `${label} not found: ${filename}`)
|
||||
}
|
||||
|
||||
const packageText = await deps.readFile(paths.packagePath, 'utf8')
|
||||
const lockText = await deps.readFile(packageLockPath, 'utf8')
|
||||
const versions = packageVersions(packageText, lockText)
|
||||
|
||||
if (request.resume) {
|
||||
assert(
|
||||
versions.version === request.version,
|
||||
`--resume requires repository version ${request.version}; found ${versions.version}`
|
||||
)
|
||||
assert(await deps.pathExists(paths.outputDir), `Resume output directory not found: ${paths.outputDir}`)
|
||||
assert(await deps.pathExists(paths.installerPath), `Resume installer not found: ${paths.installerPath}`)
|
||||
assert(await deps.pathExists(paths.manifestPath), `Resume manifest not found: ${paths.manifestPath}`)
|
||||
} else {
|
||||
const compared = compareVersions(request.version, versions.version)
|
||||
assert(
|
||||
compared >= 0,
|
||||
`Refusing to release ${request.version}; repository version is newer (${versions.version})`
|
||||
)
|
||||
assert(
|
||||
!(await deps.pathExists(paths.outputDir)),
|
||||
`Refusing to build an existing release directory: ${paths.outputDir}`
|
||||
)
|
||||
}
|
||||
|
||||
if (deps.checkToolchain) await deps.checkToolchain(paths, { resume: request.resume })
|
||||
await deps.checkSsh(request.sshHost)
|
||||
const current = await deps.readLatest({
|
||||
host: request.sshHost,
|
||||
publicKeyPem: publicPem,
|
||||
baseUrl: request.baseUrl
|
||||
})
|
||||
|
||||
let currentVersion = null
|
||||
if (current?.document) {
|
||||
verifySignedManifest(current.document, publicPem, { baseUrl: request.baseUrl })
|
||||
currentVersion = current.document.payload.version
|
||||
const compared = compareVersions(request.version, currentVersion)
|
||||
if (request.resume) {
|
||||
assert(
|
||||
compared >= 0,
|
||||
`Refusing resume for ${request.version}; server latest is newer (${currentVersion})`
|
||||
)
|
||||
} else {
|
||||
assert(
|
||||
compared > 0,
|
||||
`Release version ${request.version} must be strictly newer than server latest ${currentVersion}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let localManifestText = null
|
||||
let localManifest = null
|
||||
if (request.resume) {
|
||||
localManifestText = await deps.readFile(paths.manifestPath, 'utf8')
|
||||
localManifest = parseSignedManifest(localManifestText, publicPem, request.baseUrl, 'Local latest.json')
|
||||
assert(
|
||||
localManifest.payload.version === request.version,
|
||||
`Resume manifest version ${localManifest.payload.version} does not match ${request.version}`
|
||||
)
|
||||
if (currentVersion === request.version) {
|
||||
assert(
|
||||
sameManifest(current.document, localManifest),
|
||||
`Server already has a different ${request.version} manifest`
|
||||
)
|
||||
}
|
||||
const bundle = await deps.validateBundle({
|
||||
manifestPath: paths.manifestPath,
|
||||
installerPath: paths.installerPath,
|
||||
publicKeyPath,
|
||||
baseUrl: request.baseUrl
|
||||
})
|
||||
assert(
|
||||
bundle?.document?.payload?.version === request.version,
|
||||
`Revalidated bundle version does not match ${request.version}`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
repoVersion: versions.version,
|
||||
privateKeyPath,
|
||||
packageText,
|
||||
lockText,
|
||||
publicPem,
|
||||
currentVersion,
|
||||
currentRaw: current?.raw ?? null,
|
||||
localManifestText,
|
||||
localManifest
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTargetVersions({ request, paths, preflight, deps }) {
|
||||
if (request.resume) return { changed: false }
|
||||
const nextPackageText = updatePackageText(preflight.packageText, request.version)
|
||||
const nextLockText = updateLockText(preflight.lockText, request.version)
|
||||
let changed = false
|
||||
if (nextPackageText !== preflight.packageText) {
|
||||
await deps.writeFileAtomic(paths.packagePath, nextPackageText)
|
||||
changed = true
|
||||
}
|
||||
if (nextLockText !== preflight.lockText) {
|
||||
await deps.writeFileAtomic(path.join(paths.root, 'package-lock.json'), nextLockText)
|
||||
changed = true
|
||||
}
|
||||
return { changed }
|
||||
}
|
||||
|
||||
export async function acquireReleaseLock(lockPath, { version } = {}) {
|
||||
await fsp.mkdir(path.dirname(lockPath), { recursive: true })
|
||||
const token = crypto.randomBytes(16).toString('hex')
|
||||
let handle
|
||||
let created = false
|
||||
try {
|
||||
handle = await fsp.open(lockPath, 'wx', 0o600)
|
||||
created = true
|
||||
await handle.writeFile(
|
||||
`${JSON.stringify({
|
||||
token,
|
||||
pid: process.pid,
|
||||
version: version || null,
|
||||
startedAt: new Date().toISOString()
|
||||
})}\n`,
|
||||
'utf8'
|
||||
)
|
||||
await handle.sync()
|
||||
} catch (error) {
|
||||
if (handle) await handle.close().catch(() => {})
|
||||
if (created) await fsp.rm(lockPath, { force: true }).catch(() => {})
|
||||
if (error?.code === 'EEXIST') {
|
||||
throw new Error(`Release lock already exists: ${lockPath}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
await handle.close()
|
||||
|
||||
let released = false
|
||||
return async function releaseLock() {
|
||||
if (released) return
|
||||
let raw
|
||||
try {
|
||||
raw = await fsp.readFile(lockPath, 'utf8')
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
released = true
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
let record
|
||||
try {
|
||||
record = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new Error(`Release lock was replaced or corrupted: ${lockPath}`)
|
||||
}
|
||||
assert(record.token === token, `Release lock ownership changed: ${lockPath}`)
|
||||
await fsp.rm(lockPath, { force: true })
|
||||
released = true
|
||||
}
|
||||
}
|
||||
|
||||
async function readResponseLimited(response, maxBytes) {
|
||||
const declared = Number(response.headers.get('content-length'))
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
throw new Error(`latest.json exceeds ${maxBytes} bytes`)
|
||||
}
|
||||
if (!response.body) return Buffer.alloc(0)
|
||||
const reader = response.body.getReader()
|
||||
const chunks = []
|
||||
let total = 0
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
total += value.byteLength
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel()
|
||||
throw new Error(`latest.json exceeds ${maxBytes} bytes`)
|
||||
}
|
||||
chunks.push(Buffer.from(value))
|
||||
}
|
||||
return Buffer.concat(chunks, total)
|
||||
}
|
||||
|
||||
export async function verifyPublishedManifest({
|
||||
baseUrl,
|
||||
expectedManifestText,
|
||||
publicKeyPem,
|
||||
version,
|
||||
fetchImpl = globalThis.fetch,
|
||||
timeoutMs = 30_000,
|
||||
maxBytes = MAX_HTTP_MANIFEST_BYTES
|
||||
}) {
|
||||
validateStableVersion(version)
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
|
||||
assert(typeof fetchImpl === 'function', 'fetch is not available')
|
||||
const manifestUrl = new URL('latest.json', normalizedBaseUrl)
|
||||
const response = await fetchImpl(manifestUrl, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
referrerPolicy: 'no-referrer',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'cache-control': 'no-cache',
|
||||
pragma: 'no-cache'
|
||||
},
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
})
|
||||
assert(response.status === 200, `latest.json HTTP status is ${response.status}, expected 200`)
|
||||
const expected = Buffer.from(expectedManifestText, 'utf8')
|
||||
assert(expected.length <= maxBytes, 'Local signed manifest exceeds the HTTP verification limit')
|
||||
const actual = await readResponseLimited(response, maxBytes)
|
||||
assert(actual.equals(expected), 'HTTP latest.json does not exactly match the local signed manifest')
|
||||
const document = parseSignedManifest(actual.toString('utf8'), publicKeyPem, normalizedBaseUrl, 'HTTP latest.json')
|
||||
assert(document.payload.version === version, `HTTP latest.json version is ${document.payload.version}, expected ${version}`)
|
||||
return document
|
||||
}
|
||||
|
||||
function deployArgs({ request, paths }) {
|
||||
return [
|
||||
'--manifest',
|
||||
paths.manifestPath,
|
||||
'--installer',
|
||||
paths.installerPath,
|
||||
'--transport',
|
||||
'ssh',
|
||||
'--base-url',
|
||||
request.baseUrl,
|
||||
'--public-key',
|
||||
request.publicKeyPath,
|
||||
'--ssh-host',
|
||||
request.sshHost
|
||||
]
|
||||
}
|
||||
|
||||
export async function runReleaseWorkflow(request, deps) {
|
||||
validateStableVersion(request.version)
|
||||
const paths = releasePaths(deps.root, request.version)
|
||||
let releaseLock
|
||||
let primaryError = null
|
||||
|
||||
releaseLock = await runStage('lock', () => deps.acquireReleaseLock(paths.lockPath, { version: request.version }))
|
||||
assert(typeof releaseLock === 'function', 'Release lock must return a release function')
|
||||
|
||||
try {
|
||||
const preflight = await runStage('preflight', () => preflightRelease({ request, paths, deps }))
|
||||
if (!request.resume) {
|
||||
await runStage('version-update', () => updateTargetVersions({ request, paths, preflight, deps }))
|
||||
await runStage('test-updates', () => deps.runNpm(['run', 'test:updates']))
|
||||
await runStage('build', () => deps.runNpm(['run', 'build']))
|
||||
await runStage('package', () =>
|
||||
deps.runNode([
|
||||
paths.builderCli,
|
||||
'--win',
|
||||
'nsis',
|
||||
'--x64',
|
||||
'--publish',
|
||||
'never',
|
||||
`--config.directories.output=${path.relative(paths.root, paths.outputDir).replaceAll(path.sep, '/')}`
|
||||
])
|
||||
)
|
||||
await runStage('sign', () =>
|
||||
deps.runNode([
|
||||
paths.prepareScript,
|
||||
'--installer',
|
||||
paths.installerPath,
|
||||
'--version',
|
||||
request.version,
|
||||
'--notes-zh',
|
||||
request.notes.zh,
|
||||
'--notes-en',
|
||||
request.notes.en,
|
||||
'--base-url',
|
||||
request.baseUrl,
|
||||
'--private-key',
|
||||
preflight.privateKeyPath,
|
||||
'--public-key',
|
||||
request.publicKeyPath,
|
||||
'--output',
|
||||
paths.manifestPath
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
await runStage('deploy-dry-run', () => deps.runNode([paths.deployScript, ...deployArgs({ request, paths }), '--dry-run']))
|
||||
await runStage('deploy', () => deps.runNode([paths.deployScript, ...deployArgs({ request, paths })]))
|
||||
await runStage('verify-http', async () => {
|
||||
const expectedManifestText =
|
||||
preflight.localManifestText || (await deps.readFile(paths.manifestPath, 'utf8'))
|
||||
await deps.verifyHttp({
|
||||
baseUrl: request.baseUrl,
|
||||
expectedManifestText,
|
||||
publicKeyPem: preflight.publicPem,
|
||||
version: request.version
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
version: request.version,
|
||||
outputDir: paths.outputDir,
|
||||
installerPath: paths.installerPath,
|
||||
manifestPath: paths.manifestPath
|
||||
}
|
||||
} catch (error) {
|
||||
primaryError = toReleaseError('workflow', error)
|
||||
throw primaryError
|
||||
} finally {
|
||||
try {
|
||||
await releaseLock()
|
||||
} catch (error) {
|
||||
if (!primaryError) throw toReleaseError('lock-release', error)
|
||||
if (deps.logWarning) deps.logWarning(`Release lock cleanup failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { createInterface } from 'node:readline/promises'
|
||||
import {
|
||||
DEFAULT_BASE_URL,
|
||||
REPO_ROOT,
|
||||
readPrivateKey,
|
||||
readPublicKey,
|
||||
runSsh,
|
||||
writeFileAtomic
|
||||
} from './lib.mjs'
|
||||
import {
|
||||
ReleaseError,
|
||||
acquireReleaseLock,
|
||||
finalizeReleaseRequest,
|
||||
parseReleaseArgs,
|
||||
resolveVersionChoice,
|
||||
runReleaseWorkflow,
|
||||
toReleaseError,
|
||||
verifyPublishedManifest
|
||||
} from './release-workflow.mjs'
|
||||
import { readLatestViaSsh, validateReleaseBundle } from './transport.mjs'
|
||||
|
||||
const usage = `Usage:
|
||||
npm run release
|
||||
npm run release -- --version <x.y.z> [--notes-zh <text>] [--notes-en <text>]
|
||||
npm run release -- --resume --version <x.y.z>
|
||||
npm run release -- --help
|
||||
|
||||
Options:
|
||||
--version <x.y.z> Stable Windows x64 version. Prompted when omitted.
|
||||
--notes-zh <text> Chinese release notes. Defaults to "YCode <version> 更新".
|
||||
--notes-en <text> English release notes. Defaults to "YCode <version> update".
|
||||
--resume Reuse an existing installer and signed manifest.
|
||||
--ssh-host <host> SSH host, default local001.
|
||||
--base-url <url> Release base URL, default ${DEFAULT_BASE_URL}.
|
||||
--private-key <path> Signing key, default in the current user profile.
|
||||
--help Show this help.
|
||||
|
||||
The workflow runs tests, builds, signs, deploys, and verifies latest.json.
|
||||
It does not create keys, roll back the server, or clean stale release locks.
|
||||
`
|
||||
|
||||
async function pathExists(filename) {
|
||||
try {
|
||||
await fsp.access(filename)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function runExecutable(command, args, { cwd = REPO_ROOT } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
shell: false,
|
||||
stdio: 'inherit',
|
||||
windowsHide: true
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`${path.basename(command)} exited with ${signal ? `signal ${signal}` : `code ${code}`}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resolveNpmCli() {
|
||||
const candidates = [
|
||||
path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
||||
process.env.npm_execpath
|
||||
].filter((candidate) => candidate && path.basename(candidate).toLowerCase() === 'npm-cli.js')
|
||||
const npmCli = candidates.find((candidate) => fs.existsSync(candidate))
|
||||
if (!npmCli) throw new Error('Unable to locate npm-cli.js; run this command through npm run release')
|
||||
return npmCli
|
||||
}
|
||||
|
||||
async function promptForVersion() {
|
||||
const readline = createInterface({ input: process.stdin, output: process.stdout })
|
||||
try {
|
||||
return await readline.question('Release version (stable x.y.z): ')
|
||||
} finally {
|
||||
readline.close()
|
||||
}
|
||||
}
|
||||
|
||||
function productionDependencies() {
|
||||
const npmCli = resolveNpmCli()
|
||||
return {
|
||||
root: REPO_ROOT,
|
||||
pathExists,
|
||||
realpath: fsp.realpath,
|
||||
readFile: fsp.readFile,
|
||||
writeFileAtomic,
|
||||
readPrivateKey,
|
||||
readPublicKey,
|
||||
validateBundle: validateReleaseBundle,
|
||||
acquireReleaseLock,
|
||||
checkToolchain: async (paths, { resume }) => {
|
||||
const required = [['deploy.mjs', paths.deployScript]]
|
||||
if (!resume) {
|
||||
required.unshift(
|
||||
['npm-cli.js', npmCli],
|
||||
['electron-builder CLI', paths.builderCli],
|
||||
['prepare-release.mjs', paths.prepareScript]
|
||||
)
|
||||
}
|
||||
for (const [label, filename] of required) {
|
||||
if (!fs.existsSync(filename)) throw new Error(`${label} not found: ${filename}`)
|
||||
}
|
||||
},
|
||||
checkSsh: async (host) => {
|
||||
runSsh(host, 'true\n')
|
||||
},
|
||||
readLatest: ({ host, publicKeyPem, baseUrl }) => readLatestViaSsh(host, publicKeyPem, baseUrl),
|
||||
runNpm: (args) => runExecutable(process.execPath, [npmCli, ...args]),
|
||||
runNode: (args) => runExecutable(process.execPath, args),
|
||||
verifyHttp: verifyPublishedManifest,
|
||||
logWarning: (message) => console.warn(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let parsed
|
||||
try {
|
||||
parsed = parseReleaseArgs(process.argv.slice(2))
|
||||
} catch (error) {
|
||||
throw toReleaseError('input', error)
|
||||
}
|
||||
if (parsed.help) {
|
||||
process.stdout.write(usage)
|
||||
return
|
||||
}
|
||||
|
||||
let version
|
||||
try {
|
||||
version = await resolveVersionChoice({ version: parsed.version, prompt: promptForVersion })
|
||||
} catch (error) {
|
||||
throw toReleaseError('prompt', error)
|
||||
}
|
||||
if (!version) {
|
||||
console.log('Release cancelled.')
|
||||
return
|
||||
}
|
||||
let request
|
||||
try {
|
||||
request = finalizeReleaseRequest(parsed, version)
|
||||
} catch (error) {
|
||||
throw toReleaseError('input', error)
|
||||
}
|
||||
const result = await runReleaseWorkflow(request, productionDependencies())
|
||||
console.log(`Release ${result.version} published and verified.`)
|
||||
console.log(`Installer: ${result.installerPath}`)
|
||||
console.log(`Manifest: ${result.manifestPath}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const releaseError = error instanceof ReleaseError ? error : toReleaseError('input', error)
|
||||
console.error(`Release failed during ${releaseError.stage}: ${releaseError.message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,372 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import fsp from 'node:fs/promises'
|
||||
import http from 'node:http'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
REPO_ROOT,
|
||||
buildManifestPayload,
|
||||
compareVersions,
|
||||
createSignedManifest,
|
||||
normalizeBaseUrl,
|
||||
readPrivateKey,
|
||||
readPublicKey,
|
||||
renderDownloadPage,
|
||||
runProcess,
|
||||
serializeSignedManifest,
|
||||
signPayload,
|
||||
verifyPayloadSignature,
|
||||
verifySignedManifest
|
||||
} from './lib.mjs'
|
||||
import {
|
||||
publishLocalRelease,
|
||||
readLocalLatest
|
||||
} from './transport.mjs'
|
||||
|
||||
let passed = 0
|
||||
|
||||
async function test(name, callback) {
|
||||
await callback()
|
||||
passed += 1
|
||||
console.log(`PASS ${name}`)
|
||||
}
|
||||
|
||||
function expectedError(pattern) {
|
||||
return (error) => {
|
||||
assert.match(String(error?.message || error), pattern)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash('sha256').update(buffer).digest('hex')
|
||||
}
|
||||
|
||||
function contentType(filename) {
|
||||
return filename.endsWith('.json') ? 'application/json' : 'application/octet-stream'
|
||||
}
|
||||
|
||||
function startStaticServer(root) {
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://127.0.0.1')
|
||||
const pathname = decodeURIComponent(url.pathname)
|
||||
const relative = pathname.startsWith('/ycode/releases/')
|
||||
? pathname.slice('/ycode/releases/'.length)
|
||||
: null
|
||||
assert(relative !== null, 'unexpected path')
|
||||
const requested = relative === '' || relative.endsWith('/') ? `${relative}index.html` : relative
|
||||
const filename = path.resolve(root, 'ycode', 'releases', requested)
|
||||
const releaseRoot = path.resolve(root, 'ycode', 'releases')
|
||||
assert(filename.startsWith(`${releaseRoot}${path.sep}`), 'path traversal')
|
||||
const data = await fsp.readFile(filename)
|
||||
response.writeHead(200, {
|
||||
'content-length': data.length,
|
||||
'content-type': contentType(filename)
|
||||
})
|
||||
response.end(data)
|
||||
} catch {
|
||||
response.writeHead(404)
|
||||
response.end('not found')
|
||||
}
|
||||
})
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
resolve({ server, origin: `http://127.0.0.1:${address.port}` })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function releaseFor({ version, bytes, filename, baseUrl, privatePem, publicPem, artifactPath }) {
|
||||
const installer = {
|
||||
filename,
|
||||
size: bytes.length,
|
||||
sha256: sha256(bytes)
|
||||
}
|
||||
const payload = buildManifestPayload({
|
||||
version,
|
||||
publishedAt: '2026-10-20T10:00:00.000Z',
|
||||
notes: { zh: `版本 ${version} 测试说明`, en: `Release ${version} test notes` },
|
||||
installer,
|
||||
baseUrl
|
||||
})
|
||||
const document = createSignedManifest({
|
||||
payload,
|
||||
privateKeyPem: privatePem,
|
||||
publicKeyPem: publicPem,
|
||||
baseUrl
|
||||
})
|
||||
return {
|
||||
document,
|
||||
installer: { ...installer, path: artifactPath },
|
||||
manifestText: serializeSignedManifest(document, { baseUrl }),
|
||||
publicKeyPem: publicPem,
|
||||
baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-update-selftest-'))
|
||||
const privateKey = path.join(temporary, 'keys', 'update-signing.key')
|
||||
const publicKey = path.join(temporary, 'keys', 'public-key.pem')
|
||||
const publicRoot = path.join(temporary, 'ycode')
|
||||
const artifactDirectory = path.join(temporary, 'fixtures')
|
||||
await fsp.mkdir(artifactDirectory, { recursive: true })
|
||||
let server
|
||||
|
||||
try {
|
||||
await test('key generation stays outside the repository and writes a PEM trust anchor', async () => {
|
||||
const initKey = path.join(REPO_ROOT, 'scripts', 'updates', 'init-key.mjs')
|
||||
const output = runProcess(process.execPath, [initKey, '--private-key', privateKey, '--public-key', publicKey])
|
||||
assert.match(output, /Private key:/)
|
||||
assert.doesNotMatch(output, /BEGIN PRIVATE KEY/)
|
||||
const privatePem = await fsp.readFile(privateKey, 'utf8')
|
||||
const publicPem = await fsp.readFile(publicKey, 'utf8')
|
||||
assert.match(privatePem, /BEGIN PRIVATE KEY/)
|
||||
assert.match(publicPem, /BEGIN PUBLIC KEY/)
|
||||
if (process.platform === 'win32') {
|
||||
const acl = runProcess('icacls', [privateKey])
|
||||
assert.doesNotMatch(acl, /\(I\)/)
|
||||
}
|
||||
})
|
||||
|
||||
const privatePem = await readPrivateKey(privateKey)
|
||||
const publicPem = await readPublicKey(publicKey)
|
||||
|
||||
await test('SemVer precedence is strict and build metadata does not create a newer release', async () => {
|
||||
assert(compareVersions('2.0.0', '1.9.9') > 0)
|
||||
assert(compareVersions('2.0.0-rc.2', '2.0.0-rc.10') < 0)
|
||||
assert(compareVersions('2.0.0+1', '2.0.0+2') === 0)
|
||||
assert.throws(() => compareVersions('01.0.0', '1.0.0'), /strict SemVer/)
|
||||
assert.throws(() => compareVersions('1.0', '1.0.0'), /strict SemVer/)
|
||||
})
|
||||
|
||||
const staticServer = await startStaticServer(temporary)
|
||||
server = staticServer.server
|
||||
const baseUrl = normalizeBaseUrl(`${staticServer.origin}/ycode/releases/`)
|
||||
|
||||
const version1 = '2.0.0'
|
||||
const bytes1 = Buffer.from(`fake installer ${version1} `.repeat(16_384), 'utf8')
|
||||
const filename1 = `YCode-${version1}-Setup-x64.exe`
|
||||
const artifact1 = path.join(artifactDirectory, filename1)
|
||||
await fsp.writeFile(artifact1, bytes1)
|
||||
const release1 = await releaseFor({
|
||||
version: version1,
|
||||
bytes: bytes1,
|
||||
filename: filename1,
|
||||
baseUrl,
|
||||
privatePem,
|
||||
publicPem,
|
||||
artifactPath: artifact1
|
||||
})
|
||||
|
||||
await test('manifest signs exact JSON.stringify(payload) and rejects any payload or signature tamper', async () => {
|
||||
verifySignedManifest(release1.document, publicPem, { baseUrl })
|
||||
assert(verifyPayloadSignature(release1.document.payload, release1.document.signature, publicPem))
|
||||
|
||||
const notesTamper = structuredClone(release1.document)
|
||||
notesTamper.payload.notes.en = 'tampered'
|
||||
assert.throws(() => verifySignedManifest(notesTamper, publicPem, { baseUrl }), /signature is invalid/)
|
||||
|
||||
const hashTamper = structuredClone(release1.document)
|
||||
hashTamper.payload.platforms['windows-x64'].sha256 = '0'.repeat(64)
|
||||
assert.throws(() => verifySignedManifest(hashTamper, publicPem, { baseUrl }), /signature is invalid/)
|
||||
|
||||
const signatureTamper = structuredClone(release1.document)
|
||||
const first = signatureTamper.signature[0] === 'A' ? 'B' : 'A'
|
||||
signatureTamper.signature = first + signatureTamper.signature.slice(1)
|
||||
assert.throws(() => verifySignedManifest(signatureTamper, publicPem, { baseUrl }), /signature is invalid/)
|
||||
})
|
||||
|
||||
await test('signed manifests reject query, fragment, credentials, cross-version paths and non-canonical timestamps', async () => {
|
||||
const cases = [
|
||||
(payload) => { payload.platforms['windows-x64'].url += '?token=bad' },
|
||||
(payload) => { payload.platforms['windows-x64'].url += '#fragment' },
|
||||
(payload) => { payload.platforms['windows-x64'].url = payload.platforms['windows-x64'].url.replace('127.0.0.1', 'user:pass@127.0.0.1') },
|
||||
(payload) => { payload.platforms['windows-x64'].url = payload.platforms['windows-x64'].url.replace('/versions/2.0.0/', '/versions/2.0.1/') },
|
||||
(payload) => { payload.publishedAt = '2026-10-20T10:00:00Z' },
|
||||
(payload) => { payload.platforms['windows-x64'].sha256 = payload.platforms['windows-x64'].sha256.toUpperCase() }
|
||||
]
|
||||
for (const mutate of cases) {
|
||||
const payload = structuredClone(release1.document.payload)
|
||||
mutate(payload)
|
||||
const document = { payload, signature: signPayload(payload, privatePem) }
|
||||
assert.throws(() => verifySignedManifest(document, publicPem, { baseUrl }), /Manifest|URL|publishedAt|sha256/)
|
||||
}
|
||||
})
|
||||
|
||||
await test('download page escapes signed notes and contains no script', async () => {
|
||||
const payload = structuredClone(release1.document.payload)
|
||||
payload.notes.zh = '<script>alert("zh")</script>\n第二行'
|
||||
payload.notes.en = '<img src=x onerror=alert(1)>'
|
||||
const html = renderDownloadPage(payload, { baseUrl })
|
||||
assert.doesNotMatch(html, /<script>alert/)
|
||||
assert.doesNotMatch(html, /<img src=x/)
|
||||
assert.match(html, /<script>/)
|
||||
assert.match(html, /<img src=x onerror=alert\(1\)>/)
|
||||
assert.match(html, /Content-Security-Policy/)
|
||||
})
|
||||
|
||||
await test('local fake transport publishes immutable artifact then fetches and verifies it over HTTP', async () => {
|
||||
let current = await readLocalLatest(publicRoot, publicPem, baseUrl)
|
||||
await publishLocalRelease({ root: publicRoot, release: release1, current })
|
||||
const response = await fetch(new URL('latest.json', baseUrl))
|
||||
assert.equal(response.status, 200)
|
||||
assert.equal(response.headers.get('cache-control'), null)
|
||||
const text = await response.text()
|
||||
const document = JSON.parse(text)
|
||||
verifySignedManifest(document, publicPem, { baseUrl })
|
||||
const artifactResponse = await fetch(document.payload.platforms['windows-x64'].url)
|
||||
const downloaded = Buffer.from(await artifactResponse.arrayBuffer())
|
||||
assert.equal(sha256(downloaded), document.payload.platforms['windows-x64'].sha256)
|
||||
assert.equal(downloaded.length, document.payload.platforms['windows-x64'].size)
|
||||
const pageResponse = await fetch(new URL('./', baseUrl))
|
||||
assert.equal(pageResponse.status, 200)
|
||||
const page = await pageResponse.text()
|
||||
assert.match(page, /v2\.0\.0/)
|
||||
assert.match(page, new RegExp(document.payload.platforms['windows-x64'].sha256))
|
||||
assert.doesNotMatch(page, /<script\b/i)
|
||||
})
|
||||
|
||||
await test('exact redeploy is idempotent', async () => {
|
||||
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
|
||||
await publishLocalRelease({ root: publicRoot, release: release1, current })
|
||||
})
|
||||
|
||||
await test('a conflicting artifact in an existing version directory is rejected', async () => {
|
||||
const bytes = Buffer.from('conflicting same-version bytes', 'utf8')
|
||||
const artifact = path.join(artifactDirectory, filename1)
|
||||
await fsp.writeFile(artifact, bytes)
|
||||
const conflicting = await releaseFor({
|
||||
version: version1,
|
||||
bytes,
|
||||
filename: filename1,
|
||||
baseUrl,
|
||||
privatePem,
|
||||
publicPem,
|
||||
artifactPath: artifact
|
||||
})
|
||||
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
|
||||
assert.equal(current.document.payload.version, version1)
|
||||
await fsp.writeFile(artifact, bytes1)
|
||||
await assert.rejects(
|
||||
publishLocalRelease({ root: publicRoot, release: conflicting, current }),
|
||||
expectedError(/different manifest/)
|
||||
)
|
||||
})
|
||||
|
||||
await test('a lower version is rejected without changing latest.json', async () => {
|
||||
const bytes = Buffer.from('lower release fixture', 'utf8')
|
||||
const filename = 'YCode-1.9.9-Setup-x64.exe'
|
||||
const artifact = path.join(artifactDirectory, filename)
|
||||
await fsp.writeFile(artifact, bytes)
|
||||
const lower = await releaseFor({
|
||||
version: '1.9.9',
|
||||
bytes,
|
||||
filename,
|
||||
baseUrl,
|
||||
privatePem,
|
||||
publicPem,
|
||||
artifactPath: artifact
|
||||
})
|
||||
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
|
||||
await assert.rejects(
|
||||
publishLocalRelease({ root: publicRoot, release: lower, current }),
|
||||
expectedError(/newer/)
|
||||
)
|
||||
const after = await readLocalLatest(publicRoot, publicPem, baseUrl)
|
||||
assert.equal(after.raw, current.raw)
|
||||
})
|
||||
|
||||
await test('failure before latest.json swap leaves the previous signed manifest live', async () => {
|
||||
const version = '2.1.0'
|
||||
const bytes = Buffer.from('failed atomic fixture', 'utf8')
|
||||
const filename = `YCode-${version}-Setup-x64.exe`
|
||||
const artifact = path.join(artifactDirectory, filename)
|
||||
await fsp.writeFile(artifact, bytes)
|
||||
const failed = await releaseFor({
|
||||
version,
|
||||
bytes,
|
||||
filename,
|
||||
baseUrl,
|
||||
privatePem,
|
||||
publicPem,
|
||||
artifactPath: artifact
|
||||
})
|
||||
const before = await readLocalLatest(publicRoot, publicPem, baseUrl)
|
||||
const pageBefore = await (await fetch(new URL('./', baseUrl))).text()
|
||||
await assert.rejects(
|
||||
publishLocalRelease({
|
||||
root: publicRoot,
|
||||
release: failed,
|
||||
current: before,
|
||||
beforeLatestSwap: async () => { throw new Error('injected publication failure') }
|
||||
}),
|
||||
expectedError(/injected publication failure/)
|
||||
)
|
||||
const after = await readLocalLatest(publicRoot, publicPem, baseUrl)
|
||||
assert.equal(after.raw, before.raw)
|
||||
const pageAfter = await (await fetch(new URL('./', baseUrl))).text()
|
||||
assert.equal(pageAfter, pageBefore)
|
||||
})
|
||||
|
||||
await test('a different artifact cannot reuse a version directory left by a failed publication', async () => {
|
||||
const version = '2.1.0'
|
||||
const bytes = Buffer.from('different failed artifact', 'utf8')
|
||||
const filename = `YCode-${version}-Setup-x64.exe`
|
||||
const artifact = path.join(artifactDirectory, filename)
|
||||
await fsp.writeFile(artifact, bytes)
|
||||
const conflicting = await releaseFor({
|
||||
version,
|
||||
bytes,
|
||||
filename,
|
||||
baseUrl,
|
||||
privatePem,
|
||||
publicPem,
|
||||
artifactPath: artifact
|
||||
})
|
||||
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
|
||||
await assert.rejects(
|
||||
publishLocalRelease({ root: publicRoot, release: conflicting, current }),
|
||||
expectedError(/conflicting artifact/)
|
||||
)
|
||||
})
|
||||
|
||||
await test('successful publication swaps the generated download page with latest.json', async () => {
|
||||
const version = '2.2.0'
|
||||
const bytes = Buffer.from('successful next release fixture', 'utf8')
|
||||
const filename = `YCode-${version}-Setup-x64.exe`
|
||||
const artifact = path.join(artifactDirectory, filename)
|
||||
await fsp.writeFile(artifact, bytes)
|
||||
const release = await releaseFor({
|
||||
version,
|
||||
bytes,
|
||||
filename,
|
||||
baseUrl,
|
||||
privatePem,
|
||||
publicPem,
|
||||
artifactPath: artifact
|
||||
})
|
||||
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
|
||||
await publishLocalRelease({ root: publicRoot, release, current })
|
||||
const latest = await readLocalLatest(publicRoot, publicPem, baseUrl)
|
||||
assert.equal(latest.document.payload.version, version)
|
||||
const page = await (await fetch(new URL('./', baseUrl))).text()
|
||||
assert.match(page, /v2\.2\.0/)
|
||||
assert.match(page, new RegExp(latest.document.payload.platforms['windows-x64'].sha256))
|
||||
})
|
||||
|
||||
console.log(`\n${passed} release-tool self-tests passed. No SSH target or production latest.json was touched.`)
|
||||
} finally {
|
||||
if (server) await new Promise((resolve) => server.close(resolve))
|
||||
await fsp.rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,408 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fsp from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
DEFAULT_BASE_URL,
|
||||
SERVER_RELEASES_ROOT,
|
||||
SERVER_STAGING_ROOT,
|
||||
assert,
|
||||
compareVersions,
|
||||
normalizeBaseUrl,
|
||||
parseVersion,
|
||||
readJson,
|
||||
readPublicKey,
|
||||
renderDownloadPage,
|
||||
serializeSignedManifest,
|
||||
sha256File,
|
||||
verifyReleaseInstaller,
|
||||
verifySignedManifest,
|
||||
runProcess,
|
||||
runSsh,
|
||||
validateSshHost,
|
||||
writeFileAtomic
|
||||
} from './lib.mjs'
|
||||
|
||||
const MAX_MANIFEST_BYTES = 1024 * 1024
|
||||
|
||||
export function parseCurrentManifest(raw, publicKeyPem, baseUrl) {
|
||||
assert(Buffer.byteLength(raw, 'utf8') <= MAX_MANIFEST_BYTES, 'Current latest.json exceeds 1 MiB')
|
||||
let document
|
||||
try {
|
||||
document = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new Error('Current latest.json is not valid JSON')
|
||||
}
|
||||
verifySignedManifest(document, publicKeyPem, { baseUrl })
|
||||
return document
|
||||
}
|
||||
|
||||
export function assertMonotonicTransition(current, target) {
|
||||
if (!current) return
|
||||
const compared = compareVersions(target.payload.version, current.payload.version)
|
||||
assert(compared >= 0, `Refusing to publish ${target.payload.version}; server latest is newer (${current.payload.version})`)
|
||||
if (compared === 0) {
|
||||
assert(
|
||||
JSON.stringify(current) === JSON.stringify(target),
|
||||
`Refusing to replace ${target.payload.version} with a different manifest`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateReleaseBundle({
|
||||
manifestPath,
|
||||
installerPath,
|
||||
publicKeyPath,
|
||||
baseUrl = DEFAULT_BASE_URL
|
||||
}) {
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
|
||||
const publicKeyPem = await readPublicKey(publicKeyPath)
|
||||
let document
|
||||
try {
|
||||
document = await readJson(manifestPath)
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to read release manifest: ${error.message}`)
|
||||
}
|
||||
verifySignedManifest(document, publicKeyPem, { baseUrl: normalizedBaseUrl })
|
||||
const expected = document.payload.platforms['windows-x64']
|
||||
const installer = await verifyReleaseInstaller(installerPath, document.payload.version)
|
||||
assert(installer.size === expected.size, 'Installer size does not match the signed manifest')
|
||||
assert(installer.sha256 === expected.sha256, 'Installer SHA-256 does not match the signed manifest')
|
||||
assert(path.posix.basename(new URL(expected.url).pathname) === installer.filename, 'Signed URL filename does not match the installer')
|
||||
|
||||
return {
|
||||
document,
|
||||
manifestText: serializeSignedManifest(document, { baseUrl: normalizedBaseUrl }),
|
||||
pageHtml: renderDownloadPage(document.payload, { baseUrl: normalizedBaseUrl }),
|
||||
installer,
|
||||
publicKeyPem,
|
||||
baseUrl: normalizedBaseUrl
|
||||
}
|
||||
}
|
||||
|
||||
export async function readLocalLatest(root, publicKeyPem, baseUrl) {
|
||||
const filename = path.join(root, 'releases', 'latest.json')
|
||||
try {
|
||||
const raw = await fsp.readFile(filename, 'utf8')
|
||||
return { raw, document: parseCurrentManifest(raw, publicKeyPem, baseUrl) }
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return { raw: null, document: null }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function installVersionDirectory({ root, version, installerPath, expectedSha256, runId }) {
|
||||
const releases = path.join(root, 'releases')
|
||||
const versions = path.join(releases, 'versions')
|
||||
const target = path.join(versions, version)
|
||||
const filename = path.basename(installerPath)
|
||||
await fsp.mkdir(versions, { recursive: true })
|
||||
|
||||
try {
|
||||
const entries = await fsp.readdir(target)
|
||||
assert(entries.length === 1 && entries[0] === filename, `Version directory ${version} already conflicts with this artifact`)
|
||||
const existingHash = await sha256File(path.join(target, filename))
|
||||
assert(existingHash === expectedSha256, `Version directory ${version} contains a conflicting artifact`)
|
||||
return
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'ENOENT') throw error
|
||||
}
|
||||
|
||||
const staging = path.join(root, '.staging', runId)
|
||||
const stagedVersion = path.join(staging, 'version')
|
||||
const stagedFile = path.join(stagedVersion, filename)
|
||||
await fsp.mkdir(stagedVersion, { recursive: true })
|
||||
try {
|
||||
await fsp.copyFile(installerPath, stagedFile)
|
||||
const copiedHash = await sha256File(stagedFile)
|
||||
assert(copiedHash === expectedSha256, 'Staged installer hash changed')
|
||||
await fsp.rename(stagedVersion, target)
|
||||
} finally {
|
||||
await fsp.rm(staging, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishLocalRelease({
|
||||
root,
|
||||
release,
|
||||
current,
|
||||
beforeLatestSwap
|
||||
}) {
|
||||
const { document, installer, manifestText, publicKeyPem, baseUrl } = release
|
||||
assertMonotonicTransition(current?.document, document)
|
||||
await installVersionDirectory({
|
||||
root,
|
||||
version: document.payload.version,
|
||||
installerPath: installer.path,
|
||||
expectedSha256: document.payload.platforms['windows-x64'].sha256,
|
||||
runId: crypto.randomBytes(12).toString('hex')
|
||||
})
|
||||
|
||||
const latestPath = path.join(root, 'releases', 'latest.json')
|
||||
const indexPath = path.join(root, 'releases', 'index.html')
|
||||
let previousPage = null
|
||||
try {
|
||||
previousPage = await fsp.readFile(indexPath)
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'ENOENT') throw error
|
||||
}
|
||||
|
||||
const assertCurrentLatest = async () => {
|
||||
if (current?.raw != null) {
|
||||
const liveRaw = await fsp.readFile(latestPath, 'utf8')
|
||||
assert(liveRaw === current.raw, 'latest.json changed during publication')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fsp.access(latestPath)
|
||||
throw new Error('latest.json appeared during publication')
|
||||
} catch (error) {
|
||||
if (error && error.code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
|
||||
await assertCurrentLatest()
|
||||
try {
|
||||
await writeFileAtomic(
|
||||
indexPath,
|
||||
release.pageHtml || renderDownloadPage(document.payload, { baseUrl }),
|
||||
0o644
|
||||
)
|
||||
if (beforeLatestSwap) await beforeLatestSwap()
|
||||
await assertCurrentLatest()
|
||||
await writeFileAtomic(latestPath, manifestText, 0o644)
|
||||
} catch (error) {
|
||||
try {
|
||||
if (previousPage == null) {
|
||||
await fsp.rm(indexPath, { force: true })
|
||||
} else {
|
||||
await writeFileAtomic(indexPath, previousPage, 0o644)
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
throw new Error(`${error.message}; failed to restore download page: ${rollbackError.message}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return { latestPath, document }
|
||||
}
|
||||
|
||||
export async function readLatestViaSsh(host, publicKeyPem, baseUrl) {
|
||||
const script = `set -euo pipefail
|
||||
if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
|
||||
cat '${SERVER_RELEASES_ROOT}/latest.json'
|
||||
fi
|
||||
`
|
||||
const raw = runSsh(host, script)
|
||||
if (!raw) return { raw: null, document: null }
|
||||
assert(Buffer.byteLength(raw, 'utf8') <= MAX_MANIFEST_BYTES, 'Server latest.json exceeds 1 MiB')
|
||||
return { raw, document: parseCurrentManifest(raw, publicKeyPem, baseUrl) }
|
||||
}
|
||||
|
||||
function sha256Text(value) {
|
||||
return crypto.createHash('sha256').update(value, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
export async function publishViaSsh({
|
||||
host,
|
||||
release,
|
||||
current
|
||||
}) {
|
||||
validateSshHost(host)
|
||||
const { document, installer, manifestText, publicKeyPem, baseUrl } = release
|
||||
assertMonotonicTransition(current?.document, document)
|
||||
|
||||
const runId = crypto.randomBytes(12).toString('hex')
|
||||
const version = document.payload.version
|
||||
const filename = installer.filename
|
||||
parseVersion(version)
|
||||
assert(/^[A-Za-z0-9._-]+\.exe$/.test(filename), 'Unsafe installer filename')
|
||||
const staging = `${SERVER_STAGING_ROOT}/${runId}`
|
||||
const manifestSha256 = sha256Text(manifestText)
|
||||
const pageHtml = renderDownloadPage(document.payload, { baseUrl })
|
||||
const pageSha256 = sha256Text(pageHtml)
|
||||
const metadata = {
|
||||
runId,
|
||||
version,
|
||||
filename,
|
||||
sha256: document.payload.platforms['windows-x64'].sha256,
|
||||
size: document.payload.platforms['windows-x64'].size,
|
||||
manifestSha256,
|
||||
pageSha256,
|
||||
expectedLatestSha256: current?.raw == null ? null : sha256Text(current.raw)
|
||||
}
|
||||
|
||||
runSsh(host, `set -euo pipefail
|
||||
install -d -o root -g root -m 0700 '${SERVER_STAGING_ROOT}'
|
||||
install -d -o root -g root -m 0700 '${staging}'
|
||||
`)
|
||||
let publicationError
|
||||
try {
|
||||
runProcess('scp', ['-q', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', installer.path, `${host}:${staging}/${filename}`])
|
||||
|
||||
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-update-deploy-'))
|
||||
const localManifest = path.join(temporary, 'latest.json')
|
||||
const localPage = path.join(temporary, 'index.html')
|
||||
await fsp.writeFile(localManifest, manifestText, { mode: 0o600 })
|
||||
await fsp.writeFile(localPage, pageHtml, { mode: 0o600 })
|
||||
try {
|
||||
runProcess('scp', ['-q', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', localManifest, `${host}:${staging}/latest.json`])
|
||||
runProcess('scp', ['-q', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', localPage, `${host}:${staging}/index.html`])
|
||||
} finally {
|
||||
await fsp.rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const metadataBase64 = Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64')
|
||||
const remoteScript = `set -euo pipefail
|
||||
umask 022
|
||||
metadata_file="$(mktemp /tmp/ycode-release.XXXXXX.json)"
|
||||
cleanup() {
|
||||
rm -f "$metadata_file"
|
||||
rm -rf '${staging}'
|
||||
}
|
||||
trap cleanup EXIT
|
||||
printf '%s' '${metadataBase64}' | base64 -d > "$metadata_file"
|
||||
python3 - "$metadata_file" <<'PY'
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
meta = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
run_id = meta["runId"]
|
||||
version = meta["version"]
|
||||
filename = meta["filename"]
|
||||
expected_hash = meta["sha256"]
|
||||
expected_size = meta["size"]
|
||||
manifest_hash = meta["manifestSha256"]
|
||||
page_hash = meta["pageSha256"]
|
||||
expected_latest_hash = meta["expectedLatestSha256"]
|
||||
|
||||
if not re.fullmatch(r"[0-9A-Fa-f]{24}", run_id):
|
||||
raise SystemExit("invalid staging id")
|
||||
if not re.fullmatch(r"[0-9A-Za-z._-]+\\.exe", filename):
|
||||
raise SystemExit("invalid installer filename")
|
||||
if not re.fullmatch(r"[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?", version):
|
||||
raise SystemExit("invalid version")
|
||||
|
||||
public_root = "${path.posix.dirname(SERVER_RELEASES_ROOT)}"
|
||||
releases = "${SERVER_RELEASES_ROOT}"
|
||||
staging = "${SERVER_STAGING_ROOT}/" + run_id
|
||||
versions = os.path.join(releases, "versions")
|
||||
target_dir = os.path.join(versions, version)
|
||||
staged_artifact = os.path.join(staging, filename)
|
||||
staged_manifest = os.path.join(staging, "latest.json")
|
||||
staged_page = os.path.join(staging, "index.html")
|
||||
latest = os.path.join(releases, "latest.json")
|
||||
index = os.path.join(releases, "index.html")
|
||||
|
||||
def digest(pathname):
|
||||
value = hashlib.sha256()
|
||||
with open(pathname, "rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
value.update(chunk)
|
||||
return value.hexdigest()
|
||||
|
||||
if os.stat(staging).st_dev != os.stat(public_root).st_dev:
|
||||
raise SystemExit("staging and release roots are on different filesystems")
|
||||
|
||||
os.makedirs(versions, mode=0o755, exist_ok=True)
|
||||
os.makedirs("${SERVER_STAGING_ROOT}", mode=0o700, exist_ok=True)
|
||||
lock_path = "/var/lock/ycode-release.lock"
|
||||
with open(lock_path, "a+", encoding="utf-8") as lock:
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
||||
|
||||
live_latest_hash = digest(latest) if os.path.isfile(latest) else None
|
||||
if live_latest_hash != expected_latest_hash:
|
||||
raise SystemExit("latest.json changed during publication")
|
||||
|
||||
if os.path.getsize(staged_artifact) != expected_size or digest(staged_artifact) != expected_hash:
|
||||
raise SystemExit("staged installer does not match signed metadata")
|
||||
if os.path.getsize(staged_manifest) > 1024 * 1024 or digest(staged_manifest) != manifest_hash:
|
||||
raise SystemExit("staged manifest does not match expected bytes")
|
||||
if os.path.getsize(staged_page) > 512 * 1024 or digest(staged_page) != page_hash:
|
||||
raise SystemExit("staged download page does not match expected bytes")
|
||||
|
||||
if os.path.exists(target_dir):
|
||||
entries = sorted(os.listdir(target_dir))
|
||||
if entries != [filename]:
|
||||
raise SystemExit("version directory conflicts with this artifact")
|
||||
if digest(os.path.join(target_dir, filename)) != expected_hash:
|
||||
raise SystemExit("version directory contains a conflicting artifact")
|
||||
else:
|
||||
version_stage = os.path.join(staging, "version")
|
||||
os.makedirs(version_stage, mode=0o755, exist_ok=False)
|
||||
os.replace(staged_artifact, os.path.join(version_stage, filename))
|
||||
os.chmod(os.path.join(version_stage, filename), 0o644)
|
||||
os.replace(version_stage, target_dir)
|
||||
|
||||
previous_page = None
|
||||
if os.path.isfile(index):
|
||||
with open(index, "rb") as stream:
|
||||
previous_page = stream.read()
|
||||
if len(previous_page) > 512 * 1024:
|
||||
raise SystemExit("existing download page is unexpectedly large")
|
||||
|
||||
next_latest = os.path.join(releases, f".latest.json.{run_id}.tmp")
|
||||
next_page = os.path.join(releases, f".index.html.{run_id}.tmp")
|
||||
os.replace(staged_manifest, next_latest)
|
||||
os.chmod(next_latest, 0o644)
|
||||
with open(next_latest, "rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(staged_page, next_page)
|
||||
os.chmod(next_page, 0o644)
|
||||
with open(next_page, "rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
page_swapped = False
|
||||
try:
|
||||
os.replace(next_page, index)
|
||||
page_swapped = True
|
||||
os.replace(next_latest, latest)
|
||||
except Exception:
|
||||
if page_swapped:
|
||||
if previous_page is None:
|
||||
try:
|
||||
os.unlink(index)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
rollback_page = os.path.join(releases, f".index.html.{run_id}.rollback")
|
||||
with open(rollback_page, "wb") as stream:
|
||||
stream.write(previous_page)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.chmod(rollback_page, 0o644)
|
||||
os.replace(rollback_page, index)
|
||||
raise
|
||||
directory_fd = os.open(releases, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
|
||||
print(version)
|
||||
PY
|
||||
`
|
||||
runSsh(host, remoteScript)
|
||||
} catch (error) {
|
||||
publicationError = error
|
||||
throw error
|
||||
} finally {
|
||||
try {
|
||||
runSsh(host, `set -euo pipefail
|
||||
rm -rf '${staging}'
|
||||
`)
|
||||
} catch (cleanupError) {
|
||||
if (publicationError) {
|
||||
console.error(`Unable to remove remote staging path after failure: ${cleanupError.message}`)
|
||||
} else {
|
||||
console.warn(`Publication succeeded, but remote staging cleanup failed: ${cleanupError.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { version }
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
// Real isolated YCode main/preload/renderer verification for native append delivery.
|
||||
// The production build is loaded unchanged. A loopback Anthropic-compatible server
|
||||
// holds the first response while two durable queue messages are sent as guidance.
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/claude-append-app-'))
|
||||
const data = path.join(directory, 'data')
|
||||
const home = path.join(directory, 'home')
|
||||
await Promise.all([fs.mkdir(data, { recursive: true }), fs.mkdir(home, { recursive: true })])
|
||||
const probe = require('node:net').createServer()
|
||||
await new Promise((resolve, reject) => {
|
||||
probe.once('error', reject)
|
||||
probe.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = probe.address()
|
||||
if (!address || typeof address === 'string') throw new Error('failed to reserve loopback port')
|
||||
const appendPort = address.port
|
||||
await new Promise(resolve => probe.close(resolve))
|
||||
const env = {
|
||||
...process.env,
|
||||
YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_USER_DATA_DIR: data,
|
||||
YCODE_APPEND_PORT: String(appendPort),
|
||||
YCODE_QA_OFFSCREEN: '1',
|
||||
YCODE_DISABLE_GPU: '1',
|
||||
YCODE_DISABLE_SYSTEM_INTEGRATION: '1',
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
DISABLE_TELEMETRY: '1',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1'
|
||||
}
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(
|
||||
require('electron'),
|
||||
['--no-sandbox', '--disable-gpu-sandbox', __filename],
|
||||
{ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
)
|
||||
let output = ''
|
||||
child.stdout.on('data', bytes => {
|
||||
output += bytes
|
||||
if (bytes.toString().includes('APPEND_E2E_')) process.stdout.write(bytes)
|
||||
})
|
||||
child.stderr.on('data', bytes => {
|
||||
output += bytes
|
||||
if (bytes.toString().includes('APPEND_E2E_')) process.stderr.write(bytes)
|
||||
})
|
||||
const timer = setTimeout(() => child.kill(), 180000)
|
||||
child.once('exit', async code => {
|
||||
clearTimeout(timer)
|
||||
await fs.writeFile(path.join(directory, 'process.log'), output)
|
||||
console.log(JSON.stringify({ directory, exitCode: code }))
|
||||
if (code) console.error(output.slice(-12000))
|
||||
process.exitCode = code ? 1 : 0
|
||||
})
|
||||
})().catch(error => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { createServer } = require('node:http')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const fsSync = require('node:fs')
|
||||
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
assert.ok(directory, 'YCODE_QA_DIRECTORY is required')
|
||||
const appendPort = Number(process.env.YCODE_APPEND_PORT)
|
||||
assert.ok(Number.isInteger(appendPort) && appendPort > 0, 'YCODE_APPEND_PORT is required')
|
||||
const relative = path.relative(path.resolve('.tmp'), path.resolve(directory))
|
||||
assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'unsafe QA directory')
|
||||
|
||||
const data = path.join(directory, 'data')
|
||||
const home = path.join(directory, 'home')
|
||||
const workspace = path.join(home, 'YCodeWorkspace')
|
||||
const modelId = 'native-append-loopback'
|
||||
const modelBId = 'native-append-loopback-b'
|
||||
const modelCId = 'native-append-loopback-c'
|
||||
// Each profile keeps its own wire model id so the loopback server can prove
|
||||
// which model actually served a request.
|
||||
const wireModelA = 'claude-native-append-a'
|
||||
const wireModelB = 'claude-native-append-b'
|
||||
const wireModelC = 'claude-native-append-c'
|
||||
// Each profile gets its own base path so the request URL alone proves which
|
||||
// model served a turn, independently of the request body fields.
|
||||
const modelPathA = '/m-a'
|
||||
const modelPathB = '/m-b'
|
||||
const modelPathC = '/m-c'
|
||||
|
||||
app.disableHardwareAcceleration()
|
||||
app.setPath('userData', data)
|
||||
app.setPath('sessionData', data)
|
||||
app.setPath('home', home)
|
||||
|
||||
let hidingWindows = true
|
||||
app.on('browser-window-created', (_event, win) => {
|
||||
win.setSkipTaskbar(true)
|
||||
win.on('show', () => {
|
||||
if (!hidingWindows || win.isDestroyed()) return
|
||||
setImmediate(() => {
|
||||
if (!win.isDestroyed()) win.hide()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const checks = []
|
||||
const rendererErrors = []
|
||||
const requests = []
|
||||
const serverEvents = []
|
||||
const queueTimeline = []
|
||||
const openSockets = new Set()
|
||||
let firstResponseGate
|
||||
let firstResponseReleased = false
|
||||
const firstResponse = new Promise(resolve => { firstResponseGate = resolve })
|
||||
let stage = 'boot'
|
||||
|
||||
function setStage(value) {
|
||||
stage = value
|
||||
serverEvents.push({ type: 'stage', stage: value })
|
||||
}
|
||||
|
||||
function releaseFirstResponse() {
|
||||
if (firstResponseReleased) return
|
||||
firstResponseReleased = true
|
||||
firstResponseGate()
|
||||
}
|
||||
|
||||
function check(label, actual, expected) {
|
||||
const pass = typeof expected === 'function'
|
||||
? expected(actual)
|
||||
: JSON.stringify(actual) === JSON.stringify(expected)
|
||||
checks.push({
|
||||
label,
|
||||
pass,
|
||||
actual,
|
||||
expected: typeof expected === 'function' ? expected.toString() : expected
|
||||
})
|
||||
}
|
||||
|
||||
function sse(res, event, value) {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(value)}\n\n`)
|
||||
}
|
||||
|
||||
function messageStart(res, id) {
|
||||
sse(res, 'message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: modelId,
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 12, output_tokens: 1 }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function textBlock(res, text) {
|
||||
sse(res, 'content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'text', text: '' }
|
||||
})
|
||||
sse(res, 'content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text }
|
||||
})
|
||||
sse(res, 'content_block_stop', { type: 'content_block_stop', index: 0 })
|
||||
}
|
||||
|
||||
function messageEnd(res) {
|
||||
sse(res, 'message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
||||
usage: { output_tokens: 4 }
|
||||
})
|
||||
sse(res, 'message_stop', { type: 'message_stop' })
|
||||
res.end()
|
||||
}
|
||||
|
||||
function plainText(res, id, text) {
|
||||
messageStart(res, id)
|
||||
textBlock(res, text)
|
||||
messageEnd(res)
|
||||
}
|
||||
|
||||
function messageTexts(message) {
|
||||
if (!message || typeof message !== 'object') return []
|
||||
const content = message.content
|
||||
if (typeof content === 'string') return [content]
|
||||
if (!Array.isArray(content)) return []
|
||||
return content.flatMap(block => block && typeof block === 'object' &&
|
||||
block.type === 'text' && typeof block.text === 'string' ? [block.text] : [])
|
||||
}
|
||||
|
||||
function userTexts(body) {
|
||||
if (!Array.isArray(body.messages)) return []
|
||||
return body.messages.flatMap(message => message?.role === 'user' ? messageTexts(message) : [])
|
||||
}
|
||||
|
||||
async function read(source, win) {
|
||||
let timer
|
||||
try {
|
||||
return await Promise.race([
|
||||
win.webContents.executeJavaScript(source),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 140))), 20000)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(source, label, win, attempts = 500) {
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
const value = await read(source, win)
|
||||
if (value) return value
|
||||
await pause(40)
|
||||
}
|
||||
throw Error('Timeout: ' + label)
|
||||
}
|
||||
|
||||
async function waitForMain(predicate, label, attempts = 500) {
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
const value = await predicate()
|
||||
if (value) return value
|
||||
await pause(40)
|
||||
}
|
||||
throw Error('Timeout: ' + label)
|
||||
}
|
||||
|
||||
async function readQueue(win, sessionId) {
|
||||
return read(`window.ycode.chat.queue.get(${JSON.stringify(sessionId)})`, win)
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the send-now action of one specific queue row. The row is located by
|
||||
* its rendered preview text, which is unique per queued message, so a
|
||||
* concurrent send-now on another row can never be mistaken for this one.
|
||||
*/
|
||||
async function clickSendNowForRow(win, previewText) {
|
||||
const clicked = await read(`(()=>{
|
||||
const rows=[...document.querySelectorAll('.conversation-message-queue-item')];
|
||||
const row=rows.find(node=>node.querySelector('.conversation-queue-preview-text')?.textContent?.includes(${JSON.stringify(previewText)}));
|
||||
if(!row)return {ok:false,reason:'row-not-found',rows:rows.length};
|
||||
const button=row.querySelector('[aria-label="立即发送"]');
|
||||
if(!button)return {ok:false,reason:'no-button',status:row.textContent??''};
|
||||
if(button.disabled)return {ok:false,reason:'disabled'};
|
||||
button.click();
|
||||
return {ok:true}
|
||||
})()`, win)
|
||||
assert.equal(clicked.ok, true, `send-now for row ${previewText}: ${JSON.stringify(clicked)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* A row leaves the queue only after the native identity is acknowledged.
|
||||
* While the first model response is still gated, that acknowledgement cannot
|
||||
* arrive, so the row stays visible in the delivering state.
|
||||
*/
|
||||
async function waitForRowStatus(win, sessionId, queueId, status) {
|
||||
for (let index = 0; index < 500; index += 1) {
|
||||
const snapshot = await readQueue(win, sessionId)
|
||||
const item = snapshot.items.find(entry => entry.id === queueId)
|
||||
if (item?.status === status) return snapshot
|
||||
if (!item) {
|
||||
const error = new Error(`queue row ${queueId} left the queue before reaching ${status}`)
|
||||
error.snapshot = snapshot
|
||||
throw error
|
||||
}
|
||||
await pause(40)
|
||||
}
|
||||
throw Error(`Timeout: queue row ${queueId} did not reach ${status}`)
|
||||
}
|
||||
|
||||
async function setComposer(win, text) {
|
||||
const set = await read(`(()=>{
|
||||
const input=document.querySelector('.wb-composer__input');
|
||||
if(!(input instanceof HTMLTextAreaElement))return false;
|
||||
const setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;
|
||||
setter.call(input,${JSON.stringify(text)});
|
||||
input.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
input.focus();
|
||||
return true
|
||||
})()`, win)
|
||||
assert.equal(set, true, 'composer textarea must exist')
|
||||
await waitFor(`!document.querySelector('.cr-input-toolbar__send:not(.cr-send-button--stop)')?.disabled`,
|
||||
'composer accepts ' + text, win)
|
||||
}
|
||||
|
||||
async function clickComposerSend(win) {
|
||||
const clicked = await read(`(()=>{
|
||||
const button=document.querySelector('.cr-input-toolbar__send:not(.cr-send-button--stop)');
|
||||
if(!button||button.disabled)return false;
|
||||
button.click();
|
||||
return true
|
||||
})()`, win)
|
||||
assert.equal(clicked, true, 'composer send button must be enabled')
|
||||
}
|
||||
|
||||
async function queueFromUi(win, text, expectedCount) {
|
||||
await setComposer(win, text)
|
||||
await clickComposerSend(win)
|
||||
await waitFor(`document.querySelectorAll('.conversation-message-queue-item').length===${expectedCount}`,
|
||||
`queue count ${expectedCount} after ${text}`, win)
|
||||
}
|
||||
|
||||
async function screenshot(win, name) {
|
||||
win.webContents.invalidate()
|
||||
await pause(120)
|
||||
const image = await win.webContents.capturePage()
|
||||
await fs.writeFile(path.join(directory, name + '.png'), image.toPNG())
|
||||
return !image.isEmpty()
|
||||
}
|
||||
|
||||
async function run() {
|
||||
setStage('start')
|
||||
const watchdog = setTimeout(() => {
|
||||
fsSync.writeFileSync(path.join(directory, 'watchdog.json'), JSON.stringify({
|
||||
stage,
|
||||
checks,
|
||||
queueTimeline,
|
||||
requests: requests.map(request => ({ index: request.index, userTexts: request.userTexts, completed: request.completed, aborted: request.aborted, answer: request.answer })),
|
||||
serverEvents
|
||||
}, null, 2))
|
||||
console.error('APPEND_E2E_ERROR watchdog timeout at stage ' + stage)
|
||||
app.exit(1)
|
||||
}, 150000)
|
||||
watchdog.unref?.()
|
||||
fsSync.mkdirSync(workspace, { recursive: true })
|
||||
fsSync.writeFileSync(path.join(workspace, 'README.md'), 'native append app verification\n', 'utf8')
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
openSockets.add(req.socket)
|
||||
req.socket.on('close', () => openSockets.delete(req.socket))
|
||||
let raw = ''
|
||||
req.on('data', chunk => { raw += chunk })
|
||||
req.on('end', async () => {
|
||||
let body = {}
|
||||
try { body = JSON.parse(raw || '{}') } catch { body = { raw } }
|
||||
if (!req.url?.includes('/messages')) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end('{}')
|
||||
return
|
||||
}
|
||||
|
||||
const texts = userTexts(body)
|
||||
const servedModel = req.url?.includes(modelPathB) ? 'B'
|
||||
: req.url?.includes(modelPathC) ? 'C'
|
||||
: req.url?.includes(modelPathA) ? 'A' : 'unknown'
|
||||
const captured = {
|
||||
index: requests.length + 1,
|
||||
url: req.url,
|
||||
body,
|
||||
servedModel,
|
||||
wireModel: typeof body.model === 'string' ? body.model : null,
|
||||
userTexts: texts,
|
||||
completed: false,
|
||||
aborted: false
|
||||
}
|
||||
requests.push(captured)
|
||||
serverEvents.push({ type: 'request', index: captured.index, texts, servedModel })
|
||||
res.on('close', () => {
|
||||
if (!captured.completed) {
|
||||
captured.aborted = true
|
||||
serverEvents.push({ type: 'aborted', index: captured.index })
|
||||
}
|
||||
})
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
res.flushHeaders()
|
||||
|
||||
if (captured.index === 1) await firstResponse
|
||||
if (res.destroyed) return
|
||||
const hasA = texts.some(text => text.includes('E2E_APPEND_A'))
|
||||
const hasB = texts.some(text => text.includes('E2E_APPEND_B'))
|
||||
const afterSwitch = texts.some(text => text.includes('E2E_AFTER_MODEL_SWITCH'))
|
||||
const answer = afterSwitch ? 'E2E_ANSWER_AFTER_SWITCH'
|
||||
: captured.index === 1 ? 'E2E_ANSWER_ONE'
|
||||
: hasA && hasB ? 'E2E_ANSWER_MERGED' : 'E2E_ANSWER_UNEXPECTED'
|
||||
captured.completed = true
|
||||
captured.answer = answer
|
||||
serverEvents.push({ type: 'response', index: captured.index, answer })
|
||||
plainText(res, `msg-native-append-${captured.index}`, answer)
|
||||
})
|
||||
})
|
||||
|
||||
const serverReady = new Promise((resolve, reject) => {
|
||||
server.once('listening', resolve)
|
||||
server.once('error', reject)
|
||||
})
|
||||
server.listen(appendPort, '127.0.0.1')
|
||||
const baseUrl = `http://127.0.0.1:${appendPort}`
|
||||
|
||||
fsSync.mkdirSync(data, { recursive: true })
|
||||
fsSync.writeFileSync(path.join(data, 'models.json'), JSON.stringify({
|
||||
version: 1,
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name: 'Native Append Loopback A',
|
||||
providerId: 'custom',
|
||||
model: wireModelA,
|
||||
baseUrl: baseUrl + modelPathA,
|
||||
authMode: 'api-key',
|
||||
apiKeyPlain: 'ycode-loopback-append-key',
|
||||
protocol: 'anthropic',
|
||||
supportsToolCall: true,
|
||||
supportsImages: false,
|
||||
supportsReasoning: false,
|
||||
contextWindowTokens: 200000,
|
||||
maxInputTokens: 180000,
|
||||
maxOutputTokens: 4096,
|
||||
temperature: 0,
|
||||
maxToolRounds: 1
|
||||
},
|
||||
{
|
||||
id: modelBId,
|
||||
name: 'Native Append Loopback B',
|
||||
providerId: 'custom',
|
||||
model: wireModelB,
|
||||
baseUrl: baseUrl + modelPathB,
|
||||
authMode: 'api-key',
|
||||
apiKeyPlain: 'ycode-loopback-append-key',
|
||||
protocol: 'anthropic',
|
||||
supportsToolCall: true,
|
||||
supportsImages: false,
|
||||
supportsReasoning: false,
|
||||
contextWindowTokens: 200000,
|
||||
maxInputTokens: 180000,
|
||||
maxOutputTokens: 4096,
|
||||
temperature: 0,
|
||||
maxToolRounds: 1
|
||||
},
|
||||
{
|
||||
id: modelCId,
|
||||
name: 'Native Append Loopback C',
|
||||
providerId: 'custom',
|
||||
model: wireModelC,
|
||||
baseUrl: baseUrl + modelPathC,
|
||||
authMode: 'api-key',
|
||||
apiKeyPlain: 'ycode-loopback-append-key',
|
||||
protocol: 'anthropic',
|
||||
supportsToolCall: true,
|
||||
supportsImages: false,
|
||||
supportsReasoning: false,
|
||||
contextWindowTokens: 200000,
|
||||
maxInputTokens: 180000,
|
||||
maxOutputTokens: 4096,
|
||||
temperature: 0,
|
||||
maxToolRounds: 1
|
||||
}
|
||||
],
|
||||
selectedModelId: modelId
|
||||
}, null, 2), 'utf8')
|
||||
fsSync.writeFileSync(path.join(data, 'config.json'), JSON.stringify({
|
||||
baseUrl,
|
||||
model: 'claude-native-append',
|
||||
temperature: 0,
|
||||
maxToolRounds: 1,
|
||||
apiKeyPlain: 'ycode-loopback-append-key',
|
||||
workspaceRoot: workspace,
|
||||
preventSleep: false,
|
||||
autoLaunch: false
|
||||
}, null, 2), 'utf8')
|
||||
|
||||
let main
|
||||
try {
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
await serverReady
|
||||
await app.whenReady()
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
|
||||
if (main && !main.webContents.isLoading()) break
|
||||
await pause(25)
|
||||
}
|
||||
assert.ok(main, 'main window')
|
||||
main.webContents.on('console-message', (...args) => {
|
||||
const event = args[0]
|
||||
const level = event?.level ?? args[1]
|
||||
const message = event?.message ?? args[2]
|
||||
if (level === 'error' || level === 3) rendererErrors.push(String(message))
|
||||
})
|
||||
main.setSize(1336, 902)
|
||||
await waitFor('!!window.ycode', 'preload bridge', main)
|
||||
await waitFor(`!!document.querySelector('.conversation-list')&&!!document.querySelector('.wb-composer__input')`,
|
||||
'renderer ready', main)
|
||||
check('all created windows stay hidden during verification', main.isVisible(), false)
|
||||
|
||||
setStage('create-session')
|
||||
const session = await read(`window.ycode.session.create('Native append E2E', ${JSON.stringify({
|
||||
cwd: workspace,
|
||||
modelId,
|
||||
agentMode: 'craft',
|
||||
permissionMode: 'default-sandbox'
|
||||
})})`, main)
|
||||
assert.ok(session?.id, 'session creation')
|
||||
await read(`(()=>{
|
||||
const url=new URL(location.href);
|
||||
url.searchParams.set('window','task');
|
||||
url.searchParams.set('taskId',${JSON.stringify(session.id)});
|
||||
location.href=url.href;
|
||||
return true
|
||||
})()`, main).catch(() => undefined)
|
||||
await waitFor(`document.querySelector('[data-session-id=${JSON.stringify(session.id)}]')?.getAttribute('data-selected')==='true'`,
|
||||
'created session selected', main)
|
||||
await waitFor(`!!document.querySelector('.wb-composer__input')`, 'composer after selection', main)
|
||||
|
||||
setStage('send-initial')
|
||||
await setComposer(main, 'E2E_INITIAL')
|
||||
await clickComposerSend(main)
|
||||
await waitForMain(() => requests[0], 'first model request')
|
||||
check('first request reaches the loopback model', requests[0].url.includes('/messages'), true)
|
||||
|
||||
setStage('queue-guidance')
|
||||
await queueFromUi(main, 'E2E_APPEND_A', 1)
|
||||
await queueFromUi(main, 'E2E_APPEND_B', 2)
|
||||
const beforeSendNow = await read(`window.ycode.chat.queue.get(${JSON.stringify(session.id)})`, main)
|
||||
check('two queued guidance messages are visible in the composer', beforeSendNow.items.length, 2)
|
||||
check('queued guidance retains both texts', beforeSendNow.items.map(item => item.payload.text), ['E2E_APPEND_A', 'E2E_APPEND_B'])
|
||||
check('queue screenshot captured while the first response is active', await screenshot(main, 'append-queue'), true)
|
||||
|
||||
// Both guidance messages are handed to the running turn while the first
|
||||
// model response is still gated. A row only leaves the queue after the
|
||||
// native identity is acknowledged, so both rows must still be visible.
|
||||
setStage('send-now-a')
|
||||
await clickSendNowForRow(main, 'E2E_APPEND_A')
|
||||
const afterSendNowA = await waitForRowStatus(main, session.id, beforeSendNow.items[0].id, 'delivering')
|
||||
queueTimeline.push({ stage: 'after-send-now-a', items: afterSendNowA.items.map(item => ({ id: item.id, text: item.payload.text, status: item.status })) })
|
||||
check('the first guidance message is handed to the running turn', afterSendNowA.items[0].status, 'delivering')
|
||||
check('the second guidance message is still queued while the first is delivering',
|
||||
afterSendNowA.items[1]?.status, 'pending')
|
||||
|
||||
setStage('send-now-b')
|
||||
await clickSendNowForRow(main, 'E2E_APPEND_B')
|
||||
const afterSendNowB = await waitForRowStatus(main, session.id, beforeSendNow.items[1].id, 'delivering')
|
||||
queueTimeline.push({ stage: 'after-send-now-b', items: afterSendNowB.items.map(item => ({ id: item.id, text: item.payload.text, status: item.status })) })
|
||||
check('both guidance messages are delivered to the running turn concurrently',
|
||||
afterSendNowB.items.map(item => item.status), ['delivering', 'delivering'])
|
||||
check('no guidance row is removed before the native identity is acknowledged',
|
||||
afterSendNowB.items.length, 2)
|
||||
|
||||
// ---- Model switch while the turn is still running.
|
||||
// The active run must keep model A; only later requests may use the new choice.
|
||||
setStage('model-switch-b')
|
||||
const switchB = await read(`window.ycode.session.updateContext(${JSON.stringify(session.id)}, { modelId: ${JSON.stringify(modelBId)} })`, main)
|
||||
check('the session accepts model B while a run is active', switchB?.modelId, modelBId)
|
||||
setStage('model-switch-c')
|
||||
const switchC = await read(`window.ycode.session.updateContext(${JSON.stringify(session.id)}, { modelId: ${JSON.stringify(modelCId)} })`, main)
|
||||
check('the session accepts model C while a run is active', switchC?.modelId, modelCId)
|
||||
const modelSwitchRequests = requests.length
|
||||
|
||||
setStage('release-first-response')
|
||||
releaseFirstResponse()
|
||||
await waitForMain(() => requests.length >= 2 ? requests[1] : undefined, 'append model request')
|
||||
setStage('await-queue-drain')
|
||||
const drained = await waitForMain(async () => {
|
||||
const snapshot = await readQueue(main, session.id)
|
||||
return snapshot.items.length === 0 ? snapshot : undefined
|
||||
}, 'acknowledged guidance leaves the durable queue')
|
||||
const afterSendNow = drained
|
||||
await waitFor(`document.querySelector('.chat-thread')?.textContent.includes('E2E_ANSWER_MERGED')`,
|
||||
'merged answer rendered', main, 800)
|
||||
await waitForMain(async () => {
|
||||
const active = await read(`window.ycode.chat.activeRun(${JSON.stringify(session.id)})`, main)
|
||||
return active === null
|
||||
}, 'native run completed')
|
||||
check('final conversation screenshot captured', await screenshot(main, 'append-final'), true)
|
||||
|
||||
const messages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main)
|
||||
const finalQueue = await read(`window.ycode.chat.queue.get(${JSON.stringify(session.id)})`, main)
|
||||
const users = messages.filter(message => message.role === 'user')
|
||||
const assistants = messages.filter(message => message.role === 'assistant')
|
||||
const queuedIds = beforeSendNow.items.map(item => item.id)
|
||||
const finalText = await read(`document.querySelector('.chat-thread')?.textContent??''`, main)
|
||||
|
||||
const secondTexts = requests[1].userTexts
|
||||
const initialPosition = secondTexts.findIndex(text => text.includes('E2E_INITIAL'))
|
||||
const aPosition = secondTexts.findIndex(text => text.includes('E2E_APPEND_A'))
|
||||
const bPosition = secondTexts.findIndex(text => text.includes('E2E_APPEND_B'))
|
||||
const lastUser = [...requests[1].body.messages].reverse().find(message => message?.role === 'user')
|
||||
const lastUserText = messageTexts(lastUser).join('\n')
|
||||
const lastUserBlocks = Array.isArray(lastUser?.content) ? lastUser.content : []
|
||||
const guidanceBlocks = lastUserBlocks.filter(block => block?.type === 'text' &&
|
||||
(block.text?.includes('E2E_APPEND_A') || block.text?.includes('E2E_APPEND_B')))
|
||||
|
||||
check('first request was not interrupted by queue guidance', requests[0].aborted, false)
|
||||
check('first request completed before the append request', requests[0].completed, true)
|
||||
check('the append guidance produced a second model request', requests.length >= 2, true)
|
||||
check('append request contains the initial message before both guidance messages',
|
||||
initialPosition >= 0 && aPosition > initialPosition && bPosition > aPosition, true)
|
||||
check('both guidance messages are merged into one native user content group',
|
||||
lastUserText.includes('E2E_APPEND_A') && lastUserText.includes('E2E_APPEND_B'), true)
|
||||
check('the merged guidance arrives as separate text blocks in one user message',
|
||||
guidanceBlocks.length >= 2, true)
|
||||
check('the second model request is the append request', requests[1].answer, 'E2E_ANSWER_MERGED')
|
||||
check('the merged answer is the second assistant turn', assistants.at(-1)?.content?.includes('E2E_ANSWER_MERGED'), true)
|
||||
check('the initial answer remains in history', assistants.some(message => message.content?.includes('E2E_ANSWER_ONE')), true)
|
||||
check('both queued user message identities persist in history',
|
||||
queuedIds.every(id => users.some(message => message.id === id)), true)
|
||||
check('queued user messages keep their guidance text',
|
||||
queuedIds.map(id => users.find(message => message.id === id)?.content),
|
||||
['E2E_APPEND_A', 'E2E_APPEND_B'])
|
||||
check('both appended messages share the owning run identity',
|
||||
new Set(users.filter(message => queuedIds.includes(message.id)).map(message => message.requestId)).size, 1)
|
||||
check('final durable queue is empty', finalQueue.items.length, 0)
|
||||
check('both guidance messages were acknowledged and removed from the queue',
|
||||
beforeSendNow.items.every(item => !finalQueue.items.some(entry => entry.id === item.id)), true)
|
||||
check('final renderer shows both guidance messages and the merged answer',
|
||||
['E2E_APPEND_A', 'E2E_APPEND_B', 'E2E_ANSWER_MERGED'].every(text => finalText.includes(text)), true)
|
||||
check('no renderer errors were logged', rendererErrors, [])
|
||||
|
||||
// ---- Independent message after the switch must use the last selected model.
|
||||
setStage('model-switch-followup')
|
||||
await setComposer(main, 'E2E_AFTER_MODEL_SWITCH')
|
||||
await clickComposerSend(main)
|
||||
// The follow-up message is queued while the run is active and dispatched
|
||||
// afterwards, so wait for the request that actually carries its text
|
||||
// instead of trusting the request count.
|
||||
const followUp = await waitForMain(() => requests.find(request =>
|
||||
request.index > modelSwitchRequests && request.userTexts.some(text => text.includes('E2E_AFTER_MODEL_SWITCH'))),
|
||||
'independent message after the model switch')
|
||||
check('the independent message after switching is served by the last selected model',
|
||||
followUp.servedModel, 'C')
|
||||
await waitForMain(async () => {
|
||||
const list = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main)
|
||||
return list.some(message => message.role === 'assistant' && message.modelId === modelCId &&
|
||||
message.status === 'done' && String(message.content ?? '').includes('E2E_ANSWER_AFTER_SWITCH')) ? list : undefined
|
||||
}, 'post-switch assistant persisted')
|
||||
// The composer must also release the running state before the final captures.
|
||||
await waitForMain(async () => {
|
||||
const active = await read(`window.ycode.chat.activeRun(${JSON.stringify(session.id)})`, main)
|
||||
return active === null
|
||||
}, 'post-switch run released')
|
||||
await waitFor(`!document.querySelector('.cr-send-button--stop')&&!document.querySelector('[aria-label="停止"]')`,
|
||||
'composer stop control released', main)
|
||||
|
||||
const switchMessages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main)
|
||||
const switchAssistants = switchMessages.filter(message => message.role === 'assistant')
|
||||
const lastAssistant = switchAssistants.at(-1)
|
||||
check('the post-switch assistant records the last selected model identity',
|
||||
lastAssistant?.modelId, modelCId)
|
||||
check('the running turn kept the original model identity',
|
||||
switchAssistants.find(message => message.content?.includes('E2E_ANSWER_ONE'))?.modelId, modelId)
|
||||
check('the guidance turn kept the original model identity',
|
||||
switchAssistants.find(message => message.content?.includes('E2E_ANSWER_MERGED'))?.modelId, modelId)
|
||||
check('the running turn request was served by model A', requests[0].servedModel, 'A')
|
||||
check('the guidance turn request was served by model A', requests[1].servedModel, 'A')
|
||||
check('the post-switch request was served by model C', followUp.servedModel, 'C')
|
||||
|
||||
// ---- Dark and narrow captures of the real composer.
|
||||
setStage('captures')
|
||||
await read(`(()=>{
|
||||
const key='ycode:theme';
|
||||
localStorage.setItem(key,'dark');
|
||||
window.dispatchEvent(new StorageEvent('storage',{key,newValue:'dark'}));
|
||||
return true
|
||||
})()`, main)
|
||||
// The composer and every other themed surface must follow the shared
|
||||
// theme state, not just the document element.
|
||||
await waitFor(`document.querySelector('.chat-pane[data-cr-theme="dark"]')!==null`, 'dark theme applied to the chat pane', main)
|
||||
check('the dark theme is applied to the real renderer',
|
||||
await read(`document.querySelector('.chat-pane')?.getAttribute('data-cr-theme')??null`, main), 'dark')
|
||||
check('dark conversation screenshot captured', await screenshot(main, 'append-final-dark'), true)
|
||||
|
||||
main.setSize(720, 900)
|
||||
await pause(300)
|
||||
check('narrow composer keeps the permission control visible and unobscured', await read(`(()=>{
|
||||
const button=document.querySelector('.yc-source-permission-trigger .cr-permission-setting');
|
||||
if(!button)return false;
|
||||
const box=button.getBoundingClientRect();
|
||||
const hit=document.elementFromPoint(box.x+box.width/2,box.y+box.height/2);
|
||||
return box.width>0&&box.height>0&&box.x>=0&&box.right<=innerWidth&&!!hit&&button.contains(hit);
|
||||
})()`, main), true)
|
||||
check('narrow conversation screenshot captured', await screenshot(main, 'append-final-narrow'), true)
|
||||
main.setSize(1336, 902)
|
||||
await pause(200)
|
||||
|
||||
const report = {
|
||||
result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
|
||||
scope: 'Production out/main, out/preload, and out/renderer; isolated Electron userData/home; local loopback Anthropic SSE; real Claude SDK/CLI; two UI-queued guidance messages sent through chat.queue.sendNow while the first response was active.',
|
||||
sessionId: session.id,
|
||||
requests: requests.map(request => ({
|
||||
index: request.index,
|
||||
url: request.url,
|
||||
servedModel: request.servedModel,
|
||||
wireModel: request.wireModel,
|
||||
userTexts: request.userTexts,
|
||||
messageRoles: Array.isArray(request.body.messages) ? request.body.messages.map(message => message?.role) : [],
|
||||
completed: request.completed,
|
||||
aborted: request.aborted,
|
||||
answer: request.answer
|
||||
})),
|
||||
beforeSendNow,
|
||||
afterSendNow,
|
||||
queueTimeline,
|
||||
finalQueue,
|
||||
messages,
|
||||
switchMessages,
|
||||
serverEvents,
|
||||
checks,
|
||||
rendererErrors,
|
||||
screenshots: ['append-queue.png', 'append-final.png', 'append-final-dark.png', 'append-final-narrow.png'],
|
||||
limitations: [
|
||||
'Native result user_message_uuids are verified indirectly through persisted guidance identities, merged request content, the second assistant segment, and an empty queue.',
|
||||
'The loopback server returns plain text only; tool calls, images, cancellation, and upstream provider failures are outside this E2E scope.'
|
||||
]
|
||||
}
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('APPEND_E2E_' + report.result + ' ' + directory)
|
||||
for (const failure of checks.filter(item => !item.pass)) {
|
||||
console.log('APPEND_E2E_FAILURE ' + JSON.stringify(failure))
|
||||
}
|
||||
clearTimeout(watchdog)
|
||||
app.exit(report.result === 'PASS' ? 0 : 1)
|
||||
} finally {
|
||||
clearTimeout(watchdog)
|
||||
hidingWindows = false
|
||||
// A gated SSE response keeps the socket open, so close() would wait for
|
||||
// it. Destroy the sockets explicitly instead of hanging the harness.
|
||||
for (const socket of openSockets) socket.destroy()
|
||||
openSockets.clear()
|
||||
await new Promise(resolve => server.close(() => resolve()))
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(async error => {
|
||||
hidingWindows = false
|
||||
try {
|
||||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({
|
||||
stage,
|
||||
error: String(error),
|
||||
stack: error.stack,
|
||||
checks,
|
||||
queueTimeline,
|
||||
requests: requests.map(request => ({
|
||||
index: request.index,
|
||||
userTexts: request.userTexts,
|
||||
completed: request.completed,
|
||||
aborted: request.aborted,
|
||||
answer: request.answer
|
||||
})),
|
||||
serverEvents,
|
||||
rendererErrors
|
||||
}, null, 2))
|
||||
} catch { /* The failure report is best effort. */ }
|
||||
console.error('APPEND_E2E_ERROR', error)
|
||||
app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Production app + real GitHub package, isolated from the user's skill registry.
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/recommended-app-'))
|
||||
await Promise.all(['data', 'home', 'workspace'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
const now = Date.now()
|
||||
await fs.writeFile(path.join(directory, 'data', 'sessions.json'), JSON.stringify({ sessions: [{
|
||||
id: 'skill-existing', title: 'Skill existing draft', cwd: path.join(directory, 'workspace'),
|
||||
createdAt: now, updatedAt: now, messages: []
|
||||
}] }))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_USER_DATA_DIR: path.join(directory, 'data'), HOME: path.join(directory, 'home'),
|
||||
USERPROFILE: path.join(directory, 'home'), YCODE_QA_OFFSCREEN: '1',
|
||||
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', value => { output += value; if (String(value).includes('RECOMMENDED_APP_')) process.stdout.write(value) })
|
||||
child.stderr.on('data', value => { output += value })
|
||||
const timer = setTimeout(() => child.kill(), 200000)
|
||||
child.once('exit', async code => {
|
||||
clearTimeout(timer)
|
||||
await fs.writeFile(path.join(directory, 'process.log'), output)
|
||||
console.log(JSON.stringify({ directory, exitCode: code }))
|
||||
if (code !== 0) console.error(output.slice(-7000))
|
||||
process.exitCode = code === 0 ? 0 : 1
|
||||
})
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const directory = path.resolve(process.env.YCODE_QA_DIRECTORY)
|
||||
const rel = path.relative(path.resolve('.tmp'), directory)
|
||||
assert.ok(rel.startsWith('recommended-app-') && !rel.includes('..'))
|
||||
const checks = [], errors = []
|
||||
let stage = 'boot', win
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const read = source => win.webContents.executeJavaScript(source)
|
||||
const check = (label, value) => { assert.ok(value, label); checks.push(label) }
|
||||
async function click(selector) {
|
||||
const point = await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing target');e.scrollIntoView({block:'nearest'});const r=e.getBoundingClientRect();const x=Math.round(r.x+r.width/2),y=Math.round(r.y+r.height/2);const hit=document.elementFromPoint(x,y);if(!e.contains(hit))throw Error('Target is covered: '+${JSON.stringify(selector)});return{x,y}})()`)
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
|
||||
await pause(100)
|
||||
}
|
||||
async function wait(source, label, timeout = 15000) {
|
||||
const end = Date.now() + timeout
|
||||
while (Date.now() < end) { const value = await read(source); if (value) return value; await pause(80) }
|
||||
throw Error('Timeout: ' + label)
|
||||
}
|
||||
async function shot(name) {
|
||||
win.webContents.invalidate()
|
||||
await win.webContents.capturePage()
|
||||
await pause(250)
|
||||
await fs.writeFile(path.join(directory, name + '.png'), (await win.webContents.capturePage()).toPNG())
|
||||
}
|
||||
app.on('browser-window-created', (_event, window) => {
|
||||
window.setSkipTaskbar(true)
|
||||
window.on('show', () => window.hide())
|
||||
window.webContents.on('console-message', (event) => { if (event.level === 'error') errors.push(event.message) })
|
||||
})
|
||||
;(async () => {
|
||||
try {
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
await app.whenReady()
|
||||
for (let index = 0; index < 200; index++) {
|
||||
win = BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode')
|
||||
if (win && !win.webContents.isLoading()) break
|
||||
await pause(30)
|
||||
}
|
||||
check('production window loaded', !!win)
|
||||
await wait('!!window.ycode?.skills?.recommendations', 'real preload')
|
||||
await wait('!!document.querySelector("[data-session-id=skill-existing]")', 'original session')
|
||||
await click('[data-session-id=skill-existing]')
|
||||
await wait('!!document.querySelector(".chat-pane .wb-composer textarea")', 'initial composer')
|
||||
await read('document.querySelector(".chat-pane .wb-composer textarea").focus()')
|
||||
await win.webContents.insertText('KEEP_ORIGINAL_DRAFT')
|
||||
const catalog = await read('window.ycode.skills.recommendations()')
|
||||
check('fixed Archify recommendation is exposed by real IPC', catalog.some(item => item.id === 'archify' && item.commit === '25cf37080fc8b1dbe758a4efc3935b55f3327083'))
|
||||
await read(`(()=>{window.__installEvents=[];window.ycode.skills.onInstallProgress(event=>window.__installEvents.push(event));window.__skillChanges=0;window.ycode.skills.onChanged(()=>window.__skillChanges++);window.dispatchEvent(new CustomEvent('ycode:open-capabilities',{detail:{tab:'skills'}}));return true})()`)
|
||||
await wait('!!document.querySelector("[data-recommendation-id=archify]")', 'recommendation card')
|
||||
await shot('recommendation')
|
||||
stage = 'install'
|
||||
// Use the real card and confirmation; no direct import and no picker stub.
|
||||
await click('[data-recommendation-id=archify] [aria-label="安装 archify"]')
|
||||
await wait('!!document.querySelector("[aria-label=确认安装推荐技能]")', 'install confirmation')
|
||||
check('the modal host escapes the isolated capability center', await read('document.querySelector(".yc-skill-modal-host")?.parentElement===document.body'))
|
||||
check('the background root is inert while the skill modal is open', await read('document.getElementById("root").inert'))
|
||||
check('sidebar sticky headers are behind the global mask', await read(`(()=>{const e=document.querySelector('.conversation-section-label');if(!e)return false;const r=e.getBoundingClientRect();return !!document.elementFromPoint(r.left+5,r.top+5)?.closest('.yc-skill-modal-host')})()`))
|
||||
await read('window.__sameDetail=document.querySelector(".yc-skill-detail-shell")')
|
||||
await shot('confirm-full-window')
|
||||
await read('document.documentElement.dataset.theme="dark"')
|
||||
await pause(350); await shot('confirm-dark')
|
||||
const originalSize = win.getSize()
|
||||
win.setSize(720, 900); await pause(350)
|
||||
check('the full-window skill detail fits a narrow viewport', await read('(()=>{const r=document.querySelector(".yc-skill-detail-shell").getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight})()'))
|
||||
await shot('confirm-narrow')
|
||||
win.setSize(...originalSize); await read('document.documentElement.dataset.theme="light"'); await pause(300)
|
||||
await click('[aria-label=确认安装推荐技能]')
|
||||
await wait(`window.__installEvents.some(event=>['installed','failed','cancelled'].includes(event.phase))`, 'native installation', 150000)
|
||||
const progress = await read('window.__installEvents')
|
||||
check('actual download, validation and commit phases are observed', ['downloading', 'validating', 'installing', 'installed'].every(phase => progress.some(event => event.phase === phase)))
|
||||
const installed = await read('window.ycode.skills.list()')
|
||||
const skill = installed.find(item => item.name === 'archify')
|
||||
check('installed skill is enabled, intact and has source provenance', skill?.enabled && !skill.userModified && skill.origin?.commit === catalog[0].commit)
|
||||
const document = await read(`window.ycode.skills.read(${JSON.stringify(skill.id)})`)
|
||||
check('skill body and packaged CLI resources are present', document.content.includes('Archify') && document.files.includes('bin/archify.mjs') && document.files.length > 100)
|
||||
check('committed mutation broadcasts a skill refresh', await read('window.__skillChanges>0'))
|
||||
await wait(`document.querySelector('[data-recommendation-id=archify]')?.textContent.includes('已安装')`, 'installed card refresh')
|
||||
check('installation keeps the same detail DOM mounted', await read('window.__sameDetail===document.querySelector(".yc-skill-detail-shell")'))
|
||||
await wait('document.querySelector("[role=dialog] .sm-skill-detail-action-row")?.textContent.includes("去使用")', 'post-install action')
|
||||
check('the avatar is a packaged offline image', await read('(()=>{const i=document.querySelector("[role=dialog] .sm-skill-detail-icon-wrap img");return i?.complete&&i.naturalWidth>0&&!i.src.startsWith("http")})()'))
|
||||
await shot('installed')
|
||||
stage = 'idempotency'
|
||||
const again = await read(`window.ycode.skills.installRecommended('archify',{operationId:crypto.randomUUID()})`)
|
||||
check('same unchanged version returns existing install', again.id === skill.id && again.origin.installedAt === skill.origin.installedAt)
|
||||
await read(`window.ycode.skills.setEnabled(${JSON.stringify(skill.id)},false)`)
|
||||
const disabled = await read(`window.ycode.skills.installRecommended('archify',{operationId:crypto.randomUUID()})`)
|
||||
check('idempotent install does not silently re-enable disabled skill', disabled.enabled === false)
|
||||
await read(`window.ycode.skills.setEnabled(${JSON.stringify(skill.id)},true)`)
|
||||
stage = 'use-skill'
|
||||
await wait(`!!document.querySelector('[role=dialog] [aria-label="使用推荐技能 archify"]')`, 'use installed recommendation')
|
||||
await click('[role=dialog] [aria-label="使用推荐技能 archify"]')
|
||||
await wait(`document.querySelector('.chat-pane .wb-composer')?.textContent.includes('archify')`, 'skill reference in the real composer')
|
||||
check('using the installed recommendation fills the real composer', true)
|
||||
check('use opens a clean new draft instead of overwriting the old draft', await read('!document.querySelector(".chat-pane .wb-composer").textContent.includes("KEEP_ORIGINAL_DRAFT")'))
|
||||
check('the background is no longer inert after use', await read('!document.getElementById("root").inert'))
|
||||
await shot('use-skill')
|
||||
check('browsing, installation and use do not create persisted chat sessions', (await read('window.ycode.session.list()')).length === 1)
|
||||
await click('[data-session-id=skill-existing]')
|
||||
await wait('document.querySelector(".chat-pane .wb-composer textarea")?.value.includes("KEEP_ORIGINAL_DRAFT")', 'original session draft restored')
|
||||
check('the original session draft remains intact after using the skill', true)
|
||||
check('no renderer errors', errors.length === 0)
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, progress, skill, files: document.files,
|
||||
errors, limits: 'Real fixed GitHub package and production IPC; no skill commands or model requests executed.' }, null, 2))
|
||||
console.log('RECOMMENDED_APP_PASS ' + directory)
|
||||
app.quit()
|
||||
} catch (error) {
|
||||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ stage, error: String(error.stack ?? error), checks, errors }, null, 2))
|
||||
if (win) await shot('failure').catch(() => {})
|
||||
console.error('RECOMMENDED_APP_FAIL ' + String(error))
|
||||
app.exit(1)
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const fsp = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const { execFileSync, spawnSync } = require('node:child_process')
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
const REGISTRY_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\8a4ee941-d893-569b-8bcb-2a55d270c04b'
|
||||
const STAMP = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const RUN_ROOT = path.join(ROOT, '.tmp', 'updates-installer-marker', STAMP)
|
||||
const LATEST_REPORT = path.join(ROOT, '.tmp', 'updates-installer-marker', 'latest-report.json')
|
||||
|
||||
function parseRegistryValue(output, name) {
|
||||
const expression = new RegExp(`^\\s*${name}\\s+REG_\\w+\\s+(.+?)\\s*$`, 'mi')
|
||||
const match = expression.exec(output)
|
||||
return match ? match[1].replace(/^"(.*)"$/, '$1') : null
|
||||
}
|
||||
|
||||
function queryRegistryValue(name) {
|
||||
const output = execFileSync('reg.exe', ['query', REGISTRY_KEY, '/v', name], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 10_000,
|
||||
maxBuffer: 64 * 1024
|
||||
})
|
||||
return parseRegistryValue(output, name)
|
||||
}
|
||||
|
||||
function registryExecutablePath(value) {
|
||||
const quoted = /^\s*"([^"]+\.exe)"/i.exec(value)
|
||||
if (quoted) return quoted[1]
|
||||
const plain = /^\s*(.+?\.exe)(?:\s|$|,)/i.exec(value)
|
||||
return plain ? plain[1] : null
|
||||
}
|
||||
|
||||
function relativeImport(from, to) {
|
||||
return `./${path.relative(path.dirname(from), to).replace(/\\/g, '/')}`
|
||||
}
|
||||
|
||||
function createRunnerSource(filename) {
|
||||
const installer = path.join(ROOT, 'src', 'main', 'updates', 'installer.ts')
|
||||
const physical = path.join(ROOT, 'src', 'main', 'updates', 'physical-files.ts')
|
||||
return `
|
||||
import { app } from 'electron'
|
||||
import fsPromises from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { createNsisInstaller, queryNsisRegistryValue } from ${JSON.stringify(relativeImport(filename, installer))}
|
||||
import { physicalRealPath, physicalStat } from ${JSON.stringify(relativeImport(filename, physical))}
|
||||
|
||||
function decodeConfig() {
|
||||
return JSON.parse(Buffer.from(process.env.YCODE_MARKER_CONFIG || '', 'base64').toString('utf8'))
|
||||
}
|
||||
|
||||
async function captureError(operation) {
|
||||
try {
|
||||
await operation()
|
||||
return null
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error && typeof error === 'object' && 'code' in error ? error.code : null,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = decodeConfig()
|
||||
const patched = await fsPromises.stat(config.appPath)
|
||||
const physicalAsar = await physicalStat(config.appPath)
|
||||
const physicalResources = await physicalStat(path.join(config.installationDirectory, 'resources'))
|
||||
|
||||
const validAdapter = createNsisInstaller({
|
||||
platform: 'win32',
|
||||
isPackaged: true,
|
||||
execPath: config.executablePath,
|
||||
appPath: config.appPath,
|
||||
cacheDirectory: config.cacheDirectory,
|
||||
resolveRealPath: physicalRealPath,
|
||||
readStat: physicalStat,
|
||||
queryRegistryValue: queryNsisRegistryValue
|
||||
})
|
||||
const environment = await validAdapter.validateEnvironment(1)
|
||||
|
||||
const directoryFixtureAdapter = createNsisInstaller({
|
||||
platform: 'win32',
|
||||
isPackaged: true,
|
||||
execPath: config.directoryFixture.executablePath,
|
||||
appPath: config.directoryFixture.appPath,
|
||||
cacheDirectory: config.cacheDirectory,
|
||||
resolveRealPath: physicalRealPath,
|
||||
readStat: physicalStat,
|
||||
queryRegistryValue: async name => {
|
||||
if (name === 'UninstallString') return '"' + config.directoryFixture.uninstallerPath + '" /currentuser'
|
||||
if (name === 'DisplayIcon') return config.directoryFixture.executablePath + ',0'
|
||||
return null
|
||||
}
|
||||
})
|
||||
const directoryError = await captureError(() => directoryFixtureAdapter.validateEnvironment(0))
|
||||
|
||||
const wrongRegistryAdapter = createNsisInstaller({
|
||||
platform: 'win32',
|
||||
isPackaged: true,
|
||||
execPath: config.executablePath,
|
||||
appPath: config.appPath,
|
||||
cacheDirectory: config.cacheDirectory,
|
||||
resolveRealPath: physicalRealPath,
|
||||
readStat: physicalStat,
|
||||
queryRegistryValue: async name => {
|
||||
if (name === 'UninstallString') return '"C:\\\\Wrong\\\\Uninstall YCode.exe" /currentuser'
|
||||
if (name === 'DisplayIcon') return config.executablePath + ',0'
|
||||
return null
|
||||
}
|
||||
})
|
||||
const wrongRegistryError = await captureError(() => wrongRegistryAdapter.validateEnvironment(0))
|
||||
|
||||
const result = {
|
||||
electronVersion: process.versions.electron,
|
||||
nodeVersion: process.versions.node,
|
||||
installationDirectory: environment.installationDirectory,
|
||||
executablePath: environment.executablePath,
|
||||
appPath: config.appPath,
|
||||
patchedFs: { isFile: patched.isFile(), isDirectory: patched.isDirectory() },
|
||||
physicalAsar: { isFile: physicalAsar.isFile(), isDirectory: physicalAsar.isDirectory() },
|
||||
physicalResources: { isFile: physicalResources.isFile(), isDirectory: physicalResources.isDirectory() },
|
||||
directoryError,
|
||||
wrongRegistryError,
|
||||
installerLaunched: false
|
||||
}
|
||||
console.log('YCODE_MARKER_RESULT=' + Buffer.from(JSON.stringify(result)).toString('base64'))
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error)
|
||||
app.exit(1)
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform !== 'win32') throw new Error('Installer marker verification requires Windows')
|
||||
await fsp.mkdir(RUN_ROOT, { recursive: true })
|
||||
|
||||
const uninstallString = queryRegistryValue('UninstallString')
|
||||
const displayIcon = queryRegistryValue('DisplayIcon')
|
||||
const displayVersion = queryRegistryValue('DisplayVersion')
|
||||
const uninstallerPath = uninstallString ? registryExecutablePath(uninstallString) : null
|
||||
const executablePath = displayIcon
|
||||
? registryExecutablePath(displayIcon.replace(/,\s*-?\d+\s*$/, ''))
|
||||
: null
|
||||
if (!uninstallerPath || !executablePath) throw new Error('Current HKCU YCode registry paths are incomplete')
|
||||
const installationDirectory = path.dirname(executablePath)
|
||||
if (path.dirname(uninstallerPath).toLowerCase() !== installationDirectory.toLowerCase()) {
|
||||
throw new Error('Current HKCU uninstaller and icon paths disagree')
|
||||
}
|
||||
const appPath = path.join(installationDirectory, 'resources', 'app.asar')
|
||||
const cacheDirectory = path.join(RUN_ROOT, 'cache')
|
||||
await fsp.mkdir(cacheDirectory, { recursive: true })
|
||||
|
||||
const directoryFixtureRoot = path.join(RUN_ROOT, 'directory-marker-fixture')
|
||||
const directoryFixtureResources = path.join(directoryFixtureRoot, 'resources')
|
||||
await fsp.mkdir(path.join(directoryFixtureResources, 'app.asar'), { recursive: true })
|
||||
const directoryFixtureExe = path.join(directoryFixtureRoot, 'YCode.exe')
|
||||
const directoryFixtureUninstaller = path.join(directoryFixtureRoot, 'Uninstall YCode.exe')
|
||||
await fsp.writeFile(directoryFixtureExe, 'fixture')
|
||||
await fsp.writeFile(directoryFixtureUninstaller, 'fixture')
|
||||
|
||||
const runnerEntry = path.join(RUN_ROOT, 'runner-entry.ts')
|
||||
const runnerOutput = path.join(RUN_ROOT, 'runner.cjs')
|
||||
await fsp.writeFile(runnerEntry, createRunnerSource(runnerEntry), 'utf8')
|
||||
const esbuild = path.join(ROOT, 'node_modules', 'esbuild', 'bin', 'esbuild')
|
||||
execFileSync(process.execPath, [
|
||||
esbuild,
|
||||
runnerEntry,
|
||||
'--bundle',
|
||||
'--platform=node',
|
||||
'--format=cjs',
|
||||
'--external:electron',
|
||||
`--outfile=${runnerOutput}`,
|
||||
'--log-level=warning'
|
||||
], { cwd: ROOT, stdio: 'inherit' })
|
||||
|
||||
const electronBinary = require('electron')
|
||||
const config = {
|
||||
installationDirectory,
|
||||
executablePath,
|
||||
appPath,
|
||||
cacheDirectory,
|
||||
directoryFixture: {
|
||||
executablePath: directoryFixtureExe,
|
||||
uninstallerPath: directoryFixtureUninstaller,
|
||||
appPath: path.join(directoryFixtureResources, 'app.asar')
|
||||
}
|
||||
}
|
||||
const env = {
|
||||
...process.env,
|
||||
YCODE_MARKER_CONFIG: Buffer.from(JSON.stringify(config)).toString('base64')
|
||||
}
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
delete env.ELECTRON_NO_ASAR
|
||||
const child = spawnSync(electronBinary, [runnerOutput], {
|
||||
cwd: RUN_ROOT,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 45_000,
|
||||
maxBuffer: 4 * 1024 * 1024
|
||||
})
|
||||
if (child.error) throw child.error
|
||||
if (child.status !== 0) {
|
||||
throw new Error(`Electron marker runner failed (${child.status}): ${child.stderr || child.stdout}`)
|
||||
}
|
||||
const marker = child.stdout.split(/\r?\n/).find(line => line.startsWith('YCODE_MARKER_RESULT='))
|
||||
if (!marker) throw new Error(`Electron marker runner returned no result: ${child.stderr || child.stdout}`)
|
||||
const result = JSON.parse(Buffer.from(marker.slice('YCODE_MARKER_RESULT='.length), 'base64').toString('utf8'))
|
||||
|
||||
assert.equal(result.installationDirectory.toLowerCase(), installationDirectory.toLowerCase())
|
||||
assert.equal(result.patchedFs.isFile, false)
|
||||
assert.equal(result.patchedFs.isDirectory, true)
|
||||
assert.equal(result.physicalAsar.isFile, true)
|
||||
assert.equal(result.physicalAsar.isDirectory, false)
|
||||
assert.equal(result.physicalResources.isFile, false)
|
||||
assert.equal(result.physicalResources.isDirectory, true)
|
||||
assert.equal(result.directoryError?.code, 'installer-marker')
|
||||
assert.match(result.directoryError?.message || '', /app\.asar/)
|
||||
assert.equal(result.wrongRegistryError?.code, 'installer-registry')
|
||||
assert.equal(result.installerLaunched, false)
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
passed: true,
|
||||
registryDisplayVersion: displayVersion,
|
||||
uninstallerPath,
|
||||
executablePath,
|
||||
appPath,
|
||||
runnerOutput,
|
||||
result
|
||||
}
|
||||
const reportPath = path.join(RUN_ROOT, 'report.json')
|
||||
await fsp.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
||||
await fsp.mkdir(path.dirname(LATEST_REPORT), { recursive: true })
|
||||
await fsp.writeFile(LATEST_REPORT, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
||||
console.log(`PASS installer marker: patched fs directory, original-fs file, directory and registry rejection`)
|
||||
console.log(`report: ${reportPath}`)
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,666 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const fsp = require('node:fs/promises')
|
||||
const http = require('node:http')
|
||||
const net = require('node:net')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
const MANIFEST_URL = 'http://192.168.10.6/ycode/releases/latest.json'
|
||||
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
||||
|
||||
function readExpectedVersion() {
|
||||
const args = process.argv.slice(2)
|
||||
let value = null
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const argument = args[index]
|
||||
if (argument === '--expected-version') {
|
||||
value = args[++index]
|
||||
} else if (argument.startsWith('--expected-version=')) {
|
||||
value = argument.slice('--expected-version='.length)
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argument}`)
|
||||
}
|
||||
}
|
||||
if (value === null) value = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
|
||||
if (typeof value !== 'string' || !SEMVER_RE.test(value)) throw new Error(`Invalid expected version: ${value}`)
|
||||
return value
|
||||
}
|
||||
|
||||
const EXPECTED_VERSION = readExpectedVersion()
|
||||
const PACKAGED_EXE = path.resolve(process.env.YCODE_QA_PACKAGED_EXE || path.join(ROOT, 'release', 'win-unpacked', 'YCode.exe'))
|
||||
const PUBLIC_KEY = path.join(ROOT, 'resources', 'updates', 'public-key.pem')
|
||||
const OUTPUT_ROOT = path.join(ROOT, '.tmp', 'updates-live')
|
||||
const STAMP = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const RUN_ROOT = path.join(OUTPUT_ROOT, STAMP)
|
||||
const REPORT_PATH = path.join(RUN_ROOT, 'report.json')
|
||||
const LATEST_REPORT_PATH = path.join(OUTPUT_ROOT, 'latest-report.json')
|
||||
const SCREENSHOT_PATH = path.join(RUN_ROOT, 'production-boot.png')
|
||||
const REGISTRY_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\8a4ee941-d893-569b-8bcb-2a55d270c04b'
|
||||
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
let activeBootChild = null
|
||||
|
||||
async function handleInterrupt(signal) {
|
||||
await stopExactChild(activeBootChild)
|
||||
process.exit(signal === 'SIGINT' ? 130 : 143)
|
||||
}
|
||||
|
||||
process.once('SIGINT', () => void handleInterrupt('SIGINT'))
|
||||
process.once('SIGTERM', () => void handleInterrupt('SIGTERM'))
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
async function sha256File(filename) {
|
||||
const hash = crypto.createHash('sha256')
|
||||
for await (const chunk of fs.createReadStream(filename)) hash.update(chunk)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function writeJsonAtomic(filename, value) {
|
||||
await fsp.mkdir(path.dirname(filename), { recursive: true })
|
||||
const temporary = `${filename}.${process.pid}.tmp`
|
||||
await fsp.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
await fsp.rename(temporary, filename)
|
||||
}
|
||||
|
||||
function errorDetails(error) {
|
||||
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack }
|
||||
return { name: 'Error', message: String(error) }
|
||||
}
|
||||
|
||||
async function fetchBytes(url, maximum = 1024 * 1024, timeoutMs = 30_000) {
|
||||
let current = new URL(url)
|
||||
const origin = current.origin
|
||||
for (let redirects = 0; redirects <= 5; redirects += 1) {
|
||||
const response = await fetch(current, {
|
||||
redirect: 'manual',
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
})
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
const location = response.headers.get('location')
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
if (!location || redirects === 5) throw new Error('Manifest redirect chain is invalid')
|
||||
const next = new URL(location, current)
|
||||
if (next.origin !== origin) throw new Error('Manifest redirect left the configured origin')
|
||||
current = next
|
||||
continue
|
||||
}
|
||||
if (!response.ok) throw new Error(`Manifest request failed with HTTP ${response.status}`)
|
||||
const declared = Number(response.headers.get('content-length') || 0)
|
||||
if (declared > maximum) throw new Error('Manifest response exceeds the size limit')
|
||||
const bytes = Buffer.from(await response.arrayBuffer())
|
||||
if (bytes.length > maximum) throw new Error('Manifest response exceeds the size limit')
|
||||
return bytes
|
||||
}
|
||||
throw new Error('Manifest redirect chain is invalid')
|
||||
}
|
||||
|
||||
async function reserveLoopbackPort() {
|
||||
const server = net.createServer()
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
if (!address || typeof address === 'string') throw new Error('Unable to reserve a debug port')
|
||||
return address.port
|
||||
}
|
||||
|
||||
function getJson(url, timeoutMs = 2_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.get(url, { timeout: timeoutMs, family: 4 }, response => {
|
||||
const chunks = []
|
||||
response.on('data', chunk => chunks.push(chunk))
|
||||
response.on('end', () => {
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${response.statusCode} from ${url}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
request.on('timeout', () => request.destroy(new Error('CDP endpoint timed out')))
|
||||
request.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForCdpPage(port, child) {
|
||||
const deadline = Date.now() + 45_000
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) throw new Error(`Packaged app exited early with code ${child.exitCode}`)
|
||||
try {
|
||||
const targets = await getJson(`http://127.0.0.1:${port}/json/list`)
|
||||
const page = targets.find(target => target.type === 'page'
|
||||
&& typeof target.webSocketDebuggerUrl === 'string'
|
||||
&& (/\/index\.html(?:[?#]|$)/.test(target.url) || /\/$/.test(target.url)))
|
||||
if (page) return page
|
||||
} catch {
|
||||
// Chromium's endpoint is not ready yet.
|
||||
}
|
||||
await sleep(250)
|
||||
}
|
||||
throw new Error('Timed out waiting for the packaged app renderer target')
|
||||
}
|
||||
|
||||
function openWebSocket(url) {
|
||||
const WebSocketCtor = globalThis.WebSocket || require('ws')
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocketCtor(url)
|
||||
const timer = setTimeout(() => reject(new Error('CDP WebSocket open timed out')), 15_000)
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
socket.removeEventListener?.('open', onOpen)
|
||||
socket.removeEventListener?.('error', onError)
|
||||
}
|
||||
const onOpen = () => {
|
||||
cleanup()
|
||||
resolve(socket)
|
||||
}
|
||||
const onError = error => {
|
||||
cleanup()
|
||||
reject(error instanceof Error ? error : new Error('CDP WebSocket failed'))
|
||||
}
|
||||
socket.addEventListener('open', onOpen)
|
||||
socket.addEventListener('error', onError)
|
||||
})
|
||||
}
|
||||
|
||||
class CdpClient {
|
||||
constructor(socket) {
|
||||
this.socket = socket
|
||||
this.nextId = 1
|
||||
this.pending = new Map()
|
||||
socket.addEventListener('message', event => {
|
||||
let message
|
||||
try {
|
||||
const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8')
|
||||
message = JSON.parse(text)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!message.id) return
|
||||
const pending = this.pending.get(message.id)
|
||||
if (!pending) return
|
||||
this.pending.delete(message.id)
|
||||
clearTimeout(pending.timer)
|
||||
if (message.error) pending.reject(new Error(`${message.error.message || 'CDP error'} (${message.error.code})`))
|
||||
else pending.resolve(message.result)
|
||||
})
|
||||
socket.addEventListener('close', () => {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject(new Error('CDP WebSocket closed'))
|
||||
}
|
||||
this.pending.clear()
|
||||
})
|
||||
}
|
||||
|
||||
static async connect(url) {
|
||||
return new CdpClient(await openWebSocket(url))
|
||||
}
|
||||
|
||||
request(method, params = {}, timeoutMs = 30_000) {
|
||||
const id = this.nextId
|
||||
this.nextId += 1
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id)
|
||||
reject(new Error(`CDP request timed out: ${method}`))
|
||||
}, timeoutMs)
|
||||
this.pending.set(id, { resolve, reject, timer })
|
||||
this.socket.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
async evaluate(expression) {
|
||||
const result = await this.request('Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
userGesture: false
|
||||
})
|
||||
if (result.exceptionDetails) {
|
||||
const description = result.exceptionDetails.exception?.description
|
||||
|| result.exceptionDetails.text
|
||||
|| 'Renderer evaluation failed'
|
||||
throw new Error(description)
|
||||
}
|
||||
return result.result?.value
|
||||
}
|
||||
|
||||
close() {
|
||||
try {
|
||||
this.socket.close()
|
||||
} catch {
|
||||
// The renderer may already be gone.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForUpdatesApi(client, child) {
|
||||
const deadline = Date.now() + 30_000
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) throw new Error(`Packaged app exited early with code ${child.exitCode}`)
|
||||
try {
|
||||
const ready = await client.evaluate(
|
||||
"typeof window.ycode?.updates?.getState === 'function' && document.readyState === 'complete'"
|
||||
)
|
||||
if (ready === true) return
|
||||
} catch {
|
||||
// React/preload may still be starting.
|
||||
}
|
||||
await sleep(250)
|
||||
}
|
||||
throw new Error('Timed out waiting for window.ycode.updates')
|
||||
}
|
||||
|
||||
const UPDATER_INSPECTION_EXPRESSION = `(async () => {
|
||||
const api = window.ycode?.updates
|
||||
if (!api) throw new Error('window.ycode.updates is unavailable')
|
||||
const apiMethods = ['getState', 'check', 'download', 'install', 'dismiss', 'openDownloadPage', 'openLog', 'onState']
|
||||
.filter(name => typeof api[name] === 'function')
|
||||
const before = await api.getState()
|
||||
const events = []
|
||||
const unsubscribe = api.onState(state => events.push(state))
|
||||
let after
|
||||
try {
|
||||
after = await api.check()
|
||||
const deadline = Date.now() + 15000
|
||||
while (Date.now() < deadline) {
|
||||
if (events.some(state => state.status === 'up-to-date')) break
|
||||
if (events.some(state => state.status === 'error' || state.status === 'unsupported')) break
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
} finally {
|
||||
unsubscribe()
|
||||
}
|
||||
return { apiMethods, before, after, events }
|
||||
})()`
|
||||
|
||||
async function captureScreenshot(client, filename) {
|
||||
await client.request('Page.enable')
|
||||
const screenshot = await client.request('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: false
|
||||
})
|
||||
await fsp.writeFile(filename, Buffer.from(screenshot.data, 'base64'))
|
||||
}
|
||||
|
||||
async function stopExactChild(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) return
|
||||
const exited = new Promise(resolve => child.once('exit', resolve))
|
||||
child.kill()
|
||||
await Promise.race([exited, sleep(5_000)])
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL')
|
||||
await Promise.race([exited, sleep(2_000)])
|
||||
}
|
||||
}
|
||||
|
||||
async function runProductionBoot(expectedManifest) {
|
||||
if (!fs.existsSync(PACKAGED_EXE)) throw new Error(`Packaged executable is missing: ${PACKAGED_EXE}`)
|
||||
const port = await reserveLoopbackPort()
|
||||
const userData = path.join(RUN_ROOT, 'production-boot-user-data')
|
||||
await fsp.mkdir(userData, { recursive: true })
|
||||
const stdoutPath = path.join(RUN_ROOT, 'production-boot.stdout.log')
|
||||
const stderrPath = path.join(RUN_ROOT, 'production-boot.stderr.log')
|
||||
const stdout = fs.createWriteStream(stdoutPath, { flags: 'w' })
|
||||
const stderr = fs.createWriteStream(stderrPath, { flags: 'w' })
|
||||
const env = {
|
||||
...process.env,
|
||||
YCODE_USER_DATA_DIR: userData,
|
||||
YCODE_SKIP_KERNEL_DATA_RESET: '1',
|
||||
YCODE_QA_OFFSCREEN: '1'
|
||||
}
|
||||
delete env.ELECTRON_RENDERER_URL
|
||||
delete env.YCODE_QA_DIRECTORY
|
||||
for (const key of ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'YCODE_API_KEY', 'CODEBUDDY_API_KEY']) delete env[key]
|
||||
|
||||
const child = spawn(PACKAGED_EXE, [
|
||||
`--remote-debugging-port=${port}`,
|
||||
'--remote-debugging-address=127.0.0.1'
|
||||
], {
|
||||
cwd: RUN_ROOT,
|
||||
env,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
child.stdout.pipe(stdout)
|
||||
child.stderr.pipe(stderr)
|
||||
activeBootChild = child
|
||||
let client
|
||||
let inspection
|
||||
let screenshotWritten = false
|
||||
try {
|
||||
if (!child.pid) throw new Error('Packaged app did not expose a child PID')
|
||||
const page = await waitForCdpPage(port, child)
|
||||
client = await CdpClient.connect(page.webSocketDebuggerUrl)
|
||||
await waitForUpdatesApi(client, child)
|
||||
inspection = await client.evaluate(UPDATER_INSPECTION_EXPRESSION)
|
||||
await captureScreenshot(client, SCREENSHOT_PATH)
|
||||
screenshotWritten = true
|
||||
|
||||
assert.equal(inspection.before.currentVersion, EXPECTED_VERSION)
|
||||
assert.equal(inspection.after.currentVersion, EXPECTED_VERSION)
|
||||
assert.equal(inspection.after.status, 'up-to-date')
|
||||
assert.equal(inspection.after.lastExplicit, true)
|
||||
assert.equal(inspection.apiMethods.length, 8)
|
||||
assert.equal(inspection.events.some(state => state.status === 'checking'), true)
|
||||
assert.equal(inspection.events.some(state => state.status === 'up-to-date'), true)
|
||||
assert.equal(inspection.events.some(state => ['downloading', 'ready', 'installing'].includes(state.status)), false)
|
||||
assert.equal(inspection.events.every(state => state.currentVersion === EXPECTED_VERSION), true)
|
||||
|
||||
return {
|
||||
mode: 'actual-production-packaged-boot',
|
||||
executable: PACKAGED_EXE,
|
||||
childPid: child.pid,
|
||||
debugPort: port,
|
||||
isolatedUserData: userData,
|
||||
expectedServerVersion: expectedManifest.version,
|
||||
apiMethods: inspection.apiMethods,
|
||||
before: inspection.before,
|
||||
after: inspection.after,
|
||||
sharedIpcEvents: inspection.events,
|
||||
screenshot: screenshotWritten ? SCREENSHOT_PATH : null,
|
||||
stdoutLog: stdoutPath,
|
||||
stderrLog: stderrPath,
|
||||
installerOrUpgradeRequested: false,
|
||||
passed: true
|
||||
}
|
||||
} finally {
|
||||
client?.close()
|
||||
await stopExactChild(child)
|
||||
if (activeBootChild === child) activeBootChild = null
|
||||
stdout.end()
|
||||
stderr.end()
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntimeEntry(filename) {
|
||||
const relative = value => `./${path.relative(path.dirname(filename), value).replace(/\\/g, '/')}`
|
||||
return [
|
||||
`export { UpdateManager } from ${JSON.stringify(relative(path.join(ROOT, 'src/main/updates/manager.ts')))}`,
|
||||
`export { FileUpdatePersistence } from ${JSON.stringify(relative(path.join(ROOT, 'src/main/updates/storage.ts')))}`,
|
||||
`export { verifySignedManifest } from ${JSON.stringify(relative(path.join(ROOT, 'src/main/updates/manifest.ts')))}`,
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function bundleActualManager(runtimeDirectory) {
|
||||
fs.mkdirSync(runtimeDirectory, { recursive: true })
|
||||
const entry = path.join(runtimeDirectory, 'entry.ts')
|
||||
const output = path.join(runtimeDirectory, 'updater-runtime.cjs')
|
||||
fs.writeFileSync(entry, createRuntimeEntry(entry), 'utf8')
|
||||
const esbuild = path.join(ROOT, 'node_modules', 'esbuild', 'bin', 'esbuild')
|
||||
if (!fs.existsSync(esbuild)) throw new Error(`esbuild is missing: ${esbuild}`)
|
||||
execFileSync(process.execPath, [
|
||||
esbuild,
|
||||
entry,
|
||||
'--bundle',
|
||||
'--platform=node',
|
||||
'--format=cjs',
|
||||
'--target=node22',
|
||||
`--outfile=${output}`,
|
||||
'--log-level=warning'
|
||||
], { cwd: ROOT, stdio: 'inherit' })
|
||||
return { entry, output, runtime: require(output) }
|
||||
}
|
||||
|
||||
function forbiddenInstaller() {
|
||||
const calls = { validateEnvironment: 0, verifyInstaller: 0, launch: 0 }
|
||||
const fail = name => {
|
||||
calls[name] += 1
|
||||
throw new Error('Live verification is download-only; installer access is forbidden')
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
adapter: {
|
||||
validateEnvironment: async () => fail('validateEnvironment'),
|
||||
verifyInstaller: async () => fail('verifyInstaller'),
|
||||
launch: async () => fail('launch')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runSimulatedPredecessorDownload(expectedManifest, runtime, publicKeyPem) {
|
||||
const directory = path.join(RUN_ROOT, 'simulated-predecessor')
|
||||
const cacheDirectory = path.join(directory, 'cache')
|
||||
const persistence = new runtime.FileUpdatePersistence(path.join(directory, 'state.json'))
|
||||
const forbidden = forbiddenInstaller()
|
||||
const callbacks = { beforeInstall: 0, quitForUpdate: 0, installFailed: 0 }
|
||||
const events = []
|
||||
const manager = new runtime.UpdateManager({
|
||||
currentVersion: '0.0.1',
|
||||
manifestUrl: MANIFEST_URL,
|
||||
cacheDirectory,
|
||||
logFile: path.join(directory, 'updater.log'),
|
||||
persistence,
|
||||
publicKey: async () => publicKeyPem,
|
||||
supported: true,
|
||||
installer: forbidden.adapter,
|
||||
beforeInstall: async () => {
|
||||
callbacks.beforeInstall += 1
|
||||
throw new Error('beforeInstall is forbidden in live download verification')
|
||||
},
|
||||
quitForUpdate: async () => {
|
||||
callbacks.quitForUpdate += 1
|
||||
throw new Error('quitForUpdate is forbidden in live download verification')
|
||||
},
|
||||
installFailed: () => {
|
||||
callbacks.installFailed += 1
|
||||
},
|
||||
autoDownload: false,
|
||||
manifestTimeoutMs: 60_000,
|
||||
downloadTimeoutMs: 20 * 60_000
|
||||
})
|
||||
let lastLoggedProgress = -1
|
||||
const unsubscribe = manager.subscribe(state => {
|
||||
events.push(state)
|
||||
if (state.status === 'downloading' && typeof state.progress === 'number') {
|
||||
const bucket = Math.floor(state.progress / 10) * 10
|
||||
if (bucket > lastLoggedProgress) {
|
||||
lastLoggedProgress = bucket
|
||||
console.log(`simulated predecessor download: ${state.progress}%`)
|
||||
}
|
||||
}
|
||||
})
|
||||
try {
|
||||
const checked = await manager.check(true)
|
||||
assert.equal(checked.status, 'available')
|
||||
assert.equal(checked.version, expectedManifest.version)
|
||||
const downloaded = await manager.download(true)
|
||||
assert.equal(downloaded.status, 'ready')
|
||||
assert.equal(downloaded.progress, 100)
|
||||
assert.equal(downloaded.currentVersion, '0.0.1')
|
||||
assert.equal(downloaded.version, expectedManifest.version)
|
||||
assert.equal(downloaded.lastExplicit, true)
|
||||
|
||||
const filename = `YCode-${expectedManifest.version}-Setup.exe`
|
||||
const cachedPath = path.join(cacheDirectory, filename)
|
||||
const info = await fsp.stat(cachedPath)
|
||||
const digest = await sha256File(cachedPath)
|
||||
assert.equal(info.isFile(), true)
|
||||
assert.equal(info.size, expectedManifest.size)
|
||||
assert.equal(digest, expectedManifest.sha256)
|
||||
assert.equal(path.basename(cachedPath), filename)
|
||||
|
||||
const persisted = await persistence.load()
|
||||
assert.equal(persisted.unfinishedVersion, undefined)
|
||||
assert.equal(persisted.target, undefined)
|
||||
assert.deepEqual(forbidden.calls, { validateEnvironment: 0, verifyInstaller: 0, launch: 0 })
|
||||
assert.deepEqual(callbacks, { beforeInstall: 0, quitForUpdate: 0, installFailed: 0 })
|
||||
assert.equal(events.some(state => state.status === 'downloading'), true)
|
||||
|
||||
return {
|
||||
mode: 'simulated-predecessor-real-network-download',
|
||||
simulatedPredecessorVersion: '0.0.1',
|
||||
actualApplicationVersionChanged: false,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
expectedVersion: expectedManifest.version,
|
||||
state: downloaded,
|
||||
observedDownloading: true,
|
||||
cachedPath,
|
||||
size: info.size,
|
||||
sha256: digest,
|
||||
installerAdapterCalls: forbidden.calls,
|
||||
installCallbacks: callbacks,
|
||||
passed: true
|
||||
}
|
||||
} finally {
|
||||
unsubscribe()
|
||||
manager.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function readRealUserState() {
|
||||
let registryFingerprint = null
|
||||
let registryEntryPresent = false
|
||||
try {
|
||||
const output = execFileSync('reg.exe', ['query', REGISTRY_KEY], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 10_000,
|
||||
maxBuffer: 64 * 1024
|
||||
})
|
||||
registryFingerprint = sha256(output)
|
||||
registryEntryPresent = true
|
||||
} catch {
|
||||
// The current user may not have a production installation.
|
||||
}
|
||||
|
||||
let installedExeFingerprint = null
|
||||
let installedExePresent = false
|
||||
const installedExe = path.join(process.env.LOCALAPPDATA || '', 'Programs', 'YCode', 'YCode.exe')
|
||||
try {
|
||||
const info = fs.statSync(installedExe)
|
||||
installedExePresent = true
|
||||
installedExeFingerprint = sha256(`${installedExe}|${info.size}|${info.mtimeMs}`)
|
||||
} catch {
|
||||
// Optional read-only check only.
|
||||
}
|
||||
return { registryEntryPresent, registryFingerprint, installedExePresent, installedExeFingerprint }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fsp.mkdir(RUN_ROOT, { recursive: true })
|
||||
const realUserStateBefore = readRealUserState()
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
expectedVersion: EXPECTED_VERSION,
|
||||
realApplicationUpgradePerformed: false,
|
||||
installerLaunched: false,
|
||||
checks: {
|
||||
productionPackagedBoot: null,
|
||||
simulatedPredecessorDownload: null
|
||||
},
|
||||
realUserStateBefore,
|
||||
realUserStateAfter: null,
|
||||
limitations: [
|
||||
'Production boot verifies only packaged updater IPC and up-to-date state.',
|
||||
'Installer download uses a simulated predecessor version 0.0.1 manager.',
|
||||
'No real application upgrade, installer launch, deployment, or replacement is performed.'
|
||||
]
|
||||
}
|
||||
|
||||
let exitCode = 0
|
||||
try {
|
||||
const publicKeyPem = await fsp.readFile(PUBLIC_KEY, 'utf8')
|
||||
const runtime = bundleActualManager(path.join(RUN_ROOT, 'runtime')).runtime
|
||||
const manifestBytes = await fetchBytes(MANIFEST_URL)
|
||||
const manifest = runtime.verifySignedManifest(manifestBytes, {
|
||||
manifestUrl: MANIFEST_URL,
|
||||
publicKeyPem
|
||||
})
|
||||
assert.equal(manifest.payload.version, EXPECTED_VERSION)
|
||||
const platform = manifest.payload.platforms['windows-x64']
|
||||
const expectedManifest = {
|
||||
version: manifest.payload.version,
|
||||
url: platform.url,
|
||||
size: platform.size,
|
||||
sha256: platform.sha256
|
||||
}
|
||||
report.verifiedManifest = expectedManifest
|
||||
|
||||
try {
|
||||
report.checks.productionPackagedBoot = await runProductionBoot(expectedManifest)
|
||||
} catch (error) {
|
||||
exitCode = 1
|
||||
report.checks.productionPackagedBoot = {
|
||||
mode: 'actual-production-packaged-boot',
|
||||
passed: false,
|
||||
error: errorDetails(error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
report.checks.simulatedPredecessorDownload = await runSimulatedPredecessorDownload(
|
||||
expectedManifest,
|
||||
runtime,
|
||||
publicKeyPem
|
||||
)
|
||||
} catch (error) {
|
||||
exitCode = 1
|
||||
report.checks.simulatedPredecessorDownload = {
|
||||
mode: 'simulated-predecessor-real-network-download',
|
||||
simulatedPredecessorVersion: '0.0.1',
|
||||
actualApplicationVersionChanged: false,
|
||||
passed: false,
|
||||
error: errorDetails(error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
exitCode = 1
|
||||
report.preflightError = errorDetails(error)
|
||||
} finally {
|
||||
report.realUserStateAfter = readRealUserState()
|
||||
report.realUserStateUnchanged = JSON.stringify(report.realUserStateBefore) === JSON.stringify(report.realUserStateAfter)
|
||||
report.finishedAt = new Date().toISOString()
|
||||
report.passed = exitCode === 0
|
||||
await writeJsonAtomic(REPORT_PATH, report)
|
||||
await writeJsonAtomic(LATEST_REPORT_PATH, report)
|
||||
console.log(`report: ${REPORT_PATH}`)
|
||||
if (fs.existsSync(SCREENSHOT_PATH)) console.log(`screenshot: ${SCREENSHOT_PATH}`)
|
||||
}
|
||||
process.exitCode = exitCode
|
||||
}
|
||||
|
||||
main().catch(async error => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
try {
|
||||
await fsp.mkdir(RUN_ROOT, { recursive: true })
|
||||
await writeJsonAtomic(REPORT_PATH, {
|
||||
schemaVersion: 1,
|
||||
startedAt: null,
|
||||
finishedAt: new Date().toISOString(),
|
||||
manifestUrl: MANIFEST_URL,
|
||||
expectedVersion: EXPECTED_VERSION,
|
||||
fatalError: errorDetails(error),
|
||||
passed: false,
|
||||
realApplicationUpgradePerformed: false,
|
||||
installerLaunched: false
|
||||
})
|
||||
} catch {
|
||||
// Preserve the original failure.
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,560 @@
|
||||
// Real isolated YCode App/preload/renderer verification for the update UI.
|
||||
// Production out/main/preload are loaded unchanged. Only the harness replaces updates:* IPC handlers
|
||||
// so checking, download progress, failure/retry, dismissal and install can be exercised deterministically.
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/updates-ui-'))
|
||||
const data = path.join(directory, 'data')
|
||||
const home = path.join(directory, 'home')
|
||||
await Promise.all([fs.mkdir(data), fs.mkdir(home)])
|
||||
const env = {
|
||||
...process.env,
|
||||
YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_USER_DATA_DIR: data,
|
||||
YCODE_DISABLE_GPU: '1',
|
||||
YCODE_DISABLE_SYSTEM_INTEGRATION: '1'
|
||||
}
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(
|
||||
require('electron'),
|
||||
['--no-sandbox', '--disable-gpu-sandbox', __filename],
|
||||
{ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
)
|
||||
let output = ''
|
||||
child.stdout.on('data', bytes => {
|
||||
output += bytes
|
||||
if (bytes.toString().includes('UPDATES_UI_')) process.stdout.write(bytes)
|
||||
})
|
||||
child.stderr.on('data', bytes => {
|
||||
output += bytes
|
||||
if (bytes.toString().includes('UPDATES_UI_')) process.stderr.write(bytes)
|
||||
})
|
||||
const timer = setTimeout(() => child.kill(), 300000)
|
||||
child.once('exit', async code => {
|
||||
clearTimeout(timer)
|
||||
await fs.writeFile(path.join(directory, 'process.log'), output)
|
||||
console.log(JSON.stringify({ directory, exitCode: code }))
|
||||
if (code) console.error(output.slice(-8000))
|
||||
process.exitCode = code ? 1 : 0
|
||||
})
|
||||
})().catch(error => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
} else {
|
||||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const packageVersion = require('../package.json').version
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('updates-ui-'))
|
||||
|
||||
app.disableHardwareAcceleration()
|
||||
app.setPath('userData', path.join(directory, 'data'))
|
||||
app.setPath('sessionData', path.join(directory, 'data'))
|
||||
app.setPath('home', path.join(directory, 'home'))
|
||||
|
||||
let main
|
||||
let settings
|
||||
let phase = 'boot'
|
||||
const checks = []
|
||||
const snapshots = {}
|
||||
const rendererErrors = []
|
||||
const actionCounts = {
|
||||
check: 0,
|
||||
download: 0,
|
||||
install: 0,
|
||||
dismiss: 0,
|
||||
downloadPage: 0,
|
||||
log: 0
|
||||
}
|
||||
let updateState = {
|
||||
status: 'idle',
|
||||
currentVersion: packageVersion,
|
||||
lastExplicit: false
|
||||
}
|
||||
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
function broadcastUpdateState() {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) win.webContents.send('updates:state', { ...updateState })
|
||||
}
|
||||
}
|
||||
|
||||
function setUpdateState(patch, replace = false) {
|
||||
updateState = {
|
||||
...(replace ? { currentVersion: updateState.currentVersion } : updateState),
|
||||
...patch,
|
||||
lastChecked: new Date().toISOString()
|
||||
}
|
||||
broadcastUpdateState()
|
||||
return { ...updateState }
|
||||
}
|
||||
|
||||
const nativeHandle = ipcMain.handle.bind(ipcMain)
|
||||
const absorbedUpdateHandlers = []
|
||||
ipcMain.handle = (channel, listener) => {
|
||||
if (channel.startsWith('updates:')) {
|
||||
absorbedUpdateHandlers.push(channel)
|
||||
return
|
||||
}
|
||||
return nativeHandle(channel, listener)
|
||||
}
|
||||
|
||||
nativeHandle('updates:get-state', () => ({ ...updateState }))
|
||||
nativeHandle('updates:check', () => {
|
||||
actionCounts.check += 1
|
||||
return setUpdateState({ status: 'checking', error: undefined, lastExplicit: true })
|
||||
})
|
||||
nativeHandle('updates:download', () => {
|
||||
actionCounts.download += 1
|
||||
return setUpdateState({ status: 'downloading', progress: 37, error: undefined, lastExplicit: true })
|
||||
})
|
||||
nativeHandle('updates:install', () => {
|
||||
actionCounts.install += 1
|
||||
return setUpdateState({ status: 'installing', error: undefined, lastExplicit: true })
|
||||
})
|
||||
nativeHandle('updates:dismiss', () => {
|
||||
actionCounts.dismiss += 1
|
||||
return setUpdateState({
|
||||
dismissedVersion: updateState.version,
|
||||
status: updateState.status === 'installing' ? 'ready' : updateState.status
|
||||
})
|
||||
})
|
||||
nativeHandle('updates:open-download-page', () => {
|
||||
actionCounts.downloadPage += 1
|
||||
})
|
||||
nativeHandle('updates:open-log', () => {
|
||||
actionCounts.log += 1
|
||||
})
|
||||
|
||||
async function read(source, win = main) {
|
||||
let timer
|
||||
try {
|
||||
return await Promise.race([
|
||||
win.webContents.executeJavaScript(source),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 15000)
|
||||
})
|
||||
])
|
||||
} catch (error) {
|
||||
throw Error(error.message + ' - ' + source.slice(0, 240))
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function wait(source, label, win = main) {
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
const value = await read(source, win)
|
||||
if (value) return value
|
||||
await pause(35)
|
||||
}
|
||||
throw Error('Timeout: ' + label)
|
||||
}
|
||||
|
||||
async function step(label) {
|
||||
phase = label
|
||||
await fs.writeFile(path.join(directory, 'phase.txt'), label)
|
||||
}
|
||||
|
||||
function check(label, actual, expected) {
|
||||
const pass = typeof expected === 'function'
|
||||
? expected(actual)
|
||||
: JSON.stringify(actual) === JSON.stringify(expected)
|
||||
checks.push({
|
||||
label,
|
||||
pass,
|
||||
actual,
|
||||
expected: typeof expected === 'function' ? expected.toString() : expected
|
||||
})
|
||||
}
|
||||
|
||||
function observe(win) {
|
||||
win.webContents.on('console-message', (_event, level, message) => {
|
||||
if (level >= 3) rendererErrors.push({ windowId: win.id, message })
|
||||
})
|
||||
}
|
||||
|
||||
async function shot(name, win = main) {
|
||||
const image = await win.webContents.capturePage()
|
||||
await fs.writeFile(path.join(directory, name + '.png'), image.toPNG())
|
||||
}
|
||||
|
||||
async function click(selector, win = main) {
|
||||
const clicked = await read(`(()=>{
|
||||
const node=document.querySelector(${JSON.stringify(selector)});
|
||||
if(!node)return false;
|
||||
node.click();
|
||||
return true
|
||||
})()`, win)
|
||||
assert.equal(clicked, true, 'click target: ' + selector)
|
||||
await pause(100)
|
||||
}
|
||||
|
||||
async function openSettings(section) {
|
||||
await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`)
|
||||
for (let index = 0; index < 240; index += 1) {
|
||||
settings = BrowserWindow.getAllWindows().find(win => win !== main)
|
||||
if (settings && !settings.webContents.isLoading()) break
|
||||
await pause(25)
|
||||
}
|
||||
assert.ok(settings, 'settings window')
|
||||
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
|
||||
await wait('!!document.querySelector(".help-feedback-panel")', 'about panel', settings)
|
||||
await pause(300)
|
||||
}
|
||||
|
||||
async function setTheme(theme) {
|
||||
await read(`(()=>{
|
||||
localStorage.setItem('ycode:theme',${JSON.stringify(theme)});
|
||||
window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:${JSON.stringify(theme)}}));
|
||||
return true
|
||||
})()`)
|
||||
await wait(`document.documentElement.dataset.theme===${JSON.stringify(theme)}`, 'main theme ' + theme)
|
||||
if (settings && !settings.isDestroyed()) {
|
||||
await wait(`document.documentElement.dataset.theme===${JSON.stringify(theme)}`, 'settings theme ' + theme, settings)
|
||||
}
|
||||
}
|
||||
|
||||
const cardSource = `(()=>{
|
||||
const card=document.querySelector('.update-sidebar-card');
|
||||
const compact=document.querySelector('.update-sidebar-card-collapsed');
|
||||
if(!card&&!compact)return null;
|
||||
const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
|
||||
const style=node=>getComputedStyle(node);
|
||||
const progress=card?.querySelector('.update-sidebar-card__progress');
|
||||
const primary=card?.querySelector('[data-update-action]');
|
||||
return {
|
||||
status:card?.getAttribute('data-status')??null,
|
||||
compact:!!compact,
|
||||
card:card?{box:box(card),background:style(card).backgroundColor,border:style(card).borderTopColor,
|
||||
title:card.querySelector('.update-sidebar-card__title')?.textContent??'',
|
||||
description:card.querySelector('.update-sidebar-card__description')?.textContent??'',
|
||||
primary:primary?{text:primary.textContent.trim(),kind:primary.getAttribute('data-update-action'),disabled:primary.disabled}:null,
|
||||
progress:progress?{value:progress.getAttribute('aria-valuenow'),width:progress.getBoundingClientRect().width}:null,
|
||||
manual:!!card.querySelector('[data-update-manual]'),log:!!card.querySelector('[data-update-log]')}:null,
|
||||
accountCardPresent:!!document.querySelector('.conversation-list-footer .update-sidebar-card-host')
|
||||
}
|
||||
})()`
|
||||
|
||||
const accountSource = `(()=>{
|
||||
const row=document.querySelector('.ycode-account-menu [data-update-action]');
|
||||
if(!row)return null;
|
||||
return {status:row.getAttribute('data-update-status'),kind:row.getAttribute('data-update-action'),
|
||||
label:row.querySelector('.user-menu-item-label')?.textContent??'',
|
||||
value:row.querySelector('.user-menu-item-value')?.textContent??'',
|
||||
ariaDisabled:row.getAttribute('aria-disabled')}
|
||||
})()`
|
||||
|
||||
const aboutSource = `(()=>{
|
||||
const row=document.querySelector('#settings-about-version');
|
||||
const button=row?.querySelector('[data-update-action]');
|
||||
const feedback=document.querySelector('.update-ui-feedback');
|
||||
if(!row||!button)return null;
|
||||
return {status:button.getAttribute('data-update-status'),kind:button.getAttribute('data-update-action'),
|
||||
label:button.textContent.trim(),disabled:button.disabled,feedback:feedback?.textContent.replace(/\\s+/g,' ').trim()??'',
|
||||
currentVersion:row.querySelector('.help-feedback-panel__card-value')?.textContent??''}
|
||||
})()`
|
||||
|
||||
;(async () => {
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
await app.whenReady()
|
||||
for (let index = 0; index < 240; index += 1) {
|
||||
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
|
||||
if (main && !main.webContents.isLoading()) break
|
||||
await pause(25)
|
||||
}
|
||||
assert.ok(main, 'main window')
|
||||
observe(main)
|
||||
main.setSize(1336, 902)
|
||||
main.showInactive()
|
||||
await wait('!!window.ycode&&!!document.querySelector(".conversation-list")', 'main renderer')
|
||||
updateState.currentVersion = packageVersion
|
||||
broadcastUpdateState()
|
||||
await pause(300)
|
||||
|
||||
await step('ready-state')
|
||||
setUpdateState({
|
||||
status: 'ready',
|
||||
version: '0.0.3',
|
||||
notes: '改进更新卡片、失败重试与安装确认。',
|
||||
progress: undefined,
|
||||
error: undefined,
|
||||
dismissedVersion: undefined
|
||||
}, true)
|
||||
await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`, 'ready update card')
|
||||
snapshots.ready = await read(cardSource)
|
||||
check('ready update card is mounted permanently above the account trigger',
|
||||
[snapshots.ready.status, snapshots.ready.accountCardPresent, snapshots.ready.compact], ['ready', true, false])
|
||||
check('ready card offers restart and shows the target version',
|
||||
[snapshots.ready.card.primary.text, /v0\.0\.3/.test(snapshots.ready.card.title)], ['重启升级', true])
|
||||
|
||||
await step('account-sync-ready')
|
||||
await click('.user-menu-trigger--workbuddy')
|
||||
await wait('!!document.querySelector(".ycode-account-menu")', 'account menu')
|
||||
snapshots.accountReady = await read(accountSource)
|
||||
check('account menu shares the ready state and remains actionable',
|
||||
[snapshots.accountReady.status, snapshots.accountReady.kind, snapshots.accountReady.label, snapshots.accountReady.ariaDisabled],
|
||||
['ready', 'install', '重启升级', 'false'])
|
||||
await shot('updates-main-light-ready')
|
||||
await click('.ycode-account-menu [data-update-action="install"]')
|
||||
check('account menu install action reaches the updates IPC bridge', actionCounts.install, 1)
|
||||
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='installing'`, 'account installing state')
|
||||
await read(`document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`)
|
||||
await pause(100)
|
||||
await shot('updates-main-light-ready-card')
|
||||
|
||||
await step('dismiss-and-reveal')
|
||||
setUpdateState({
|
||||
status: 'ready',
|
||||
version: '0.0.3',
|
||||
progress: undefined,
|
||||
error: undefined,
|
||||
dismissedVersion: undefined
|
||||
})
|
||||
await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"] [data-update-dismiss]')`, 'ready card dismiss')
|
||||
await click('[data-update-dismiss]')
|
||||
await wait('!!document.querySelector(".update-sidebar-card-collapsed")', 'collapsed update entry')
|
||||
check('closing the card dismisses this version and keeps a compact update entry',
|
||||
[actionCounts.dismiss, await read(`!document.querySelector('.update-sidebar-card')`)], [1, true])
|
||||
await click('[data-update-reveal]')
|
||||
await wait('!!document.querySelector(".update-sidebar-card")', 'revealed collapsed update card')
|
||||
check('the compact update entry can reveal the dismissed version card again',
|
||||
await read(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`), true)
|
||||
|
||||
await step('error-and-manual-links')
|
||||
setUpdateState({
|
||||
status: 'error',
|
||||
version: '0.0.4',
|
||||
progress: undefined,
|
||||
error: '模拟更新检查失败',
|
||||
dismissedVersion: undefined
|
||||
})
|
||||
await wait(`!!document.querySelector('.update-sidebar-card[data-status="error"]')`, 'error update card')
|
||||
snapshots.error = await read(cardSource)
|
||||
check('error state exposes retry, manual download and update log',
|
||||
[snapshots.error.card.primary.text, snapshots.error.card.manual, snapshots.error.card.log], ['重试', true, true])
|
||||
await click('[data-update-manual]')
|
||||
await click('[data-update-log]')
|
||||
check('manual download and update log actions reach the updates IPC bridge',
|
||||
[actionCounts.downloadPage, actionCounts.log], [1, 1])
|
||||
await click('.update-sidebar-card [data-update-action="check"]')
|
||||
await wait(`!!document.querySelector('.update-sidebar-card')===false`, 'checking hides the non-actionable card')
|
||||
check('retry invokes the real check IPC action', actionCounts.check, 1)
|
||||
setUpdateState({ status: 'ready', version: '0.0.4', error: undefined })
|
||||
await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`, 'ready after retry')
|
||||
|
||||
await step('settings-ready')
|
||||
await openSettings('helpFeedback')
|
||||
observe(settings)
|
||||
snapshots.aboutReady = await read(aboutSource, settings)
|
||||
check('settings About shows the shared ready state',
|
||||
[snapshots.aboutReady.status, snapshots.aboutReady.kind, snapshots.aboutReady.label, /新版本 v0\.0\.4 已就绪/.test(snapshots.aboutReady.feedback)],
|
||||
['ready', 'install', '重启升级', true])
|
||||
await shot('updates-settings-light-ready', settings)
|
||||
|
||||
await step('settings-download-sync')
|
||||
setUpdateState({
|
||||
status: 'available',
|
||||
version: '0.0.5',
|
||||
progress: undefined,
|
||||
error: undefined,
|
||||
dismissedVersion: undefined
|
||||
})
|
||||
await wait(`document.querySelector('#settings-about-version [data-update-status]')?.getAttribute('data-update-status')==='available'`, 'settings available state', settings)
|
||||
await wait(`!!document.querySelector('.update-sidebar-card[data-status="available"]')`, 'main available card')
|
||||
await click('#settings-about-version [data-update-action="download"]', settings)
|
||||
await wait(`document.querySelector('#settings-about-version [data-update-action]')?.textContent.includes('下载中 37%')`, 'settings download progress', settings)
|
||||
await wait(`document.querySelector('.update-sidebar-card[data-status="downloading"]')?.querySelector('.update-sidebar-card__progress')?.getAttribute('aria-valuenow')==='37'`, 'main download progress')
|
||||
snapshots.downloading = {
|
||||
main: await read(cardSource),
|
||||
settings: await read(aboutSource, settings)
|
||||
}
|
||||
check('download initiated in settings synchronizes to the sidebar card',
|
||||
[actionCounts.download, snapshots.downloading.main.status, snapshots.downloading.main.card.progress.value, snapshots.downloading.settings.label],
|
||||
[1, 'downloading', '37', '下载中 37%'])
|
||||
setUpdateState({ status: 'ready', version: '0.0.5', progress: 100 })
|
||||
await wait(`document.querySelector('#settings-about-version [data-update-action]')?.textContent.trim()==='重启升级'`, 'settings ready after download', settings)
|
||||
await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`, 'main ready after download')
|
||||
check('download completion synchronizes settings and sidebar', await Promise.all([
|
||||
read(`document.querySelector('#settings-about-version [data-update-action]')?.textContent.trim()`, settings),
|
||||
read(`document.querySelector('.update-sidebar-card__primary')?.textContent.trim()`)
|
||||
]), ['重启升级', '重启升级'])
|
||||
|
||||
await step('dark')
|
||||
await setTheme('dark')
|
||||
snapshots.dark = {
|
||||
main: await read(cardSource),
|
||||
settings: await read(aboutSource, settings)
|
||||
}
|
||||
check('update card remains rendered with shared dark theme text and surface',
|
||||
[snapshots.dark.main.status, snapshots.dark.main.card.background !== 'rgba(0, 0, 0, 0)'], ['ready', true])
|
||||
await shot('updates-main-dark')
|
||||
await shot('updates-settings-dark', settings)
|
||||
|
||||
await step('narrow-light')
|
||||
await setTheme('light')
|
||||
main.setSize(940, 800)
|
||||
await pause(300)
|
||||
snapshots.narrow = await read(`(()=>{
|
||||
const card=document.querySelector('.update-sidebar-card');
|
||||
const sidebar=document.querySelector('.conversation-list');
|
||||
if(!card||!sidebar)return null;
|
||||
const c=card.getBoundingClientRect(),s=sidebar.getBoundingClientRect();
|
||||
return {viewport:innerWidth,card:[c.x,c.y,c.width,c.height],sidebar:[s.x,s.y,s.width,s.height],
|
||||
horizontalOverflow:document.documentElement.scrollWidth>innerWidth,
|
||||
title:card.querySelector('.update-sidebar-card__title')?.textContent??''}
|
||||
})()`)
|
||||
check('narrow window keeps the update card inside the sidebar without horizontal overflow',
|
||||
[snapshots.narrow.horizontalOverflow, snapshots.narrow.card[2] <= snapshots.narrow.sidebar[2] + 1],
|
||||
[false, true])
|
||||
await shot('updates-main-narrow')
|
||||
|
||||
await step('up-to-date')
|
||||
setUpdateState({
|
||||
status: 'up-to-date',
|
||||
version: undefined,
|
||||
progress: undefined,
|
||||
error: undefined,
|
||||
dismissedVersion: undefined
|
||||
}, true)
|
||||
await wait('!document.querySelector(".update-sidebar-card")', 'up-to-date hides sidebar card')
|
||||
await wait(`document.querySelector('#settings-about-version [data-update-status]')?.getAttribute('data-update-status')==='up-to-date'`, 'settings up-to-date', settings)
|
||||
await click('.user-menu-trigger--workbuddy')
|
||||
await wait('!!document.querySelector(".ycode-account-menu")', 'account menu up-to-date')
|
||||
snapshots.upToDate = {
|
||||
mainCard: await read(`!document.querySelector('.update-sidebar-card')`),
|
||||
account: await read(accountSource),
|
||||
settings: await read(aboutSource, settings)
|
||||
}
|
||||
check('up-to-date state hides the sidebar card and synchronizes account/settings',
|
||||
[snapshots.upToDate.mainCard, snapshots.upToDate.account.status, snapshots.upToDate.account.value, /已是最新版本/.test(snapshots.upToDate.settings.feedback)],
|
||||
[true, 'up-to-date', '已是最新', true])
|
||||
|
||||
await step('unsupported')
|
||||
setUpdateState({
|
||||
status: 'checking',
|
||||
version: undefined,
|
||||
progress: undefined,
|
||||
error: undefined,
|
||||
dismissedVersion: undefined,
|
||||
unfinishedVersion: undefined
|
||||
}, true)
|
||||
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='checking'`, 'background check clears explicit intent')
|
||||
setUpdateState({
|
||||
status: 'unsupported',
|
||||
version: undefined,
|
||||
progress: undefined,
|
||||
error: '当前平台不支持自动更新',
|
||||
dismissedVersion: undefined,
|
||||
unfinishedVersion: undefined
|
||||
}, true)
|
||||
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='unsupported'`, 'unsupported account row')
|
||||
snapshots.unsupported = {
|
||||
main: await read(cardSource),
|
||||
account: await read(accountSource),
|
||||
settings: await read(aboutSource, settings)
|
||||
}
|
||||
check('background unsupported state stays out of the sidebar while account/settings remain honest',
|
||||
[snapshots.unsupported.main, snapshots.unsupported.account.label, snapshots.unsupported.account.value,
|
||||
snapshots.unsupported.account.kind, /当前平台不支持自动更新/.test(snapshots.unsupported.settings.feedback)],
|
||||
[null, '手动下载', '需手动下载', 'manual', true])
|
||||
|
||||
await step('background-error-quiet')
|
||||
setUpdateState({
|
||||
status: 'error',
|
||||
version: undefined,
|
||||
progress: undefined,
|
||||
error: '后台检查失败',
|
||||
dismissedVersion: undefined,
|
||||
unfinishedVersion: undefined
|
||||
}, true)
|
||||
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='error'`, 'quiet background error')
|
||||
await pause(150)
|
||||
check('background error with no released version does not open a sidebar card',
|
||||
[await read(`!document.querySelector('.update-sidebar-card')`), (await read(accountSource)).label],
|
||||
[true, '重试'])
|
||||
await click('.ycode-account-menu [data-update-action="check"]')
|
||||
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='checking'`, 'explicit retry checking')
|
||||
setUpdateState({ status: 'error', error: '显式检查失败' }, true)
|
||||
await wait(`!!document.querySelector('.update-sidebar-card[data-status="error"]')`, 'explicit error card')
|
||||
check('explicit retry failure is allowed to surface in the sidebar',
|
||||
[actionCounts.check, await read(`document.querySelector('.update-sidebar-card__description')?.textContent`)],
|
||||
[2, '显式检查失败'])
|
||||
|
||||
await step('unfinished-version')
|
||||
setUpdateState({
|
||||
status: 'up-to-date',
|
||||
version: undefined,
|
||||
progress: undefined,
|
||||
error: undefined,
|
||||
dismissedVersion: undefined,
|
||||
unfinishedVersion: '0.0.6'
|
||||
}, true)
|
||||
await wait(`!!document.querySelector('.update-sidebar-card[data-unfinished="true"]')`, 'unfinished update card')
|
||||
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='up-to-date'`, 'unfinished account row')
|
||||
snapshots.unfinished = {
|
||||
main: await read(cardSource),
|
||||
account: await read(accountSource),
|
||||
settings: await read(aboutSource, settings)
|
||||
}
|
||||
check('unfinishedVersion survives an otherwise latest state as an actionable warning',
|
||||
[snapshots.unfinished.main.card.title, snapshots.unfinished.main.card.primary.text,
|
||||
snapshots.unfinished.account.label, snapshots.unfinished.account.value,
|
||||
/上次升级至 v0\.0\.6 未完成/.test(snapshots.unfinished.settings.feedback)],
|
||||
['上次升级未完成', '重试更新', '重试更新', '上次升级未完成', true])
|
||||
await click('[data-update-log]')
|
||||
check('unfinished update warning opens the update log', actionCounts.log, 2)
|
||||
|
||||
await step('errors')
|
||||
check('no renderer error logs', rendererErrors, [])
|
||||
|
||||
const report = {
|
||||
result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
|
||||
scope: 'Real isolated YCode main/preload/renderer and settings window at 1336x902, 940x800 narrow, 900x680 settings; deterministic updates IPC injection; ready/downloading/error/up-to-date/unsupported/unfinished states, retry, dismissal, manual/log links, cross-window synchronization, light/dark screenshots.',
|
||||
limitations: [
|
||||
'This verifies renderer interaction and IPC call shape, not a real package download, signature verification, NSIS install, or restart.',
|
||||
'The harness intercepts updates:install before the production native confirmation dialog, so confirmation copy and dialog behavior remain a main-process acceptance item.',
|
||||
'Static REA evidence does not include final visual measurements for every WorkBuddy update-card state; screenshots are visual review artifacts, not pixel-parity claims.'
|
||||
],
|
||||
injectedUpdateChannels: [
|
||||
'updates:get-state',
|
||||
'updates:check',
|
||||
'updates:download',
|
||||
'updates:install',
|
||||
'updates:dismiss',
|
||||
'updates:open-download-page',
|
||||
'updates:open-log'
|
||||
],
|
||||
absorbedProductionUpdateHandlers: absorbedUpdateHandlers,
|
||||
actionCounts,
|
||||
snapshots,
|
||||
checks,
|
||||
rendererErrors
|
||||
}
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('UPDATES_UI_' + report.result + ' ' + directory)
|
||||
for (const failure of checks.filter(item => !item.pass)) {
|
||||
console.log('UPDATES_UI_FAILURE ' + JSON.stringify(failure))
|
||||
}
|
||||
app.exit(report.result === 'PASS' ? 0 : 1)
|
||||
})().catch(async error => {
|
||||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({
|
||||
phase,
|
||||
error: String(error),
|
||||
stack: error.stack,
|
||||
actionCounts,
|
||||
checks,
|
||||
snapshots,
|
||||
rendererErrors
|
||||
}, null, 2))
|
||||
console.error(error)
|
||||
app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -15,13 +15,14 @@ import {
|
||||
} from '@anthropic-ai/claude-agent-sdk'
|
||||
import { z } from 'zod'
|
||||
import type { AgentMode, PermissionMode } from '@shared/conversation'
|
||||
import type { ChatEvent, MessagePart, TokenUsage, ToolCallRecord } from '@shared/types'
|
||||
import type { ChatEvent, ContextUsageBreakdown, MessagePart, TokenUsage, ToolCallRecord } from '@shared/types'
|
||||
import type { ResolvedModel } from '@shared/models'
|
||||
import type { ThinkingLevel } from '@shared/workspaces'
|
||||
import type { InteractionBroker } from '../../interaction-broker'
|
||||
import { browserTool } from '../tools/browser'
|
||||
import { claudeAnthropicBaseUrl } from './claude-endpoint'
|
||||
import { planFromExitInput, questionsFromInput } from './claude-tool-input'
|
||||
import { createClaudeCapabilityRuntime } from '../../capabilities/claude-capabilities'
|
||||
|
||||
const READ_ONLY_TOOLS = new Set([
|
||||
'Read',
|
||||
@@ -75,6 +76,9 @@ const DISALLOWED_TOOLS = [
|
||||
]
|
||||
|
||||
interface ClaudeTurnRequest {
|
||||
command?: 'compact'
|
||||
skillIds?: string[]
|
||||
mcpServerIds?: string[]
|
||||
runId: string
|
||||
userMessageId: string
|
||||
prompt: string
|
||||
@@ -83,6 +87,20 @@ interface ClaudeTurnRequest {
|
||||
interactions: InteractionBroker
|
||||
signal: AbortSignal
|
||||
ownerWebContentsId?: number
|
||||
onSegmentComplete?(result: ClaudeTurnResult): Promise<void>
|
||||
onSegmentStart?(inputs: ClaudeAppendInput[]): Promise<void>
|
||||
}
|
||||
|
||||
export interface ClaudeAppendInput {
|
||||
userMessageId: string
|
||||
prompt: string
|
||||
images?: ClaudeTurnRequest['images']
|
||||
skillIds?: string[]
|
||||
mcpServerIds?: string[]
|
||||
}
|
||||
|
||||
function appendError(message: string, submitted: boolean): Error & { submitted: boolean } {
|
||||
return Object.assign(new Error(message), { submitted })
|
||||
}
|
||||
|
||||
export interface ClaudeTurnResult {
|
||||
@@ -118,6 +136,14 @@ interface ActiveTurn {
|
||||
reasoning: Map<number, string>
|
||||
lastEntryId?: string
|
||||
planEmitted: boolean
|
||||
streamUsage?: TokenUsage
|
||||
nativeUsage?: Record<string, unknown>
|
||||
compactBoundary?: boolean
|
||||
foreignOutput?: boolean
|
||||
groupIds: string[]
|
||||
awaitingGroup: boolean
|
||||
appends: Map<string, { input: ClaudeAppendInput; resolve(): void; reject(error: Error): void }>
|
||||
segmentReady?: { promise: Promise<void>; resolve(): void }
|
||||
}
|
||||
|
||||
class AsyncMessageQueue<T> implements AsyncIterable<T> {
|
||||
@@ -294,12 +320,18 @@ function tokenUsage(value: unknown): TokenUsage | undefined {
|
||||
}
|
||||
|
||||
export class ClaudeKernel {
|
||||
private readonly capabilities: ReturnType<typeof createClaudeCapabilityRuntime>
|
||||
private input: AsyncMessageQueue<SDKUserMessage> | null = null
|
||||
private processAbort: AbortController | null = null
|
||||
private instance: Query | null = null
|
||||
private consuming: Promise<void> | null = null
|
||||
private active: ActiveTurn | null = null
|
||||
private starting = false
|
||||
private draining: Promise<void> | null = null
|
||||
private contextQuery: ReturnType<Query['getContextUsage']> | null = null
|
||||
private lastRequestId?: string
|
||||
private lastGroupKey = ''
|
||||
private contextQueryGroup = ''
|
||||
private closed = false
|
||||
private sessionEstablished = false
|
||||
private currentSessionId: string
|
||||
@@ -307,6 +339,9 @@ export class ClaudeKernel {
|
||||
private agentMode: AgentMode
|
||||
|
||||
constructor(private readonly options: ClaudeKernelOptions) {
|
||||
this.capabilities = createClaudeCapabilityRuntime({ cwd: options.workspaceRoot, requestSignal: () => this.active?.request.signal, beforeCall: async () => {
|
||||
if (!await this.waitForSegment()) throw new Error('当前没有可调用能力的活动回合')
|
||||
} })
|
||||
this.currentSessionId = options.resumeSessionId ?? options.sessionId
|
||||
this.permissionMode = options.permissionMode
|
||||
this.agentMode = options.agentMode
|
||||
@@ -361,7 +396,7 @@ export class ClaudeKernel {
|
||||
PreToolUse: [{
|
||||
matcher: 'ExitPlanMode',
|
||||
hooks: [async (input) => {
|
||||
const active = this.active
|
||||
const active = await this.waitForSegment()
|
||||
if (!active) {
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
@@ -407,6 +442,7 @@ export class ClaudeKernel {
|
||||
snapshot: true
|
||||
},
|
||||
mcpServers: {
|
||||
ycode_capabilities: this.capabilities.server,
|
||||
ycode: createSdkMcpServer({
|
||||
name: 'ycode',
|
||||
tools: [
|
||||
@@ -471,14 +507,16 @@ export class ClaudeKernel {
|
||||
|
||||
private async consume(instance: Query): Promise<void> {
|
||||
try {
|
||||
for await (const message of instance) this.handleMessage(message)
|
||||
for await (const message of instance) await this.handleMessage(message)
|
||||
} catch (error) {
|
||||
this.failActive(error instanceof Error ? error : new Error(String(error)))
|
||||
if (!this.closed) console.error('Claude Code process stopped', error)
|
||||
} finally {
|
||||
this.failActive(new Error('Claude Code 进程已退出'))
|
||||
if (this.instance === instance) {
|
||||
instance.close()
|
||||
this.instance = null
|
||||
this.contextQuery = null
|
||||
this.input = null
|
||||
this.processAbort = null
|
||||
}
|
||||
@@ -486,7 +524,7 @@ export class ClaudeKernel {
|
||||
}
|
||||
|
||||
private canUseTool: CanUseTool = async (toolName, input, options): Promise<PermissionResult | null> => {
|
||||
const active = this.active
|
||||
const active = await this.waitForSegment()
|
||||
if (!active) return { behavior: 'deny', message: '当前没有正在运行的 YCode 回合。' }
|
||||
if (options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' }
|
||||
try {
|
||||
@@ -558,7 +596,7 @@ export class ClaudeKernel {
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(message: SDKMessage): void {
|
||||
private async handleMessage(message: SDKMessage): Promise<void> {
|
||||
const value = message as unknown as Record<string, unknown>
|
||||
if (typeof value.session_id === 'string' && value.session_id !== this.currentSessionId) {
|
||||
this.rememberSessionId(value.session_id)
|
||||
@@ -569,6 +607,45 @@ export class ClaudeKernel {
|
||||
if (sessionId) this.rememberSessionId(sessionId)
|
||||
return
|
||||
}
|
||||
if (value.type === 'system' && value.subtype === 'compact_boundary' && this.active?.request.command === 'compact') {
|
||||
this.active.compactBoundary = true
|
||||
return
|
||||
}
|
||||
const activeOwner = this.active
|
||||
if (activeOwner?.request.signal.aborted && ['stream_event', 'assistant', 'result'].includes(String(value.type))) return
|
||||
if (activeOwner && ['stream_event', 'assistant', 'result'].includes(String(value.type)) && !value.parent_tool_use_id) {
|
||||
const ids = Array.isArray(value.user_message_uuids) ? value.user_message_uuids :
|
||||
typeof value.user_message_uuid === 'string' ? [value.user_message_uuid] : undefined
|
||||
if (activeOwner.awaitingGroup && ids?.length && ids.every(id => typeof id === 'string' && activeOwner.appends.has(id))) {
|
||||
const inputs = (ids as string[]).map(id => activeOwner.appends.get(id)!.input)
|
||||
await activeOwner.request.onSegmentStart!(inputs)
|
||||
if (this.active !== activeOwner || activeOwner.settled || activeOwner.request.signal.aborted) return
|
||||
activeOwner.groupIds = ids as string[]
|
||||
this.lastGroupKey = activeOwner.groupIds.join('|')
|
||||
activeOwner.request.skillIds = [...new Set([...(activeOwner.request.skillIds ?? []), ...inputs.flatMap(input => input.skillIds ?? [])])]
|
||||
activeOwner.request.mcpServerIds = [...new Set([...(activeOwner.request.mcpServerIds ?? []), ...inputs.flatMap(input => input.mcpServerIds ?? [])])]
|
||||
this.capabilities.setRequestSelection({ explicitSkillIds: activeOwner.request.skillIds, selectedMcpServerIds: activeOwner.request.mcpServerIds })
|
||||
activeOwner.awaitingGroup = false
|
||||
activeOwner.content = ''
|
||||
activeOwner.tools = new Map()
|
||||
activeOwner.toolOrder = []
|
||||
activeOwner.reasoning = new Map()
|
||||
activeOwner.streamUsage = undefined
|
||||
activeOwner.nativeUsage = undefined
|
||||
activeOwner.lastEntryId = undefined
|
||||
activeOwner.planEmitted = false
|
||||
for (const input of inputs) {
|
||||
const pending = activeOwner.appends.get(input.userMessageId)!
|
||||
activeOwner.appends.delete(input.userMessageId)
|
||||
pending.resolve()
|
||||
}
|
||||
activeOwner.segmentReady?.resolve()
|
||||
activeOwner.segmentReady = undefined
|
||||
}
|
||||
if (ids?.length) activeOwner.foreignOutput = !ids.some(id => activeOwner.groupIds.includes(String(id)))
|
||||
if (activeOwner.awaitingGroup) return
|
||||
if (activeOwner.foreignOutput) return
|
||||
}
|
||||
|
||||
if (value.type === 'stream_event') {
|
||||
const event = value.event as Record<string, unknown> | undefined
|
||||
@@ -578,6 +655,19 @@ export class ClaudeKernel {
|
||||
const index = typeof event.index === 'number' ? event.index : 0
|
||||
if (event.type === 'message_start') {
|
||||
active.assistantSequence++
|
||||
const message = event.message as Record<string, unknown> | undefined
|
||||
active.nativeUsage = message?.usage && typeof message.usage === 'object' ? message.usage as Record<string, unknown> : undefined
|
||||
active.streamUsage = tokenUsage(active.nativeUsage)
|
||||
if (active.streamUsage && !active.request.command) active.request.emit({ runId: active.request.runId, type: 'usage', usage: active.streamUsage })
|
||||
return
|
||||
}
|
||||
if (event.type === 'message_delta') {
|
||||
const usage = event.usage as Record<string, unknown> | undefined
|
||||
if (usage && typeof usage === 'object') {
|
||||
active.nativeUsage = { ...active.nativeUsage, ...usage }
|
||||
active.streamUsage = tokenUsage(active.nativeUsage)
|
||||
if (active.streamUsage && !active.request.command) active.request.emit({ runId: active.request.runId, type: 'usage', usage: active.streamUsage })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'content_block_delta') {
|
||||
@@ -679,10 +769,13 @@ export class ClaudeKernel {
|
||||
if (value.type === 'result') {
|
||||
const active = this.active
|
||||
if (!active) return
|
||||
const usage = tokenUsage(value.usage)
|
||||
if (usage) active.request.emit({ runId: active.request.runId, type: 'usage', usage })
|
||||
// A result aggregates multiple API calls; it is not the last request's context size.
|
||||
const usage = active.streamUsage
|
||||
if (usage && !active.request.command) active.request.emit({ runId: active.request.runId, type: 'usage', usage })
|
||||
const failed = value.is_error === true || value.subtype !== 'success'
|
||||
if (failed) {
|
||||
if (active.request.command === 'compact' && !failed && !active.compactBoundary) {
|
||||
active.reject(new Error('内核未确认原生压缩完成,不能将普通回复视为压缩成功'))
|
||||
} else if (failed) {
|
||||
const errors = Array.isArray(value.errors) ? value.errors.filter((item): item is string => typeof item === 'string') : []
|
||||
const message = errors.length
|
||||
? errors.join('\n')
|
||||
@@ -691,11 +784,25 @@ export class ClaudeKernel {
|
||||
: `Claude Code 回合失败 (${String(value.subtype ?? 'unknown')})`
|
||||
active.reject(new Error(message))
|
||||
} else {
|
||||
if (active.request.command === 'compact') {
|
||||
active.content = '上下文压缩完成'
|
||||
active.request.emit({ runId: active.request.runId, type: 'text', delta: active.content })
|
||||
}
|
||||
if (!active.content && typeof value.result === 'string') {
|
||||
active.content = value.result
|
||||
active.request.emit({ runId: active.request.runId, type: 'text', delta: value.result })
|
||||
}
|
||||
active.resolve({ content: active.content, tools: [...active.toolOrder], status: 'done', entryId: active.lastEntryId })
|
||||
const result: ClaudeTurnResult = { content: active.content, tools: [...active.toolOrder], status: 'done', entryId: active.lastEntryId }
|
||||
if (active.appends.size) {
|
||||
active.awaitingGroup = true
|
||||
let ready!: () => void
|
||||
const promise = new Promise<void>(resolve => { ready = resolve })
|
||||
active.segmentReady = { promise, resolve: ready }
|
||||
await active.request.onSegmentComplete!(result)
|
||||
if (this.active !== active || active.settled) return
|
||||
return
|
||||
}
|
||||
active.resolve(result)
|
||||
}
|
||||
this.finishActive(active)
|
||||
}
|
||||
@@ -712,21 +819,33 @@ export class ClaudeKernel {
|
||||
private finishActive(active: ActiveTurn): void {
|
||||
if (active.settled) return
|
||||
active.settled = true
|
||||
active.segmentReady?.resolve()
|
||||
for (const pending of active.appends.values()) pending.reject(appendError('运行已结束,追加消息交付未确认', true))
|
||||
active.appends.clear()
|
||||
if (this.active === active) this.active = null
|
||||
}
|
||||
|
||||
private async waitForSegment(): Promise<ActiveTurn | undefined> {
|
||||
const active = this.active
|
||||
if (!active) return undefined
|
||||
if (active.awaitingGroup) await active.segmentReady?.promise
|
||||
return this.active === active && !active.settled && !active.request.signal.aborted ? active : undefined
|
||||
}
|
||||
|
||||
private failActive(error: Error): void {
|
||||
const active = this.active
|
||||
if (!active || active.settled) return
|
||||
active.reject(error)
|
||||
active.reject(active.request.signal.aborted ? abortError() : error)
|
||||
this.finishActive(active)
|
||||
}
|
||||
|
||||
async runTurn(request: ClaudeTurnRequest): Promise<ClaudeTurnResult> {
|
||||
await this.draining
|
||||
if (this.closed) throw new Error('Claude 内核已释放')
|
||||
if (this.active || this.starting) throw new Error('该会话正在运行')
|
||||
this.starting = true
|
||||
try {
|
||||
this.capabilities.setRequestSelection({ explicitSkillIds: request.skillIds, selectedMcpServerIds: request.mcpServerIds })
|
||||
await this.ensureStarted()
|
||||
const input = this.input
|
||||
if (!input) throw new Error('Claude Code 输入流未就绪')
|
||||
@@ -742,13 +861,23 @@ export class ClaudeKernel {
|
||||
settled: false,
|
||||
assistantSequence: 0,
|
||||
reasoning: new Map(),
|
||||
planEmitted: false
|
||||
planEmitted: false,
|
||||
groupIds: [request.userMessageId], awaitingGroup: false, appends: new Map()
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
if (active.settled) return
|
||||
void this.instance?.interrupt().catch(() => undefined)
|
||||
active.reject(abortError())
|
||||
this.finishActive(active)
|
||||
active.segmentReady?.resolve()
|
||||
// Retire the producer before allowing a new turn. A late result from an
|
||||
// interrupted producer must never settle the next turn's promise.
|
||||
const consuming = this.consuming
|
||||
this.processAbort?.abort()
|
||||
this.instance?.close()
|
||||
const draining = (consuming ?? Promise.resolve()).catch(() => undefined)
|
||||
this.draining = draining
|
||||
void draining.finally(() => { if (this.draining === draining) this.draining = null })
|
||||
void draining.finally(() => {
|
||||
if (!active.settled) { active.reject(abortError()); this.finishActive(active) }
|
||||
})
|
||||
}
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
const originalResolve = active.resolve
|
||||
@@ -762,9 +891,12 @@ export class ClaudeKernel {
|
||||
originalReject(error)
|
||||
}
|
||||
this.active = active
|
||||
this.lastRequestId = request.runId
|
||||
this.lastGroupKey = request.userMessageId
|
||||
try {
|
||||
input.push({
|
||||
type: 'user',
|
||||
...(request.command ? {} : { client_composed: true }),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
@@ -793,12 +925,80 @@ export class ClaudeKernel {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve only after an identity-stamped native response and host history commit. */
|
||||
appendInput(input: ClaudeAppendInput): Promise<void> {
|
||||
const active = this.active
|
||||
if (!active || active.settled || active.request.command || !this.input || this.draining || this.closed ||
|
||||
!active.request.onSegmentStart || !active.request.onSegmentComplete) {
|
||||
return Promise.reject(appendError('当前运行尚不能接收引导消息,请稍后重试', false))
|
||||
}
|
||||
if (active.request.signal.aborted || active.appends.has(input.userMessageId) || active.groupIds.includes(input.userMessageId)) {
|
||||
return Promise.reject(appendError('追加消息已提交或当前运行已取消', false))
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
active.appends.set(input.userMessageId, { input, resolve, reject })
|
||||
try {
|
||||
this.input!.push({ type: 'user', client_composed: true, parent_tool_use_id: null,
|
||||
uuid: input.userMessageId as SDKUserMessage['uuid'], session_id: this.currentSessionId,
|
||||
message: { role: 'user', content: [
|
||||
{ type: 'text', text: input.prompt },
|
||||
...(input.images ?? []).map(image => ({ type: 'image' as const, source: { type: 'base64' as const,
|
||||
media_type: image.mimeType as 'image/png', data: image.data } }))
|
||||
] } })
|
||||
} catch (error) {
|
||||
active.appends.delete(input.userMessageId)
|
||||
reject(appendError(error instanceof Error ? error.message : String(error), false))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async setPolicy(permissionMode: PermissionMode, agentMode: AgentMode): Promise<void> {
|
||||
await this.draining
|
||||
this.permissionMode = permissionMode
|
||||
this.agentMode = agentMode
|
||||
if (this.instance) await this.instance.setPermissionMode(permissionModeFor(permissionMode, agentMode))
|
||||
}
|
||||
|
||||
/** Read only: never start a kernel or request per-category token-count calls. */
|
||||
async getContextSummary(): Promise<Awaited<ReturnType<Query['getContextUsage']>> | undefined> {
|
||||
if (!this.instance || this.draining || this.closed) return undefined
|
||||
const query = this.contextQuery ?? this.instance.getContextUsage({ detail: 'summary' })
|
||||
if (!this.contextQuery) {
|
||||
this.contextQuery = query
|
||||
this.contextQueryGroup = this.lastGroupKey
|
||||
void query.finally(() => { if (this.contextQuery === query) this.contextQuery = null }).catch(() => {})
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([query, new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('内核上下文统计查询超时')), 5000)
|
||||
timer.unref()
|
||||
})])
|
||||
} finally { if (timer) clearTimeout(timer) }
|
||||
}
|
||||
|
||||
async getContextBreakdown(): Promise<ContextUsageBreakdown | undefined> {
|
||||
const requestId = this.lastRequestId
|
||||
const groupKey = this.lastGroupKey
|
||||
if (this.contextQuery && this.contextQueryGroup !== groupKey) return undefined
|
||||
const instance = this.instance
|
||||
const sampledAt = Date.now()
|
||||
const result = await this.getContextSummary()
|
||||
if (!result || !requestId || requestId !== this.lastRequestId || groupKey !== this.lastGroupKey || instance !== this.instance) return undefined
|
||||
const counts: ContextUsageBreakdown['counts'] = { system: 0, tools: 0, conversation: 0, mcp: 0, skills: 0, other: 0 }
|
||||
for (const category of result.categories) {
|
||||
if (category.kind !== 'used' || !Number.isFinite(category.tokens) || category.tokens < 0) continue
|
||||
const id = category.id
|
||||
const key = id === 'system_prompt' || id === 'memory_files' ? 'system' : id === 'system_tools' ? 'tools' :
|
||||
id === 'messages' ? 'conversation' : id === 'mcp_tools' || id === 'mcp_instructions' ? 'mcp' : id === 'skills' ? 'skills' : 'other'
|
||||
counts[key] += category.tokens
|
||||
}
|
||||
const contextWindow = this.options.model.profile.contextWindowTokens ?? this.options.model.profile.maxInputTokens
|
||||
return { requestId, sequence: sampledAt, sampledAt, modelId: this.options.model.profile.id,
|
||||
contextWindow, method: 'native-summary', scope: 'prepared-request', counts,
|
||||
inputTokens: Object.values(counts).reduce((sum, value) => sum + value, 0), partial: true }
|
||||
}
|
||||
|
||||
async rewindFiles(userMessageId: string): Promise<void> {
|
||||
if (!this.instance) return
|
||||
const result = await this.instance.rewindFiles(userMessageId, { dryRun: false })
|
||||
@@ -839,6 +1039,7 @@ function mustAskInFullAccess(options: Parameters<CanUseTool>[2]): boolean {
|
||||
|
||||
function isReadOnlyCall(toolName: string, input: Record<string, unknown>): boolean {
|
||||
if (READ_ONLY_TOOLS.has(toolName)) return true
|
||||
if (['mcp__ycode_capabilities__list_capabilities', 'mcp__ycode_capabilities__load_skill', 'mcp__ycode_capabilities__read_skill_resource'].includes(toolName)) return true
|
||||
if (toolName !== 'mcp__ycode__browser') return false
|
||||
return ['open', 'navigate', 'snapshot', 'wait', 'list', 'screenshot'].includes(String(input.action ?? ''))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PermissionMode } from '../../shared/conversation'
|
||||
|
||||
export interface QueuedRequestSettingsInput {
|
||||
queued: boolean
|
||||
payloadPermissionMode?: PermissionMode
|
||||
sessionPermissionMode?: PermissionMode
|
||||
}
|
||||
|
||||
export interface QueuedRequestSettings {
|
||||
permissionMode: PermissionMode
|
||||
persistSessionPermission: boolean
|
||||
}
|
||||
|
||||
/** Direct sends may seed session policy; queued delivery always follows current session policy. */
|
||||
export function resolveQueuedRequestSettings(input: QueuedRequestSettingsInput): QueuedRequestSettings {
|
||||
const permissionMode = input.queued
|
||||
? input.sessionPermissionMode ?? 'default-sandbox'
|
||||
: input.payloadPermissionMode ?? input.sessionPermissionMode ?? 'default-sandbox'
|
||||
if (permissionMode !== 'default-sandbox' && permissionMode !== 'full-access') throw new Error('权限模式无效')
|
||||
return {
|
||||
permissionMode,
|
||||
persistSessionPermission: !input.queued && input.sessionPermissionMode !== permissionMode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SessionContext } from '../../shared/workspaces'
|
||||
import { thinkingLevel } from './mode-policy'
|
||||
|
||||
/** During a run only future-request settings may change, never active policy. */
|
||||
export function nextRequestSettings(patch: SessionContext): SessionContext {
|
||||
if (!patch || typeof patch !== 'object' || Array.isArray(patch) ||
|
||||
Object.keys(patch).some(key => key !== 'modelId' && key !== 'thinkingLevel')) {
|
||||
throw new Error('运行期间只能修改下次发送的模型和思考强度')
|
||||
}
|
||||
const result: SessionContext = {}
|
||||
if (patch.modelId !== undefined) {
|
||||
if (typeof patch.modelId !== 'string' || !patch.modelId.trim()) throw new Error('模型标识无效')
|
||||
result.modelId = patch.modelId
|
||||
}
|
||||
if (patch.thinkingLevel !== undefined) result.thinkingLevel = thinkingLevel(patch.thinkingLevel)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { z } from 'zod'
|
||||
import type { SkillRecord } from '@shared/capabilities'
|
||||
import {
|
||||
discoverMcpCapabilities,
|
||||
executeMcpTool,
|
||||
type McpCapabilityDescriptor
|
||||
} from './mcp'
|
||||
import {
|
||||
buildSkillContext,
|
||||
listSkills,
|
||||
loadSkillContext,
|
||||
readSkillResourceForInvocation
|
||||
} from './skills'
|
||||
|
||||
export interface ClaudeCapabilityPorts {
|
||||
mcp: {
|
||||
discover(options?: { serverId?: string; query?: string }): Promise<McpCapabilityDescriptor[]>
|
||||
execute(serverId: string, toolName: string, args: Record<string, unknown>, signal: AbortSignal):
|
||||
Promise<{ ok: boolean; content: string; data?: unknown }>
|
||||
}
|
||||
skills: {
|
||||
list(cwd: string): Promise<SkillRecord[]>
|
||||
loadAutomatic(id: string, cwd: string): Promise<string>
|
||||
loadExplicit(ids: string[], cwd: string): Promise<string>
|
||||
readResource(id: string, resource: string, cwd: string, explicit: boolean): Promise<string>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClaudeCapabilityOptions {
|
||||
beforeCall?(): Promise<void>
|
||||
requestSignal?(): AbortSignal | undefined
|
||||
cwd: string
|
||||
/** Explicit references belong to this request only and never alter the global enabled flag. */
|
||||
explicitSkillIds?: readonly string[]
|
||||
selectedMcpServerIds?: readonly string[]
|
||||
}
|
||||
|
||||
export interface ClaudeCapabilityCatalog {
|
||||
mcp: Array<Omit<McpCapabilityDescriptor, 'tools'> & {
|
||||
selected: boolean
|
||||
tools: Array<{ name: string; description: string; inputSchema?: Record<string, unknown> }>
|
||||
}>
|
||||
skills: Array<Pick<SkillRecord, 'id' | 'name' | 'description' | 'userInvocable' | 'disableModelInvocation'> & {
|
||||
selected: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
const MAX_QUERY = 256
|
||||
const MAX_LISTED_MCP_TOOLS = 200
|
||||
const MAX_DESCRIPTION = 600
|
||||
|
||||
const defaultPorts: ClaudeCapabilityPorts = {
|
||||
mcp: { discover: discoverMcpCapabilities, execute: executeMcpTool },
|
||||
skills: {
|
||||
list: listSkills,
|
||||
loadAutomatic: loadSkillContext,
|
||||
loadExplicit: buildSkillContext,
|
||||
readResource: readSkillResourceForInvocation
|
||||
}
|
||||
}
|
||||
|
||||
function textResult(text: string, isError = false) {
|
||||
return { content: [{ type: 'text' as const, text }], isError }
|
||||
}
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function normalizedQuery(value: string | undefined): string {
|
||||
const query = value?.trim() ?? ''
|
||||
if (query.length > MAX_QUERY) throw new Error('能力搜索词过长')
|
||||
return query.toLocaleLowerCase()
|
||||
}
|
||||
|
||||
function matches(query: string, ...values: string[]): boolean {
|
||||
return !query || values.some(value => value.toLocaleLowerCase().includes(query))
|
||||
}
|
||||
|
||||
export function createClaudeCapabilityRuntime(
|
||||
options: ClaudeCapabilityOptions,
|
||||
override: Partial<ClaudeCapabilityPorts> = {}
|
||||
) {
|
||||
const ports: ClaudeCapabilityPorts = {
|
||||
mcp: override.mcp ?? defaultPorts.mcp,
|
||||
skills: { ...defaultPorts.skills, ...override.skills }
|
||||
}
|
||||
let explicitSkillIds = new Set(options.explicitSkillIds ?? [])
|
||||
let selectedMcpServerIds = new Set(options.selectedMcpServerIds ?? [])
|
||||
|
||||
/** The persistent kernel is sequential; switch its request-scoped references before each turn. */
|
||||
function setRequestSelection(next: Pick<ClaudeCapabilityOptions, 'explicitSkillIds' | 'selectedMcpServerIds'>): void {
|
||||
explicitSkillIds = new Set(next.explicitSkillIds ?? [])
|
||||
selectedMcpServerIds = new Set(next.selectedMcpServerIds ?? [])
|
||||
}
|
||||
|
||||
async function findSkill(id: string): Promise<SkillRecord> {
|
||||
const skill = (await ports.skills.list(options.cwd)).find(item => item.id === id)
|
||||
if (!skill) throw new Error('技能不存在或不属于当前空间')
|
||||
if (skill.error) throw new Error(skill.error)
|
||||
if (!skill.enabled) throw new Error('该技能已禁用')
|
||||
if (explicitSkillIds.has(id)) {
|
||||
if (!skill.userInvocable) throw new Error('该技能不允许用户手动调用')
|
||||
} else if (skill.disableModelInvocation) {
|
||||
throw new Error('该技能不允许模型自动调用')
|
||||
}
|
||||
return skill
|
||||
}
|
||||
|
||||
async function list(queryValue?: string, serverId?: string): Promise<ClaudeCapabilityCatalog> {
|
||||
await options.beforeCall?.()
|
||||
const query = normalizedQuery(queryValue)
|
||||
const serverFilter = serverId?.trim() || undefined
|
||||
if (serverFilter && serverFilter.length > 256) throw new Error('MCP 服务标识过长')
|
||||
const [mcp, skills] = await Promise.all([
|
||||
ports.mcp.discover({ ...(serverFilter ? { serverId: serverFilter } : {}), ...(query ? { query: queryValue?.trim() } : {}) }),
|
||||
ports.skills.list(options.cwd)
|
||||
])
|
||||
let remainingTools = MAX_LISTED_MCP_TOOLS
|
||||
const mcpView = mcp
|
||||
.filter(server => serverFilter ? server.id === serverFilter : true)
|
||||
.map(server => {
|
||||
const serverMatches = matches(query, server.name, server.reason ?? '')
|
||||
const filtered = !query || serverMatches
|
||||
? server.tools
|
||||
: server.tools.filter(item => matches(query, item.name, item.description))
|
||||
const tools = filtered.slice(0, remainingTools).map(item => ({
|
||||
name: item.name,
|
||||
description: item.description.slice(0, MAX_DESCRIPTION),
|
||||
...(serverFilter || query && matches(query, item.name, item.description)
|
||||
? item.inputSchema ? { inputSchema: item.inputSchema } : {}
|
||||
: {})
|
||||
}))
|
||||
remainingTools -= tools.length
|
||||
return {
|
||||
...server,
|
||||
selected: selectedMcpServerIds.has(server.id),
|
||||
tools,
|
||||
...(filtered.length > tools.length ? { reason: '工具列表已截断,请使用搜索词继续发现' } : {})
|
||||
}
|
||||
})
|
||||
.filter(server => !query || server.tools.length > 0 || matches(query, server.name, server.reason ?? ''))
|
||||
const skillView = skills
|
||||
.filter(skill => skill.enabled && !skill.error)
|
||||
.filter(skill => explicitSkillIds.has(skill.id) ? skill.userInvocable : !skill.disableModelInvocation)
|
||||
.filter(skill => matches(query, skill.name, skill.description))
|
||||
.map(skill => ({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
userInvocable: skill.userInvocable,
|
||||
disableModelInvocation: skill.disableModelInvocation,
|
||||
selected: explicitSkillIds.has(skill.id)
|
||||
}))
|
||||
return { mcp: mcpView, skills: skillView }
|
||||
}
|
||||
|
||||
async function loadSkill(id: string): Promise<string> {
|
||||
await options.beforeCall?.()
|
||||
await findSkill(id)
|
||||
return explicitSkillIds.has(id)
|
||||
? ports.skills.loadExplicit([id], options.cwd)
|
||||
: ports.skills.loadAutomatic(id, options.cwd)
|
||||
}
|
||||
|
||||
async function skillResource(id: string, resource: string): Promise<string> {
|
||||
await options.beforeCall?.()
|
||||
await findSkill(id)
|
||||
return ports.skills.readResource(id, resource, options.cwd, explicitSkillIds.has(id))
|
||||
}
|
||||
|
||||
async function explicitSkillContext(): Promise<string> {
|
||||
if (!explicitSkillIds.size) return ''
|
||||
for (const id of explicitSkillIds) await findSkill(id)
|
||||
return ports.skills.loadExplicit([...explicitSkillIds], options.cwd)
|
||||
}
|
||||
|
||||
async function callMcp(serverId: string, toolName: string, args: Record<string, unknown>, signal: AbortSignal) {
|
||||
await options.beforeCall?.()
|
||||
if (!serverId || !toolName) throw new Error('MCP 服务和工具标识不能为空')
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('MCP 工具参数必须是对象')
|
||||
const requestSignal = options.requestSignal?.()
|
||||
return ports.mcp.execute(serverId, toolName, args, requestSignal ? AbortSignal.any([signal, requestSignal]) : signal)
|
||||
}
|
||||
|
||||
const server = createSdkMcpServer({
|
||||
name: 'ycode_capabilities',
|
||||
version: '1.0.0',
|
||||
// Keep only the four small bridge tools visible; supplier tools stay on demand.
|
||||
alwaysLoad: true,
|
||||
instructions: 'YCode capabilities are discovered on demand. List before invoking an unfamiliar MCP tool or loading a skill body. Selecting a capability is not proof that it ran.',
|
||||
tools: [
|
||||
tool(
|
||||
'list_capabilities',
|
||||
'List currently enabled MCP servers/tools and skills. Use a search query when the catalog is large. This only discovers capabilities; it does not run them.',
|
||||
{
|
||||
query: z.string().max(MAX_QUERY).optional(),
|
||||
serverId: z.string().min(1).max(256).optional()
|
||||
},
|
||||
async args => {
|
||||
try { return textResult(JSON.stringify(await list(args.query, args.serverId))) }
|
||||
catch (error) { return textResult(message(error), true) }
|
||||
}
|
||||
),
|
||||
tool(
|
||||
'call_mcp_tool',
|
||||
'Call one enabled tool from an approved, connected MCP server. The host rechecks service approval, enabled state, connection generation and tool state before invocation.',
|
||||
{
|
||||
serverId: z.string().min(1).max(256),
|
||||
toolName: z.string().min(1).max(256),
|
||||
arguments: z.record(z.string(), z.unknown())
|
||||
},
|
||||
async (args, extra) => {
|
||||
const signal = (extra as { signal?: AbortSignal } | undefined)?.signal ?? new AbortController().signal
|
||||
try {
|
||||
const result = await callMcp(args.serverId, args.toolName, args.arguments, signal)
|
||||
return textResult(result.content, !result.ok)
|
||||
} catch (error) { return textResult(message(error), true) }
|
||||
}
|
||||
),
|
||||
tool(
|
||||
'load_skill',
|
||||
'Load the selected or model-invocable SKILL.md body and its resource list on demand. Skills are not executed by this tool.',
|
||||
{ id: z.string().min(1).max(256) },
|
||||
async args => {
|
||||
try { return textResult(await loadSkill(args.id)) }
|
||||
catch (error) { return textResult(message(error), true) }
|
||||
}
|
||||
),
|
||||
tool(
|
||||
'read_skill_resource',
|
||||
'Read a UTF-8 resource by path relative to its skill package root. Paths outside the package and symlinks are rejected.',
|
||||
{ id: z.string().min(1).max(256), resource: z.string().min(1).max(4096) },
|
||||
async args => {
|
||||
try { return textResult(await skillResource(args.id, args.resource)) }
|
||||
catch (error) { return textResult(message(error), true) }
|
||||
}
|
||||
)
|
||||
]
|
||||
})
|
||||
|
||||
return { setRequestSelection, list, loadSkill, skillResource, explicitSkillContext, callMcp, server }
|
||||
}
|
||||
@@ -241,6 +241,7 @@ export async function connectMcpServer(id: string): Promise<McpServerView> {
|
||||
}
|
||||
export async function disconnectMcpServer(id: string): Promise<void> { await writes; requireServer(await load(), id); await closeRuntime(id) }
|
||||
export async function setMcpEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
if (typeof enabled !== 'boolean') throw new Error('MCP 启停状态无效')
|
||||
await mutate(async current => {
|
||||
const server = requireServer(current, id)
|
||||
if (enabled && server.trustedHash !== server.configHash) throw new Error('请先批准当前 MCP 配置')
|
||||
@@ -250,6 +251,7 @@ export async function setMcpEnabled(id: string, enabled: boolean): Promise<void>
|
||||
if (enabled) await connectMcpServer(id)
|
||||
}
|
||||
export async function setMcpToolEnabled(id: string, toolName: string, enabled: boolean): Promise<void> {
|
||||
if (typeof enabled !== 'boolean') throw new Error('MCP 工具启停状态无效')
|
||||
await mutate(async current => {
|
||||
const server = requireServer(current, id)
|
||||
if (!server.tools.some(tool => tool.name === toolName)) throw new Error('MCP 工具不存在')
|
||||
@@ -265,6 +267,65 @@ export async function initializeMcpServers(): Promise<void> {
|
||||
await writes
|
||||
for (const server of (await load()).servers) if (server.enabled && server.trustedHash === server.configHash) void connectMcpServer(server.id).catch(() => undefined)
|
||||
}
|
||||
export interface McpCapabilityDescriptor {
|
||||
id: string
|
||||
name: string
|
||||
status: McpStatus
|
||||
available: boolean
|
||||
reason?: string
|
||||
tools: Array<{ name: string; description: string; inputSchema?: Record<string, unknown> }>
|
||||
}
|
||||
export interface McpDiscoveryOptions { serverId?: string; query?: string }
|
||||
/** Model discovery never connects or restarts a server; it only reports current approved state. */
|
||||
export async function discoverMcpCapabilities(options: McpDiscoveryOptions = {}): Promise<McpCapabilityDescriptor[]> {
|
||||
await writes
|
||||
const query = options.query?.trim().toLocaleLowerCase() ?? ''
|
||||
const serverId = options.serverId?.trim()
|
||||
return (await load()).servers.filter(server => server.enabled && (!serverId || server.id === serverId)).map(server => {
|
||||
const runtime = runtimes.get(server.id)
|
||||
const approved = server.trustedHash === server.configHash
|
||||
const available = approved && runtime?.status === 'connected'
|
||||
const serverMatches = !query || [server.name, runtime?.error ?? ''].some(value => value.toLocaleLowerCase().includes(query))
|
||||
const tools = available ? server.tools.filter(tool => {
|
||||
if (!tool.enabled) return false
|
||||
return serverMatches || [tool.name, tool.description].some(value => value.toLocaleLowerCase().includes(query))
|
||||
}) : []
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
status: runtime?.status ?? 'disconnected',
|
||||
available,
|
||||
...(!available ? { reason: !approved ? '请先批准当前 MCP 配置' : runtime?.error ?? 'MCP 服务尚未连接' } : {}),
|
||||
// Schemas require an explicit drill-down or a tool search, never the default catalog.
|
||||
tools: tools.map(tool => ({ name: tool.name, description: tool.description,
|
||||
...(serverId || query && [tool.name, tool.description].some(value => value.toLocaleLowerCase().includes(query))
|
||||
? { inputSchema: clone(tool.inputSchema) } : {}) }))
|
||||
}
|
||||
})
|
||||
}
|
||||
export async function executeMcpTool(serverId: string, toolName: string, args: Record<string, unknown>, signal: AbortSignal): Promise<{ ok: boolean; content: string; data?: unknown }> {
|
||||
if (signal.aborted) throw abortError()
|
||||
await writes
|
||||
const server = (await load()).servers.find(item => item.id === serverId), runtime = runtimes.get(serverId)
|
||||
if (!server) return { ok: false, content: 'MCP 服务不存在' }
|
||||
if (!server.enabled) return { ok: false, content: 'MCP 服务已禁用' }
|
||||
if (server.trustedHash !== server.configHash) return { ok: false, content: '请先批准当前 MCP 配置' }
|
||||
if (!runtime || runtime.configHash !== server.configHash || runtime.status !== 'connected') return { ok: false, content: runtime?.error ?? 'MCP 服务尚未连接' }
|
||||
if (!server.tools.some(tool => tool.name === toolName && tool.enabled)) return { ok: false, content: 'MCP 工具不存在或已禁用' }
|
||||
try {
|
||||
const result = await runtime.client.callTool({ name: toolName, arguments: args }, undefined,
|
||||
{ signal: AbortSignal.any([signal, runtime.controller.signal]), timeout: CALL_TIMEOUT })
|
||||
const data = mask(result, runtime.secretValues) as Record<string, unknown>
|
||||
const content = Array.isArray(data.content)
|
||||
? data.content.map(part => object(part) && part.type === 'text' && typeof part.text === 'string' ? part.text : JSON.stringify(part)).join('\n')
|
||||
: JSON.stringify(data)
|
||||
return { ok: !result.isError, content, data }
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw abortError()
|
||||
if (runtime.controller.signal.aborted || runtimes.get(serverId) !== runtime) return { ok: false, content: 'MCP 配置或连接已变化,请重新发起请求' }
|
||||
return { ok: false, content: publicError(error, 'call') }
|
||||
}
|
||||
}
|
||||
function mask(value: unknown, secrets: string[]): unknown {
|
||||
if (typeof value === 'string') { for (const secret of secrets) if (secret) value = (value as string).split(secret).join('[已隐藏]'); return value }
|
||||
if (Array.isArray(value)) return value.map(item => mask(item, secrets))
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { app } from 'electron'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import type { RecommendedSkill, RecommendedSkillInstallOptions, SkillInstallProgress, SkillRecord } from '@shared/capabilities'
|
||||
import { SKILL_RECOMMENDATIONS } from './skill-recommendations'
|
||||
import { getSkillInstallSnapshot, importRecommendedSkillArchive } from './skills'
|
||||
|
||||
const MAX_BYTES = 20 * 1024 * 1024
|
||||
const UUID = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i
|
||||
const DIRECTORY = /^install-(\d+)-[a-f\d-]{36}$/i
|
||||
const ACTIVE_PHASES = new Set(['downloading', 'validating', 'installing'])
|
||||
|
||||
export interface RecommendedSkillInstallerPorts {
|
||||
tempRoot: string
|
||||
catalog: readonly RecommendedSkill[]
|
||||
snapshot: typeof getSkillInstallSnapshot
|
||||
importArchive: typeof importRecommendedSkillArchive
|
||||
request?: typeof fetch
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/** The factory is a test boundary; IPC only exposes the fixed production catalog. */
|
||||
export function createRecommendedSkillInstaller(ports: RecommendedSkillInstallerPorts) {
|
||||
const root = path.resolve(ports.tempRoot)
|
||||
const catalog = structuredClone([...ports.catalog])
|
||||
const listeners = new Set<(progress: SkillInstallProgress) => void>()
|
||||
const usedIds = new Set<string>()
|
||||
let state: SkillInstallProgress | null = null
|
||||
let stopping = false
|
||||
let initialized = false
|
||||
let lifecycle = 0
|
||||
let initialization: Promise<void> | undefined
|
||||
let active: { operationId: string; controller: AbortController; task: Promise<SkillRecord> } | undefined
|
||||
|
||||
function publish(operationId: string, recommendationId: string, phase: SkillInstallProgress['phase'],
|
||||
receivedBytes: number, totalBytes?: number, error?: string): void {
|
||||
state = { operationId, recommendationId, phase, receivedBytes, totalBytes, error,
|
||||
updatedAt: Math.max(Date.now(), (state?.updatedAt ?? 0) + 1) }
|
||||
for (const listener of listeners) {
|
||||
try { listener(structuredClone(state)) } catch { /* A closed renderer cannot fail installation. */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRoot(): Promise<void> {
|
||||
let current = path.parse(root).root
|
||||
for (const component of path.relative(current, root).split(path.sep)) {
|
||||
current = path.join(current, component)
|
||||
await fs.mkdir(current).catch(error => { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error })
|
||||
const stat = await fs.lstat(current)
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('技能安装临时目录不能包含符号链接或文件')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOwned(directory: string): Promise<void> {
|
||||
const target = path.resolve(directory)
|
||||
if (path.dirname(target) !== root || !DIRECTORY.test(path.basename(target))) throw Error('技能安装临时路径越界')
|
||||
const stat = await fs.lstat(target).catch(error => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
||||
throw error
|
||||
})
|
||||
if (!stat) return
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) throw Error('技能安装临时目录已被重定向')
|
||||
await fs.rm(target, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
async function ensureInitialized(): Promise<void> {
|
||||
if (initialized) return
|
||||
if (!initialization) {
|
||||
initialization = (async () => {
|
||||
await ensureRoot()
|
||||
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
||||
const match = DIRECTORY.exec(entry.name)
|
||||
if (!match || !entry.isDirectory() || entry.isSymbolicLink()) continue
|
||||
let alive = true
|
||||
try { process.kill(Number(match[1]), 0) }
|
||||
catch (error) { alive = (error as NodeJS.ErrnoException).code !== 'ESRCH' }
|
||||
if (!alive) await removeOwned(path.join(root, entry.name))
|
||||
}
|
||||
initialized = true
|
||||
})()
|
||||
void initialization.catch(() => { initialization = undefined })
|
||||
}
|
||||
await initialization
|
||||
}
|
||||
|
||||
function downloadUrl(recommendation: RecommendedSkill): URL {
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(recommendation.repository) || !/^[a-f\d]{40}$/i.test(recommendation.commit) ||
|
||||
!/^[a-f\d]{64}$/i.test(recommendation.sha256) || recommendation.packagePath.split('/').some(part => !part || part === '.' || part === '..' || /[^\w.-]/.test(part))) {
|
||||
throw Error('推荐技能目录条目无效')
|
||||
}
|
||||
// Request the pinned file bytes through GitHub's API; raw.githubusercontent.com
|
||||
// is reset by some networks even when api.github.com remains reachable.
|
||||
return new URL(`https://api.github.com/repos/${recommendation.repository}/contents/${recommendation.packagePath}?ref=${recommendation.commit}`)
|
||||
}
|
||||
|
||||
async function download(recommendation: RecommendedSkill, file: string, signal: AbortSignal,
|
||||
operationId: string): Promise<number> {
|
||||
const request = ports.request ?? fetch
|
||||
let url = downloadUrl(recommendation)
|
||||
let response: Response | undefined
|
||||
for (let redirects = 0; redirects <= 3; redirects++) {
|
||||
signal.throwIfAborted()
|
||||
if (url.protocol !== 'https:' || !['api.github.com', 'raw.githubusercontent.com'].includes(url.hostname) || url.username || url.password || (url.port && url.port !== '443')) throw Error('推荐技能下载跳转到不允许的地址')
|
||||
response = await request(url, { signal, redirect: 'manual', headers: {
|
||||
'Accept-Encoding': 'identity', Accept: 'application/vnd.github.raw+json', 'X-GitHub-Api-Version': '2022-11-28'
|
||||
} }).catch(error => {
|
||||
if (signal.aborted) throw error
|
||||
throw Error('无法连接 GitHub 下载服务,请检查网络或代理后重试', { cause: error })
|
||||
})
|
||||
if (![301, 302, 303, 307, 308].includes(response.status)) break
|
||||
const location = response.headers.get('location')
|
||||
await response.body?.cancel()
|
||||
if (!location || redirects === 3) throw Error('推荐技能下载重定向过多或缺少地址')
|
||||
url = new URL(location, url)
|
||||
}
|
||||
if (!response?.ok || !response.body) {
|
||||
await response?.body?.cancel().catch(() => {})
|
||||
if (response?.status === 429 || response?.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') throw Error('GitHub 下载请求限额已用完,请稍后重试')
|
||||
throw Error(`推荐技能下载失败(HTTP ${response?.status ?? 'unknown'})`)
|
||||
}
|
||||
const declared = Number(response.headers.get('content-length'))
|
||||
if (declared > MAX_BYTES) { await response.body.cancel(); throw Error('推荐技能包超过 20MB') }
|
||||
const handle = await fs.open(file, 'wx').catch(async error => { await response!.body!.cancel().catch(() => {}); throw error })
|
||||
const reader = response.body.getReader()
|
||||
const digest = createHash('sha256')
|
||||
let received = 0, lastProgress = 0
|
||||
try {
|
||||
for (;;) {
|
||||
signal.throwIfAborted()
|
||||
const chunk = await reader.read()
|
||||
if (chunk.done) break
|
||||
received += chunk.value.byteLength
|
||||
if (received > MAX_BYTES) throw Error('推荐技能包超过 20MB')
|
||||
digest.update(chunk.value)
|
||||
await handle.writeFile(chunk.value)
|
||||
if (Date.now() - lastProgress >= 80 || received === recommendation.expectedBytes) {
|
||||
publish(operationId, recommendation.id, 'downloading', received, recommendation.expectedBytes)
|
||||
lastProgress = Date.now()
|
||||
}
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (received !== recommendation.expectedBytes) throw Error('推荐技能包大小与目录不符')
|
||||
if (digest.digest('hex') !== recommendation.sha256) throw Error('推荐技能包 SHA-256 校验失败')
|
||||
return received
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {})
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function install(id: string, options: RecommendedSkillInstallOptions): Promise<SkillRecord> {
|
||||
if (stopping) return Promise.reject(Error('技能安装服务正在停止'))
|
||||
const recommendation = catalog.find(item => item.id === id)
|
||||
if (!recommendation) return Promise.reject(Error('推荐技能不存在'))
|
||||
if (!options || !UUID.test(options.operationId) || (options.overwrite !== undefined && typeof options.overwrite !== 'boolean')) return Promise.reject(Error('技能安装操作参数无效'))
|
||||
if (active) return Promise.reject(Error('已有推荐技能正在安装,请等待当前操作完成'))
|
||||
if (usedIds.has(options.operationId)) return Promise.reject(Error('技能安装操作已提交,不能重复执行'))
|
||||
const operationId = options.operationId
|
||||
usedIds.add(operationId)
|
||||
const controller = new AbortController()
|
||||
let directory: string | undefined
|
||||
let received = 0, timedOut = false
|
||||
const task = Promise.resolve().then(async () => {
|
||||
try {
|
||||
await ensureInitialized()
|
||||
controller.signal.throwIfAborted()
|
||||
const snapshot = await ports.snapshot(recommendation.name)
|
||||
const existing = snapshot.skill
|
||||
const origin = existing?.origin
|
||||
if (existing && !existing.error && !existing.userModified && origin?.recommendationId === recommendation.id &&
|
||||
origin.repository === recommendation.repository && origin.commit === recommendation.commit &&
|
||||
origin.packagePath === recommendation.packagePath && origin.archiveSha256 === recommendation.sha256 && origin.version === recommendation.version) {
|
||||
controller.signal.throwIfAborted()
|
||||
publish(operationId, id, 'installed', 0)
|
||||
return existing
|
||||
}
|
||||
if (existing && !options.overwrite) throw Error('已存在同名技能,请确认替换后重试')
|
||||
directory = path.join(root, `install-${process.pid}-${randomUUID()}`)
|
||||
await fs.mkdir(directory)
|
||||
const file = path.join(directory, 'package.zip')
|
||||
publish(operationId, id, 'downloading', 0, recommendation.expectedBytes)
|
||||
const timer = setTimeout(() => { timedOut = true; controller.abort() }, ports.timeoutMs ?? 120000)
|
||||
timer.unref()
|
||||
try { received = await download(recommendation, file, controller.signal, operationId) }
|
||||
finally { clearTimeout(timer) }
|
||||
controller.signal.throwIfAborted()
|
||||
publish(operationId, id, 'validating', received, recommendation.expectedBytes)
|
||||
const skill = await ports.importArchive(file, recommendation, options.overwrite === true, snapshot.fingerprint, () => {
|
||||
controller.signal.throwIfAborted()
|
||||
publish(operationId, id, 'installing', received, recommendation.expectedBytes)
|
||||
})
|
||||
publish(operationId, id, 'installed', received, recommendation.expectedBytes)
|
||||
return skill
|
||||
} catch (error) {
|
||||
const message = timedOut ? '推荐技能下载超时,请重试' : controller.signal.aborted ? '推荐技能安装已取消' : error instanceof Error ? error.message : String(error)
|
||||
publish(operationId, id, controller.signal.aborted && !timedOut ? 'cancelled' : 'failed', state?.operationId === operationId ? state.receivedBytes : received, recommendation.expectedBytes, message)
|
||||
throw Object.assign(Error(message), { name: controller.signal.aborted && !timedOut ? 'AbortError' : 'Error' })
|
||||
} finally {
|
||||
if (directory) await removeOwned(directory).catch(error => console.error('技能安装临时文件清理失败', error))
|
||||
}
|
||||
})
|
||||
active = { operationId, controller, task }
|
||||
void task.finally(() => { if (active?.operationId === operationId) active = undefined }).catch(() => {})
|
||||
return task
|
||||
}
|
||||
|
||||
return {
|
||||
recommendations: (): RecommendedSkill[] => structuredClone(catalog),
|
||||
install,
|
||||
cancel(operationId: string): { cancelled: boolean } {
|
||||
if (typeof operationId !== 'string' || !UUID.test(operationId)) throw Error('技能安装操作标识无效')
|
||||
if (active?.operationId !== operationId || state?.operationId === operationId && !ACTIVE_PHASES.has(state.phase) || state?.operationId === operationId && state.phase === 'installing') return { cancelled: false }
|
||||
active.controller.abort()
|
||||
return { cancelled: true }
|
||||
},
|
||||
state: (): SkillInstallProgress | null => state ? structuredClone(state) : null,
|
||||
subscribe(listener: (progress: SkillInstallProgress) => void): () => void { listeners.add(listener); return () => listeners.delete(listener) },
|
||||
async initialize(): Promise<void> {
|
||||
const generation = ++lifecycle
|
||||
await ensureInitialized()
|
||||
if (generation === lifecycle) stopping = false
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
++lifecycle
|
||||
stopping = true
|
||||
if (active && (state?.operationId !== active.operationId || state.phase !== 'installing')) active.controller.abort()
|
||||
await active?.task.catch(() => {})
|
||||
await initialization?.catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let installer: ReturnType<typeof createRecommendedSkillInstaller> | undefined
|
||||
function service(): ReturnType<typeof createRecommendedSkillInstaller> {
|
||||
return installer ??= createRecommendedSkillInstaller({
|
||||
tempRoot: path.join(app.getPath('userData'), 'capabilities', 'skill-installs'), catalog: SKILL_RECOMMENDATIONS,
|
||||
snapshot: getSkillInstallSnapshot, importArchive: importRecommendedSkillArchive
|
||||
})
|
||||
}
|
||||
export const getRecommendedSkills = (): RecommendedSkill[] => service().recommendations()
|
||||
export const installRecommendedSkill = (id: string, options: RecommendedSkillInstallOptions): Promise<SkillRecord> => service().install(id, options)
|
||||
export const cancelRecommendedSkillInstall = (id: string): { cancelled: boolean } => service().cancel(id)
|
||||
export const getRecommendedSkillInstallState = (): SkillInstallProgress | null => service().state()
|
||||
export const subscribeRecommendedSkillInstall = (listener: (progress: SkillInstallProgress) => void): (() => void) => service().subscribe(listener)
|
||||
export const initializeRecommendedSkillInstalls = (): Promise<void> => service().initialize()
|
||||
export const stopRecommendedSkillInstalls = (): Promise<void> => service().stop()
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { RecommendedSkill } from '@shared/capabilities'
|
||||
|
||||
export const SKILL_RECOMMENDATION_CATALOG_VERSION = 1
|
||||
export const SKILL_RECOMMENDATIONS: readonly RecommendedSkill[] = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'archify', name: 'archify', description: '将想法、计划或代码库转为可交互的架构图、流程图和一页式说明。',
|
||||
author: 'tt-a1i', license: 'MIT', repository: 'tt-a1i/archify', version: '3.2.0',
|
||||
commit: '25cf37080fc8b1dbe758a4efc3935b55f3327083', packagePath: 'archify.zip',
|
||||
sha256: '70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5',
|
||||
expectedBytes: 5271959, containsScripts: true, avatarResource: 'archify-owner'
|
||||
})
|
||||
])
|
||||
@@ -4,11 +4,11 @@ import path from 'node:path'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { parseDocument } from 'yaml'
|
||||
import { Unzip, UnzipInflate, zipSync } from 'fflate'
|
||||
import type { SkillDocument, SkillRecord } from '@shared/capabilities'
|
||||
import type { RecommendedSkill, SkillDocument, SkillInstallOrigin, SkillRecord } from '@shared/capabilities'
|
||||
import { expertManagerDocument, expertManagerResource } from './experts'
|
||||
|
||||
const MAX_PACKAGE = 50 * 1024 * 1024, MAX_ARCHIVE = 20 * 1024 * 1024, MAX_FILES = 2000, MAX_DOCUMENT = 1024 * 1024
|
||||
interface Entry { id: string; name: string; folder: string; enabled: boolean; digest: string }
|
||||
interface Entry { id: string; name: string; folder: string; enabled: boolean; digest: string; origin?: SkillInstallOrigin }
|
||||
interface Registry { version: 1; entries: Entry[]; disabledWorkspace: string[] }
|
||||
interface PackageFile { name: string; data: Uint8Array }
|
||||
let operations = Promise.resolve()
|
||||
@@ -64,6 +64,17 @@ async function registry(): Promise<Registry> {
|
||||
try {
|
||||
const file = await checked(root, 'registry.json'), parsed = JSON.parse(await fs.readFile(file, 'utf8')) as Registry
|
||||
if (parsed.version !== 1 || !Array.isArray(parsed.entries) || !Array.isArray(parsed.disabledWorkspace) || parsed.entries.some(entry => !/^user:[a-f0-9]{24}$/.test(entry.id) || !/^[a-f0-9]{24}$/.test(entry.folder) || typeof entry.name !== 'string' || typeof entry.enabled !== 'boolean')) throw Error('技能目录索引格式无效')
|
||||
for (const entry of parsed.entries) {
|
||||
const origin = entry.origin
|
||||
if (origin !== undefined && (!origin || typeof origin !== 'object' ||
|
||||
typeof origin.recommendationId !== 'string' || origin.recommendationId.length > 128 ||
|
||||
typeof origin.repository !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(origin.repository) ||
|
||||
typeof origin.commit !== 'string' || !/^[a-f\d]{40}$/.test(origin.commit) ||
|
||||
typeof origin.archiveSha256 !== 'string' || !/^[a-f\d]{64}$/.test(origin.archiveSha256) ||
|
||||
typeof origin.packagePath !== 'string' || origin.packagePath.length > 1024 ||
|
||||
typeof origin.version !== 'string' || origin.version.length > 128 ||
|
||||
!Number.isFinite(origin.installedAt) || origin.installedAt < 0)) throw Error('技能安装来源索引无效')
|
||||
}
|
||||
return parsed
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, entries: [], disabledWorkspace: [] }; throw error }
|
||||
}
|
||||
@@ -84,7 +95,9 @@ function metadata(content: string): { name: string; description: string; version
|
||||
if (typeof data.name !== 'string' || !data.name.trim() || data.name.trim().length > 128) throw Error('SKILL.md 需要有效 name')
|
||||
if (typeof data.description !== 'string' || !data.description.trim() || data.description.length > 8192) throw Error('SKILL.md 需要有效 description')
|
||||
for (const key of ['enabled', 'disable', 'user-invocable', 'userInvocable', 'disable-model-invocation', 'disableModelInvocation']) if (data[key] !== undefined && typeof data[key] !== 'boolean') throw Error(`SKILL.md 的 ${key} 必须是布尔值`)
|
||||
return { name: data.name.trim(), description: data.description.trim(), version: typeof data.version === 'string' || typeof data.version === 'number' ? String(data.version) : undefined,
|
||||
const nested = data.metadata && typeof data.metadata === 'object' ? (data.metadata as Record<string, unknown>).version : undefined
|
||||
const version = data.version ?? nested
|
||||
return { name: data.name.trim(), description: data.description.trim(), version: typeof version === 'string' || typeof version === 'number' ? String(version) : undefined,
|
||||
enabled: data.enabled !== false && data.disable !== true, userInvocable: (data['user-invocable'] ?? data.userInvocable) !== false,
|
||||
disableModelInvocation: (data['disable-model-invocation'] ?? data.disableModelInvocation) === true }
|
||||
}
|
||||
@@ -203,13 +216,13 @@ async function listUnlocked(cwd?: string): Promise<SkillRecord[]> {
|
||||
try {
|
||||
await checked(managed, entry.folder)
|
||||
const meta = metadata(await documentAt(folder)), files = await packageFiles(folder)
|
||||
result.push(record(entry.id, folder, meta, 'user', entry.enabled, 'imported', packageDigest(files) !== entry.digest))
|
||||
} catch (error) { result.push(invalidRecord(entry.id, entry.name, folder, 'user', error)) }
|
||||
result.push({ ...record(entry.id, folder, meta, 'user', entry.enabled, 'imported', packageDigest(files) !== entry.digest), ...(entry.origin ? { origin: entry.origin } : {}) })
|
||||
} catch (error) { result.push({ ...invalidRecord(entry.id, entry.name, folder, 'user', error), ...(entry.origin ? { origin: entry.origin } : {}) }) }
|
||||
}
|
||||
if (cwd) {
|
||||
const workspace = await fs.realpath(path.resolve(cwd)).catch(() => undefined)
|
||||
if (!workspace) return result
|
||||
for (const source of ['.agents/skills', '.claude/skills']) {
|
||||
for (const source of ['.agents/skills']) {
|
||||
let directory: string
|
||||
try { directory = await checked(workspace, source) } catch { continue }
|
||||
if (!(await fs.stat(directory)).isDirectory()) continue
|
||||
@@ -234,32 +247,90 @@ async function findSkill(id: string, cwd?: string): Promise<SkillRecord> {
|
||||
if (found.error) throw Error(found.error)
|
||||
return found
|
||||
}
|
||||
export function importSkill(source: string, overwrite = false): Promise<SkillRecord> {
|
||||
return serialized(async () => {
|
||||
const files = await fromPath(source), main = files.find(file => file.name === 'SKILL.md')
|
||||
async function importFiles(files: PackageFile[], overwrite: boolean, origin?: SkillInstallOrigin,
|
||||
expectedFingerprint?: string, beforeCommit?: () => void): Promise<SkillRecord> {
|
||||
const main = files.find(file => file.name === 'SKILL.md')
|
||||
if (!main || main.data.length > MAX_DOCUMENT) throw Error('技能根目录需要有效 SKILL.md')
|
||||
const meta = metadata(text(main.data)), name = meta.name.normalize('NFKC').toLowerCase(), folder = hash(name).slice(0, 24), id = 'user:' + folder
|
||||
const state = await registry(), existing = state.entries.find(entry => entry.id === id)
|
||||
if (existing && !overwrite) throw Error('已存在同名技能,确认替换后重试')
|
||||
if (expectedFingerprint !== undefined && (await snapshotUnlocked(meta.name)).fingerprint !== expectedFingerprint) throw Error('本地技能在安装期间已变化,请重新确认替换')
|
||||
const packages = path.join(rootPath(), 'packages'), destination = path.join(packages, folder), stage = path.join(packages, '.stage-' + randomUUID()), backup = path.join(packages, '.backup-' + randomUUID())
|
||||
let moved = false, committed = false
|
||||
await fs.mkdir(stage)
|
||||
try {
|
||||
for (const file of files) { const target = path.join(stage, relativeName(file.name)); await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, file.data, { flag: 'wx' }) }
|
||||
if (expectedFingerprint !== undefined && (await snapshotUnlocked(meta.name)).fingerprint !== expectedFingerprint) throw Error('本地技能在安装期间已变化,请重新确认替换')
|
||||
beforeCommit?.()
|
||||
if (existing) {
|
||||
try { await checked(packages, folder); await fs.rename(destination, backup); moved = true }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
||||
}
|
||||
await fs.rename(stage, destination)
|
||||
const entry: Entry = { id, name: meta.name, folder, enabled: existing?.enabled ?? true, digest: packageDigest(files) }
|
||||
const entry: Entry = { id, name: meta.name, folder, enabled: existing?.enabled ?? true, digest: packageDigest(files), ...(origin ? { origin } : {}) }
|
||||
state.entries = [...state.entries.filter(item => item.id !== id), entry]
|
||||
try { await saveRegistry(state); committed = true } catch (error) { await fs.rm(destination, { recursive: true, force: true }); throw error }
|
||||
return record(id, destination, meta, 'user', entry.enabled, 'imported', false)
|
||||
return { ...record(id, destination, meta, 'user', entry.enabled, 'imported', false), ...(origin ? { origin } : {}) }
|
||||
} finally {
|
||||
await fs.rm(stage, { recursive: true, force: true })
|
||||
await fs.rm(stage, { recursive: true, force: true }).catch(error => console.error('技能暂存清理失败', error))
|
||||
if (moved && !committed) await fs.rename(backup, destination)
|
||||
if (committed) await fs.rm(backup, { recursive: true, force: true })
|
||||
if (committed) await fs.rm(backup, { recursive: true, force: true }).catch(error => console.error('技能备份清理失败', error))
|
||||
}
|
||||
}
|
||||
export function importSkill(source: string, overwrite = false): Promise<SkillRecord> {
|
||||
return serialized(async () => importFiles(await fromPath(source), overwrite))
|
||||
}
|
||||
|
||||
async function snapshotUnlocked(name: string): Promise<{ skill?: SkillRecord; fingerprint: string }> {
|
||||
const normalized = name.normalize('NFKC').toLowerCase()
|
||||
const state = await registry()
|
||||
const entry = state.entries.find(item => item.name.normalize('NFKC').toLowerCase() === normalized)
|
||||
if (!entry) return { fingerprint: 'absent' }
|
||||
const folder = path.join(rootPath(), 'packages', entry.folder)
|
||||
let digest = '', skill: SkillRecord
|
||||
try {
|
||||
digest = packageDigest(await packageFiles(folder))
|
||||
skill = record(entry.id, folder, metadata(await documentAt(folder)), 'user', entry.enabled, 'imported', digest !== entry.digest)
|
||||
} catch (error) {
|
||||
if (!digest) digest = `invalid:${String(error)}`
|
||||
skill = invalidRecord(entry.id, entry.name, folder, 'user', error)
|
||||
}
|
||||
if (entry.origin) skill.origin = entry.origin
|
||||
return { skill, fingerprint: hash(JSON.stringify({ entry, digest })) }
|
||||
}
|
||||
export function getSkillInstallSnapshot(name: string): Promise<{ skill?: SkillRecord; fingerprint: string }> {
|
||||
return serialized(() => snapshotUnlocked(name))
|
||||
}
|
||||
|
||||
function normalizedVersion(version: unknown): string | undefined {
|
||||
if (typeof version !== 'string' || !/^\d+\.\d+(?:\.\d+)?$/.test(version)) return undefined
|
||||
const parts = version.split('.').map(Number)
|
||||
return [parts[0], parts[1], parts[2] ?? 0].join('.')
|
||||
}
|
||||
export function importRecommendedSkillArchive(source: string, recommendation: RecommendedSkill,
|
||||
overwrite: boolean, expectedFingerprint: string, beforeCommit: () => void): Promise<SkillRecord> {
|
||||
return serialized(async () => {
|
||||
const stat = await fs.lstat(source)
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_ARCHIVE) throw Error('推荐技能包文件无效或过大')
|
||||
const bytes = await fs.readFile(source)
|
||||
if (hash(bytes) !== recommendation.sha256) throw Error('推荐技能包 SHA-256 校验失败')
|
||||
const files = archiveFiles(bytes), main = files.find(file => file.name === 'SKILL.md')
|
||||
if (!main || main.data.length > MAX_DOCUMENT) throw Error('推荐技能包缺少有效 SKILL.md')
|
||||
const content = text(main.data), meta = metadata(content)
|
||||
const front = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content)!
|
||||
const yaml = parseDocument(front[1], { uniqueKeys: true, schema: 'core' }).toJS({ maxAliasCount: 50 }) as Record<string, unknown>
|
||||
if (meta.name !== recommendation.name || normalizedVersion(meta.version) !== normalizedVersion(recommendation.version) ||
|
||||
!normalizedVersion(recommendation.version) || yaml.license !== recommendation.license) throw Error('推荐技能名称、版本或许可证与目录不符')
|
||||
const packageFile = files.find(file => file.name === 'package.json')
|
||||
if (packageFile) {
|
||||
const pkg = JSON.parse(text(packageFile.data)) as Record<string, unknown>
|
||||
if (pkg.name !== recommendation.name || normalizedVersion(pkg.version) !== normalizedVersion(recommendation.version) || pkg.license !== recommendation.license) throw Error('推荐技能 package.json 身份与目录不符')
|
||||
}
|
||||
const license = files.find(file => file.name === 'LICENSE' || file.name === 'LICENSE.md')
|
||||
if (!license || !text(license.data).includes(recommendation.license === 'MIT' ? 'MIT License' : recommendation.license)) throw Error('推荐技能许可证文件与目录不符')
|
||||
return importFiles(files, overwrite, { recommendationId: recommendation.id, repository: recommendation.repository,
|
||||
commit: recommendation.commit, packagePath: recommendation.packagePath, archiveSha256: recommendation.sha256,
|
||||
version: recommendation.version, installedAt: Date.now() }, expectedFingerprint, beforeCommit)
|
||||
})
|
||||
}
|
||||
export function readSkill(id: string, cwd?: string): Promise<SkillDocument> {
|
||||
@@ -268,6 +339,7 @@ export function readSkill(id: string, cwd?: string): Promise<SkillDocument> {
|
||||
}
|
||||
export function setSkillEnabled(id: string, enabled: boolean, cwd?: string): Promise<void> {
|
||||
return serialized(async () => {
|
||||
if (typeof enabled !== 'boolean') throw new Error('技能启停状态无效')
|
||||
const skill = await findSkill(id, cwd), state = await registry()
|
||||
if (skill.scope === 'workspace') state.disabledWorkspace = [...state.disabledWorkspace.filter(item => item !== id), ...enabled ? [] : [id]]
|
||||
else state.entries = state.entries.map(entry => entry.id === id ? { ...entry, enabled } : entry)
|
||||
@@ -335,3 +407,17 @@ export function readSkillResource(id: string, resource: string, cwd: string): Pr
|
||||
return text(await fs.readFile(file))
|
||||
})
|
||||
}
|
||||
/** Gate and read under the same serialized skill operation, including per-skill invocation flags. */
|
||||
export function readSkillResourceForInvocation(id: string, resource: string, cwd: string, explicit: boolean): Promise<string> {
|
||||
if (id === 'builtin:expert-manager') return Promise.resolve(expertManagerResource(resource))
|
||||
return serialized(async () => {
|
||||
const skill = await findSkill(id, cwd)
|
||||
if (!skill.enabled) throw Error('该技能已禁用')
|
||||
if (explicit ? !skill.userInvocable : skill.disableModelInvocation) {
|
||||
throw Error(explicit ? '该技能不允许用户手动调用' : '该技能不允许模型自动调用')
|
||||
}
|
||||
const file = await checked(skill.path, relativeName(resource)), stat = await fs.stat(file)
|
||||
if (!stat.isFile() || stat.size > MAX_DOCUMENT) throw Error('技能资源无效或过大')
|
||||
return text(await fs.readFile(file))
|
||||
})
|
||||
}
|
||||
+136
-15
@@ -18,6 +18,9 @@ import { createInteractionBroker } from './interaction-broker'
|
||||
import { agentMode, agentModeInstructions } from './agent/mode-policy'
|
||||
import { cachedClaudeKernel, getClaudeKernel, releaseClaudeKernel } from './agent/claude/claude-kernel'
|
||||
import { normalizeUserContent } from '../shared/user-content'
|
||||
import { buildSkillContext, listSkills } from './capabilities/skills'
|
||||
import { discoverMcpCapabilities } from './capabilities/mcp'
|
||||
import { resolveQueuedRequestSettings } from './agent/queued-request-settings'
|
||||
|
||||
export interface ChatTurnCompletion {
|
||||
runId: string
|
||||
@@ -30,6 +33,7 @@ export interface StartedChatTurn extends SendResult {
|
||||
completion: Promise<ChatTurnCompletion>
|
||||
}
|
||||
export interface ChatTurnServicePorts {
|
||||
hasQueuedPrompts?(sessionId: string, queueEditId?: string): Promise<boolean>
|
||||
projects: ProjectRepository
|
||||
sessionRoot(sessionId: string): Promise<string>
|
||||
broadcastChat(channel: string, payload: unknown, sender?: WebContents): void
|
||||
@@ -92,25 +96,53 @@ function contextWindowOf(model: { profile: { contextWindowTokens?: number; maxIn
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
export function createChatTurnService({ projects: projectRepository, sessionRoot, broadcastChat, getDefaultRecipient }: ChatTurnServicePorts) {
|
||||
const startTurn = async (payload: SendPayload, sender?: WebContents): Promise<StartedChatTurn> => withChatSessionLock(payload.sessionId, async () => {
|
||||
export function createChatTurnService({ projects: projectRepository, sessionRoot, broadcastChat, getDefaultRecipient, hasQueuedPrompts }: ChatTurnServicePorts) {
|
||||
const appenders = new Map<string, { runId: string; prepare(payload: SendPayload, id: string): Promise<{ delivery: Promise<void> }> }>()
|
||||
const startTurn = async (payload: SendPayload, sender?: WebContents, queuedMessageId?: string): Promise<StartedChatTurn> => withChatSessionLock(payload.sessionId, async () => {
|
||||
const recipient = sender ?? getDefaultRecipient?.()
|
||||
const { sessionId, text } = payload
|
||||
if (payload.rollbackArtifacts !== undefined && typeof payload.rollbackArtifacts !== 'boolean') throw new Error('产物回退选项无效')
|
||||
if (!(await sessions.listSessions()).some((session) => session.id === sessionId)) throw new Error('会话不存在')
|
||||
if (typeof text !== 'string' || (!text.trim() && !payload.attachments?.length && !payload.quotes?.length && !payload.resources?.length)) throw new Error('消息为空')
|
||||
if (typeof text !== 'string' || (!text.trim() && !payload.attachments?.length && !payload.quotes?.length && !payload.resources?.length && !payload.skillIds?.length && !payload.mcpServerIds?.length)) throw new Error('消息为空')
|
||||
if ([...runTasks.values()].some((run) => run.sessionId === sessionId)) throw new Error('该会话正在运行')
|
||||
if (!queuedMessageId && await hasQueuedPrompts?.(sessionId, payload.command ? undefined : payload.queueEditId)) throw new Error('请先处理待发送队列;普通消息请加入队列,压缩或重发不能绕过队列')
|
||||
if (payload.command !== undefined && payload.command !== 'compact') throw new Error('不支持的内置命令')
|
||||
if (payload.command && (text.trim() !== '/compact' || payload.attachments?.length || payload.quotes?.length || payload.resources?.length || payload.skillIds?.length || payload.mcpServerIds?.length || payload.action)) throw new Error('上下文压缩不能与普通消息、附件或能力引用混发')
|
||||
const stored = await sessions.getMessages(sessionId)
|
||||
if (payload.command && !stored.some(message => message.role === 'assistant' && message.claudeEntryId)) throw new Error('已有内核历史后才能压缩上下文')
|
||||
if (stored.some(message => message.interactions?.some(interaction => ['pending', 'responding', 'error'].includes(interaction.status)))) throw new Error('请先处理当前问答或授权')
|
||||
const deliveryMessageId = queuedMessageId ?? payload.queueEditId
|
||||
if (deliveryMessageId && stored.some(message => message.id === deliveryMessageId)) throw new Error('追加消息已在历史中,请核对后删除队列项,不能重复交付')
|
||||
|
||||
const attachments = await Promise.all((payload.attachments ?? []).map((item) => getAttachment(item.id)))
|
||||
const promptAttachments = await attachmentPrompt(attachments)
|
||||
const context = await sessions.getSessionContext(sessionId)
|
||||
const project = context.projectId ? await projectRepository.get(context.projectId) : undefined
|
||||
const mode = agentMode(context.agentMode)
|
||||
const permissionMode = payload.permissionMode ?? 'default-sandbox'
|
||||
const { permissionMode, persistSessionPermission } = resolveQueuedRequestSettings({ queued: !!queuedMessageId,
|
||||
payloadPermissionMode: payload.permissionMode, sessionPermissionMode: context.permissionMode })
|
||||
if (persistSessionPermission) await sessions.updateSessionContext(sessionId, { permissionMode })
|
||||
const selectedModel = await models.resolveModel(context.modelId)
|
||||
if (!selectedModel) throw new Error('请先添加并选择模型')
|
||||
if (!isSupportedClaudeModel(selectedModel)) throw new Error('首版仅支持 Anthropic/Claude 兼容模型')
|
||||
const workspaceRoot = await sessionRoot(sessionId)
|
||||
if (payload.skillIds !== undefined && (!Array.isArray(payload.skillIds) || payload.skillIds.some(id => typeof id !== 'string') || payload.skillIds.length > 100)) throw new Error('技能引用无效')
|
||||
if (payload.mcpServerIds !== undefined && (!Array.isArray(payload.mcpServerIds) || payload.mcpServerIds.some(id => typeof id !== 'string') || payload.mcpServerIds.length > 100)) throw new Error('MCP 服务引用无效')
|
||||
const selectedSkillIds = [...new Set(payload.skillIds ?? [])]
|
||||
const selectedMcpIds = [...new Set(payload.mcpServerIds ?? [])]
|
||||
const selectedSkillContext = await buildSkillContext(selectedSkillIds, workspaceRoot)
|
||||
const skillRecords = selectedSkillIds.length ? await listSkills(workspaceRoot) : []
|
||||
const selectedSkills = selectedSkillIds.map(id => {
|
||||
const record = skillRecords.find(skill => skill.id === id)!
|
||||
return { id, name: record.name, uri: record.path }
|
||||
})
|
||||
if (selectedMcpIds.length) {
|
||||
const servers = await discoverMcpCapabilities()
|
||||
for (const id of selectedMcpIds) {
|
||||
const server = servers.find(item => item.id === id)
|
||||
if (!server?.available) throw new Error(server?.reason ?? '所选 MCP 服务不存在或已禁用')
|
||||
}
|
||||
}
|
||||
const references = [...new Set(payload.resources ?? [])]
|
||||
for (const path of references) {
|
||||
if (typeof path !== 'string' || await classifyWorkspacePath(workspaceRoot, path) === 'unknown') {
|
||||
@@ -142,6 +174,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
}
|
||||
const target = history[targetIndex]
|
||||
if (!target || target.role !== 'user') throw new Error('未找到对应用户消息')
|
||||
if (target.command) throw new Error('压缩记录不能编辑或重新生成,请使用新的原生压缩命令')
|
||||
const prefix = history.slice(0, targetIndex)
|
||||
forkAt = [...prefix].reverse().find((message) => message.role === 'assistant' && message.claudeEntryId)?.claudeEntryId
|
||||
forceNew = !forkAt
|
||||
@@ -159,8 +192,8 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
}
|
||||
|
||||
const runId = randomUUID()
|
||||
const userMessage: ChatMessage = {
|
||||
id: randomUUID(),
|
||||
let userMessage: ChatMessage = {
|
||||
id: deliveryMessageId ?? randomUUID(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
requestId: runId,
|
||||
@@ -168,7 +201,10 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
attachments,
|
||||
resources: references,
|
||||
quotes: payload.quotes?.length ? payload.quotes : undefined,
|
||||
userContent: normalizeUserContent(payload.userContent, text, { attachments, resources: references, quotes: payload.quotes, skills: [] }),
|
||||
skills: selectedSkills.length ? selectedSkills : undefined,
|
||||
mcpServerIds: selectedMcpIds.length ? selectedMcpIds : undefined,
|
||||
command: payload.command,
|
||||
userContent: normalizeUserContent(payload.userContent, text, { attachments, resources: references, quotes: payload.quotes, skills: selectedSkills }),
|
||||
createdAt: Date.now(),
|
||||
status: 'done'
|
||||
}
|
||||
@@ -187,7 +223,9 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
|
||||
await sessions.startTurn(sessionId, userMessage, assistant, history, payload.action)
|
||||
const quoteText = (payload.quotes ?? []).map((quote) => quote.markdown.trim()).filter(Boolean).join('\n\n')
|
||||
const promptText = [text, quoteText, promptAttachments.text, references.length ? `引用工作区资源:\n${references.join('\n')}` : '', modeReminder].filter(Boolean).join('\n\n')
|
||||
const promptText = payload.command === 'compact' ? '/compact' : [text, quoteText, promptAttachments.text, selectedSkillContext,
|
||||
selectedMcpIds.length ? `本消息指定 MCP 服务(尚未执行;其他已启用能力仍可使用):${selectedMcpIds.join(', ')}` : '',
|
||||
references.length ? `引用工作区资源:\n${references.join('\n')}` : '', modeReminder].filter(Boolean).join('\n\n')
|
||||
|
||||
let eventSequence = 0
|
||||
let usage: TokenUsage | undefined
|
||||
@@ -196,7 +234,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
activeRuns.set(runId, controller)
|
||||
generalSettings.updateActiveRuns(1)
|
||||
const publish = (event: ChatEvent): void => broadcastChat('chat:event', { ...event, sessionId }, recipient)
|
||||
const checkpoint = createMessageCheckpoint(
|
||||
const createCheckpoint = () => createMessageCheckpoint(
|
||||
(message) => sessions.patchMessage(sessionId, message.id, {
|
||||
content: message.content, tools: message.tools, parts: message.parts, interactions: message.interactions,
|
||||
status: message.status, turnStartedAt: message.turnStartedAt, turnDurationMs: message.turnDurationMs,
|
||||
@@ -205,7 +243,9 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
}),
|
||||
(error) => console.error('会话增量保存失败,将在回合结束时重试', error)
|
||||
)
|
||||
let checkpoint = createCheckpoint()
|
||||
let terminalEvent: ChatEvent | null = null
|
||||
let betweenSegments = false
|
||||
const emit = (event: ChatEvent): void => {
|
||||
const isTerminal = event.type === 'done' || event.type === 'cancelled' || event.type === 'error'
|
||||
let observedEvent: ChatEvent = isTerminal
|
||||
@@ -244,9 +284,69 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
onSessionId: (id) => sessions.setClaudeSessionId(sessionId, id)
|
||||
})
|
||||
await kernel.setPolicy(permissionMode, mode)
|
||||
const preparedAppends = new Map<string, ChatMessage>()
|
||||
appenders.set(sessionId, { runId, prepare: async (input, id) => {
|
||||
const notSubmitted = (message: string): never => { throw Object.assign(new Error(message), { submitted: false }) }
|
||||
if (controller.signal.aborted || !kernel.busy || payload.command || input.command || input.action) notSubmitted('当前运行不能接收引导消息')
|
||||
const pendingInteraction = () => assistant.interactions?.some(item => ['pending', 'responding', 'error'].includes(item.status))
|
||||
if (pendingInteraction()) notSubmitted('请先处理当前问答或授权')
|
||||
if (preparedAppends.has(id) || (await sessions.getMessages(sessionId)).some(message => message.id === id)) notSubmitted('追加消息已提交,不能重复执行')
|
||||
const files = await Promise.all((input.attachments ?? []).map(item => getAttachment(item.id)))
|
||||
const attachment = await attachmentPrompt(files)
|
||||
const paths = [...new Set(input.resources ?? [])]
|
||||
for (const path of paths) if (typeof path !== 'string' || await classifyWorkspacePath(workspaceRoot, path) === 'unknown') notSubmitted('引用资源已不存在或不属于当前工作区')
|
||||
const skillIds = [...new Set(input.skillIds ?? [])]
|
||||
const skillContext = await buildSkillContext(skillIds, workspaceRoot)
|
||||
const records = skillIds.length ? await listSkills(workspaceRoot) : []
|
||||
const selected = skillIds.map(skillId => { const skill = records.find(item => item.id === skillId)!; return { id: skillId, name: skill.name, uri: skill.path } })
|
||||
const servers = [...new Set(input.mcpServerIds ?? [])]
|
||||
if (servers.length) {
|
||||
const catalog = await discoverMcpCapabilities()
|
||||
for (const serverId of servers) if (!catalog.some(item => item.id === serverId && item.available)) notSubmitted('所选 MCP 服务已禁用、未连接或尚未批准')
|
||||
}
|
||||
if (controller.signal.aborted || pendingInteraction()) notSubmitted('运行已取消或正在等待问答/授权')
|
||||
await commitAttachments(files)
|
||||
const message: ChatMessage = { id, role: 'user', content: input.text, createdAt: Date.now(), status: 'done', requestId: runId,
|
||||
attachments: files, resources: paths, quotes: input.quotes, skills: selected.length ? selected : undefined,
|
||||
mcpServerIds: servers.length ? servers : undefined,
|
||||
userContent: normalizeUserContent(input.userContent, input.text, { attachments: files, resources: paths, quotes: input.quotes, skills: selected }) }
|
||||
preparedAppends.set(id, message)
|
||||
const prompt = [input.text, ...(input.quotes ?? []).map(quote => quote.markdown), attachment.text, skillContext,
|
||||
paths.length ? `引用工作区资源:\n${paths.join('\n')}` : '', servers.length ? `本消息指定 MCP 服务(尚未执行):${servers.join(', ')}` : '', modeReminder].filter(Boolean).join('\n\n')
|
||||
const delivery = kernel.appendInput({ userMessageId: id, prompt, images: attachment.images, skillIds, mcpServerIds: servers }).catch(error => {
|
||||
if ((error as { submitted?: boolean })?.submitted === false) preparedAppends.delete(id)
|
||||
throw error
|
||||
})
|
||||
// The lock owner returns this promise as data; acknowledgement must be awaited outside the session lock.
|
||||
return { delivery }
|
||||
} })
|
||||
const result = await kernel.runTurn({
|
||||
runId, userMessageId: userMessage.id, prompt: promptText, images: promptAttachments.images,
|
||||
emit, interactions: interactionBroker, signal: controller.signal, ownerWebContentsId: recipient?.id
|
||||
runId, userMessageId: userMessage.id, prompt: promptText, command: payload.command,
|
||||
skillIds: selectedSkillIds, mcpServerIds: selectedMcpIds, images: promptAttachments.images,
|
||||
emit, interactions: interactionBroker, signal: controller.signal, ownerWebContentsId: recipient?.id,
|
||||
onSegmentComplete: async result => {
|
||||
assistant = { ...assistant, content: result.content, tools: result.tools, claudeEntryId: result.entryId,
|
||||
status: 'done', turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt), turnStartedAt: undefined }
|
||||
betweenSegments = true
|
||||
await checkpoint.finish(assistant)
|
||||
},
|
||||
onSegmentStart: async inputs => {
|
||||
if (controller.signal.aborted) throw new Error('运行已取消,追加交付未确认')
|
||||
const users = inputs.map(input => preparedAppends.get(input.userMessageId))
|
||||
if (!users.length || users.some(message => !message)) throw new Error('内核返回了未知追加消息身份')
|
||||
const nextHistory = [...history, userMessage, assistant, ...(users.slice(0, -1) as ChatMessage[])]
|
||||
const nextUser = users.at(-1)!
|
||||
const nextAssistant: ChatMessage = { id: randomUUID(), role: 'assistant', content: '', ...freezeAssistantModelMetadata(selectedModel.profile),
|
||||
requestId: runId, tools: [], parts: [], createdAt: Date.now(), turnStartedAt: nextUser.createdAt, status: 'streaming' }
|
||||
await sessions.startTurn(sessionId, nextUser, nextAssistant, nextHistory)
|
||||
history = nextHistory; userMessage = nextUser; assistant = nextAssistant
|
||||
betweenSegments = false
|
||||
for (const input of inputs) preparedAppends.delete(input.userMessageId)
|
||||
usage = undefined
|
||||
checkpoint = createCheckpoint()
|
||||
++eventSequence
|
||||
broadcastChat('chat:run-started', { ...runSnapshot(), segmentBoundary: true }, recipient)
|
||||
}
|
||||
})
|
||||
assistant.content = result.content
|
||||
assistant.tools = result.tools
|
||||
@@ -256,7 +356,10 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
await sessions.clearClaudeFork(sessionId)
|
||||
} catch (error) {
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
if (failure.name === 'AbortError' || controller.signal.aborted) emit({ runId, type: 'cancelled' })
|
||||
if (betweenSegments) {
|
||||
terminalEvent = failure.name === 'AbortError' || controller.signal.aborted
|
||||
? { runId, type: 'cancelled' } : { runId, type: 'error', message: failure.message }
|
||||
} else if (failure.name === 'AbortError' || controller.signal.aborted) emit({ runId, type: 'cancelled' })
|
||||
else {
|
||||
if (/Resume rejected by --resume-drops-turn:/i.test(failure.message)) {
|
||||
await sessions.setClaudeFork(sessionId, undefined, true).catch(() => undefined)
|
||||
@@ -269,15 +372,20 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
try {
|
||||
if (terminalEvent) {
|
||||
terminalEvent = { ...terminalEvent, sequence: ++eventSequence, turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) }
|
||||
assistant = applyAssistantEvent(assistant, terminalEvent)
|
||||
if (!betweenSegments) assistant = applyAssistantEvent(assistant, terminalEvent)
|
||||
}
|
||||
await checkpoint.finish(assistant)
|
||||
if (terminalEvent) publish(terminalEvent)
|
||||
if (betweenSegments) {
|
||||
++eventSequence
|
||||
broadcastChat('chat:run-started', { ...runSnapshot(), segmentBoundary: true }, recipient)
|
||||
}
|
||||
} catch (error) {
|
||||
const failure: ChatEvent = { runId, type: 'error', sequence: ++eventSequence, message: `会话保存失败:${error instanceof Error ? error.message : String(error)}` }
|
||||
assistant = applyAssistantEvent(assistant, failure)
|
||||
publish(failure)
|
||||
} finally {
|
||||
if (appenders.get(sessionId)?.runId === runId) appenders.delete(sessionId)
|
||||
activeRuns.delete(runId)
|
||||
generalSettings.updateActiveRuns(-1)
|
||||
runEmitters.delete(runId)
|
||||
@@ -287,11 +395,11 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
})
|
||||
runTasks.set(runId, { sessionId, recipientId: recipient?.id, promise: task, snapshot: runSnapshot })
|
||||
broadcastChat('chat:run-started', runSnapshot(), recipient)
|
||||
void task.finally(() => runTasks.delete(runId))
|
||||
void task.then(() => runTasks.delete(runId), () => runTasks.delete(runId))
|
||||
|
||||
const completion = task.then((): ChatTurnCompletion => ({
|
||||
runId, sessionId, assistantMessageId: assistant.id,
|
||||
status: assistant.status === 'done' ? 'done' : assistant.status === 'cancelled' ? 'cancelled' : 'error',
|
||||
status: terminalEvent?.type === 'cancelled' ? 'cancelled' : terminalEvent?.type === 'error' ? 'error' : assistant.status === 'done' ? 'done' : 'error',
|
||||
...(assistant.error ? { error: assistant.error } : {})
|
||||
}), (error): ChatTurnCompletion => ({
|
||||
runId, sessionId, assistantMessageId: assistant.id, status: 'error',
|
||||
@@ -355,6 +463,19 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
|
||||
return {
|
||||
startTurn,
|
||||
async appendToRun(payload: SendPayload, id: string): Promise<void> {
|
||||
let prepared: { delivery: Promise<void> }
|
||||
try {
|
||||
prepared = await withChatSessionLock(payload.sessionId, async () => {
|
||||
const active = appenders.get(payload.sessionId)
|
||||
if (!active) throw new Error('当前运行尚未准备好接收引导消息')
|
||||
return active.prepare(payload, id)
|
||||
})
|
||||
} catch (error) {
|
||||
throw Object.assign(error instanceof Error ? error : new Error(String(error)), { submitted: false })
|
||||
}
|
||||
await prepared.delivery
|
||||
},
|
||||
rewindFiles,
|
||||
revert,
|
||||
cancel(runId: string) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ChatMessage, ContextUsageInfo, ContextUsageSnapshot } from '../shared/types'
|
||||
import type { ChatMessage, ContextUsageBreakdown, ContextUsageInfo, ContextUsageSnapshot } from '../shared/types'
|
||||
import { contextInfoFromMessages } from '../shared/context-usage'
|
||||
|
||||
export interface ContextUsageDependencies {
|
||||
messages(id:string):Promise<ChatMessage[]>
|
||||
modelId(id:string):Promise<string|undefined>
|
||||
running(id:string):boolean
|
||||
summary?(id:string):Promise<ContextUsageBreakdown|undefined>
|
||||
/** The host rechecks identity inside its existing session mutation lock. */
|
||||
persistIfCurrent(id:string,assistantId:string,requestId:string|undefined,sample:ContextUsageSnapshot):Promise<void>
|
||||
}
|
||||
@@ -12,13 +13,19 @@ const latestAssistant=(messages:ChatMessage[])=>messages.findLast(message=>messa
|
||||
|
||||
export function createContextUsageService(deps:ContextUsageDependencies):{get(id:string):Promise<ContextUsageInfo>} {
|
||||
return {async get(id) {
|
||||
const messages=await deps.messages(id)
|
||||
let messages=await deps.messages(id)
|
||||
let summary:ContextUsageBreakdown|undefined
|
||||
try { summary=await deps.summary?.(id) } catch { /* Retain real stream samples on query failure. */ }
|
||||
if(summary)messages=await deps.messages(id)
|
||||
const assistant=latestAssistant(messages)
|
||||
const info=contextInfoFromMessages(messages)
|
||||
let info=contextInfoFromMessages(messages)
|
||||
if(summary&&summary.requestId===assistant?.requestId&&summary.modelId===assistant?.modelId)info={...info,breakdown:summary,status:info.sample?'latest':'unavailable',reason:'分类来自内核 summary 的本地估计'}
|
||||
const selectedModelId=await deps.modelId(id)
|
||||
const running=deps.running(id)
|
||||
const decorate=(value:ContextUsageInfo):ContextUsageInfo=>{
|
||||
const model=value.sample?.modelId??value.breakdown?.modelId??assistant?.modelId
|
||||
return model&&selectedModelId&&model!==selectedModelId?{...value,status:'previous',reason:'来自切换模型前的最近请求'}:value
|
||||
return model&&selectedModelId&&model!==selectedModelId&&!running&&assistant?.status!=='streaming'
|
||||
?{...value,status:'previous',reason:'来自切换模型前的最近请求'}:value
|
||||
}
|
||||
if(!assistant)return {status:'unavailable',reason:'发送消息后可查看用量'}
|
||||
return decorate(info)
|
||||
|
||||
@@ -15,6 +15,11 @@ import { getPreventSleep } from './config'
|
||||
import { configurePreventSleep } from './general-settings'
|
||||
import { executeBrowserTool, installBrowserAutomationLifecycle } from './browser-automation'
|
||||
import { configureBrowserTool } from './agent/tools/browser'
|
||||
import { registerUpdates } from './updates'
|
||||
import { activeRuns, runTasks } from './chat-turn-service'
|
||||
import { disposeTerminalHosts } from './terminal-host'
|
||||
import { pauseRuntimeStarts, resumeRuntimeStarts } from './update-maintenance'
|
||||
import { createUpdateLifecycle } from './update-lifecycle'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let settingsWindow: BrowserWindow | null = null
|
||||
@@ -27,6 +32,7 @@ let settingsRequested = false
|
||||
let settingsRequestId = 0
|
||||
let allowQuit = false
|
||||
let shutdownPromise: Promise<void> | null = null
|
||||
let updates: ReturnType<typeof registerUpdates> | undefined
|
||||
const mainDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/**
|
||||
@@ -271,6 +277,7 @@ app.on('before-quit', (event) => {
|
||||
if (allowQuit) return
|
||||
event.preventDefault()
|
||||
if (shutdownPromise) return
|
||||
updates?.dispose()
|
||||
shutdownPromise = stopBackgroundServices().finally(() => disposeClaudeKernels()).finally(() => disposeMcpServers())
|
||||
void shutdownPromise
|
||||
.catch(() => undefined)
|
||||
@@ -309,6 +316,29 @@ app.whenReady().then(() => {
|
||||
// 通用设置:启动时套用持久化偏好(运行任务时阻止系统休眠)。
|
||||
void getPreventSleep().then((enabled) => configurePreventSleep(enabled)).catch(() => undefined)
|
||||
registerIpc(() => mainWindow)
|
||||
const updateLifecycle = createUpdateLifecycle({
|
||||
quiesce: async () => {
|
||||
await pauseRuntimeStarts()
|
||||
for (const controller of activeRuns.values()) controller.abort()
|
||||
await stopBackgroundServices()
|
||||
await Promise.all([...runTasks.values()].map(task => task.promise))
|
||||
},
|
||||
restore: () => { resumeRuntimeStarts(); startBackgroundServices() },
|
||||
shutdown: async () => {
|
||||
updates?.dispose()
|
||||
disposeTerminalHosts()
|
||||
await disposeClaudeKernels()
|
||||
await disposeMcpServers()
|
||||
},
|
||||
exit: () => app.exit(0),
|
||||
log: error => console.error('更新退出时清理失败', error)
|
||||
})
|
||||
updates = registerUpdates({
|
||||
beforeInstall: updateLifecycle.beforeInstall,
|
||||
installFailed: updateLifecycle.installFailed,
|
||||
quitForUpdate: updateLifecycle.quitForUpdate
|
||||
})
|
||||
updates.start()
|
||||
startBackgroundServices()
|
||||
setTaskWindowFactory((sessionId) => { openTaskWindow(sessionId) })
|
||||
ipcMain.handle('app:openSettings', (_event, section?: string, action?: 'new') => openSettings(section, action))
|
||||
|
||||
+151
-15
@@ -18,7 +18,7 @@ import { classifyWorkspacePath, decodeDownload, imageDownloadPayload, readWorksp
|
||||
import type { DownloadPayload, DownloadResult, ResourceRange } from '@shared/resources'
|
||||
import type { InteractionResponse } from '@shared/conversation'
|
||||
import { respondToInteraction } from './interaction-broker'
|
||||
import { getAttachment, removeAttachment, stageAttachment, stageAttachmentPath } from './attachments'
|
||||
import { commitAttachments, getAttachment, removeAttachment, stageAttachment, stageAttachmentPath } from './attachments'
|
||||
import { transcribeAudio } from './speech'
|
||||
import * as models from './models'
|
||||
import * as workspaces from './workspaces'
|
||||
@@ -39,7 +39,7 @@ import { modelMetadata, modelMetadataStatus, refreshModelMetadata } from './mode
|
||||
import { readGitBranch } from './git'
|
||||
import { createChatTurnService, activeRuns, runTasks, runEmitters, withChatSessionLock,
|
||||
disposeSessionKernel, cancelRunsForWebContents } from './chat-turn-service'
|
||||
import { claudeRuntimeInfo, setSessionVisible, removeSessionView } from './agent/claude/claude-kernel'
|
||||
import { cachedClaudeKernel, claudeRuntimeInfo, setSessionVisible, removeSessionView } from './agent/claude/claude-kernel'
|
||||
import { createManagedSession } from './session-factory'
|
||||
import { createAutomationHost } from './automations/host'
|
||||
import type { AutomationDefinitionCreateInput, AutomationDefinitionPatch, AutomationRunFilter } from '@shared/automation'
|
||||
@@ -48,11 +48,27 @@ import { ArtifactIndexService } from './library/artifact-service'
|
||||
import { LibraryService } from './library/service'
|
||||
import { JsonLibraryUserStateRepository, libraryUserStatePath } from './library/user-state'
|
||||
import { createContextUsageService } from './context-usage-service'
|
||||
import { nextRequestSettings } from './agent/request-settings'
|
||||
import { createPromptQueue } from './prompt-queue'
|
||||
import * as skills from './capabilities/skills'
|
||||
import { getRecommendedSkills, installRecommendedSkill, cancelRecommendedSkillInstall,
|
||||
getRecommendedSkillInstallState, subscribeRecommendedSkillInstall,
|
||||
initializeRecommendedSkillInstalls, stopRecommendedSkillInstalls } from './capabilities/recommended-skills'
|
||||
import type { RecommendedSkillInstallOptions } from '@shared/capabilities'
|
||||
import * as mcp from './capabilities/mcp'
|
||||
import { contextInfoFromMessages } from '@shared/context-usage'
|
||||
import { assertRuntimeAvailable, withRuntimeStart } from './update-maintenance'
|
||||
|
||||
let automationHost: ReturnType<typeof createAutomationHost> | undefined
|
||||
export function startBackgroundServices(): void { automationHost?.start() }
|
||||
export async function stopBackgroundServices(): Promise<void> { await automationHost?.stop() }
|
||||
let stopInstallBroadcast: (() => void) | undefined
|
||||
export function startBackgroundServices(): void {
|
||||
automationHost?.start()
|
||||
void mcp.initializeMcpServers().catch(error => console.error('MCP 启动连接失败', error))
|
||||
void initializeRecommendedSkillInstalls().catch(error => console.error('推荐技能安装初始化失败', error))
|
||||
}
|
||||
export async function stopBackgroundServices(): Promise<void> {
|
||||
await Promise.all([automationHost?.stop(), stopRecommendedSkillInstalls()])
|
||||
}
|
||||
|
||||
/** 主进程入口注入:打开某任务的独立窗口。未注入时该菜单项给出诚实错误。 */
|
||||
let taskWindowFactory: ((sessionId: string) => void) | null = null
|
||||
@@ -75,7 +91,62 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
}
|
||||
browserHost.setBrowserHostEventSink(event => broadcastChat('browser:event', event))
|
||||
const projects = new ProjectRepository(join(app.getPath('userData'), 'projects.json'))
|
||||
const chatTurns = createChatTurnService({ projects, sessionRoot, broadcastChat, getDefaultRecipient: () => getWindow()?.webContents })
|
||||
const chatTurns = createChatTurnService({ projects, sessionRoot, broadcastChat, getDefaultRecipient: () => getWindow()?.webContents,
|
||||
hasQueuedPrompts: async (id, editId) => {
|
||||
const queue = await promptQueue.get(id)
|
||||
return queue.items.length > 0 || !!queue.editingDraft && queue.editingDraft.id !== editId
|
||||
} })
|
||||
const assertQueueSession = async (id: string): Promise<void> => {
|
||||
if (typeof id !== 'string' || !(await sessions.listSessions()).some(session => session.id === id)) throw new Error('会话不存在')
|
||||
}
|
||||
const hasPendingInteraction = async (id: string): Promise<boolean> => (await sessions.getMessages(id)).some(message =>
|
||||
message.interactions?.some(interaction => ['pending', 'responding', 'error'].includes(interaction.status)))
|
||||
const promptQueue = createPromptQueue({
|
||||
file: join(app.getPath('userData'), 'prompt-queues.json'),
|
||||
validate: async payload => {
|
||||
await assertQueueSession(payload.sessionId)
|
||||
if (payload.command || payload.action || payload.fromMessageId) throw new Error('命令与重新发送不能加入追加队列')
|
||||
if (typeof payload.text !== 'string' || (!payload.text.trim() && !payload.attachments?.length && !payload.resources?.length && !payload.quotes?.length && !payload.skillIds?.length && !payload.mcpServerIds?.length)) throw new Error('消息为空')
|
||||
if (await hasPendingInteraction(payload.sessionId)) throw new Error('请先处理当前问答或授权')
|
||||
const cwd = await sessionRoot(payload.sessionId)
|
||||
const labels: Record<string, string> = {}
|
||||
if (payload.skillIds?.length) {
|
||||
for (const skill of await skills.listSkills(cwd)) if (payload.skillIds.includes(skill.id)) labels[`skill:${skill.id}`] = skill.name
|
||||
}
|
||||
if (payload.mcpServerIds?.length) {
|
||||
for (const server of await mcp.listMcpServers()) if (payload.mcpServerIds.includes(server.id)) labels[`mcp:${server.id}`] = server.name
|
||||
}
|
||||
payload.capabilityLabels = labels
|
||||
const files = await Promise.all((payload.attachments ?? []).map(item => getAttachment(item.id)))
|
||||
await commitAttachments(files)
|
||||
payload.attachments = files.map(({ previewUrl: _preview, ...file }) => file)
|
||||
},
|
||||
blocked: async id => [...runTasks.values()].some(run => run.sessionId === id) || await hasPendingInteraction(id),
|
||||
deliver: (payload, id) => withRuntimeStart(() => chatTurns.startTurn(payload, undefined, id)),
|
||||
deliverNow: (payload, id) => chatTurns.appendToRun(payload, id),
|
||||
changed: snapshot => broadcastChat('chat:queue:changed', snapshot)
|
||||
})
|
||||
ipcMain.handle('chat:queue:get', async (_event, id: string) => { await assertQueueSession(id); return promptQueue.get(id) })
|
||||
ipcMain.handle('chat:queue:enqueue', (_event, payload: SendPayload) => withRuntimeStart(() => promptQueue.enqueue(payload)))
|
||||
ipcMain.handle('chat:queue:restore-edit', async (_event, sessionId: string, id: string) => {
|
||||
await assertQueueSession(sessionId)
|
||||
if (typeof id !== 'string') throw new Error('编辑取回标识无效')
|
||||
return promptQueue.restoreEdit(sessionId, id)
|
||||
})
|
||||
for (const action of ['take', 'remove', 'reorder', 'resume', 'sendNow'] as const) {
|
||||
const channel = action === 'sendNow' ? 'send-now' : action
|
||||
ipcMain.handle(`chat:queue:${channel}`, async (_event, id: string, argument: unknown) => {
|
||||
await assertQueueSession(id)
|
||||
if (action === 'resume') return withRuntimeStart(() => promptQueue.resume(id))
|
||||
if (action === 'reorder') {
|
||||
if (!Array.isArray(argument) || !argument.every(value => typeof value === 'string')) throw new Error('追加消息排序无效')
|
||||
return promptQueue.reorder(id, argument)
|
||||
}
|
||||
if (typeof argument !== 'string') throw new Error('追加消息标识无效')
|
||||
if (action === 'sendNow') return withRuntimeStart(() => promptQueue.sendNow(id, argument))
|
||||
return promptQueue[action](id, argument)
|
||||
})
|
||||
}
|
||||
const automation = createAutomationHost({ userData: app.getPath('userData'), projects, chatTurns,
|
||||
changed: () => broadcastChat('automation:changed', undefined) })
|
||||
automationHost = automation
|
||||
@@ -85,7 +156,7 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
ipcMain.handle('automation:set-enabled', (_event, id: string, enabled: boolean) => automation.api.setEnabled(id, enabled))
|
||||
ipcMain.handle('automation:remove', (_event, id: string) => automation.api.remove(id))
|
||||
ipcMain.handle('automation:runs', (_event, filter?: AutomationRunFilter) => automation.api.listRuns(filter))
|
||||
ipcMain.handle('automation:run-now', (_event, id: string) => automation.api.runNow(id))
|
||||
ipcMain.handle('automation:run-now', (_event, id: string) => { assertRuntimeAvailable(); return automation.api.runNow(id) })
|
||||
const artifactIndex = new ArtifactIndexService({ repository: new FileArtifactIndexRepository(),
|
||||
listSourceSessions: sessions.listSessions, getMessages: sessions.getMessages })
|
||||
const library = new LibraryService({ artifactIndex,
|
||||
@@ -109,17 +180,62 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
ipcMain.handle('projects:update', (_event, projectId: string, patch: Partial<ProjectInput>) => projects.update(projectId, patch))
|
||||
const capabilityMaintenance = (): never => { throw new Error('技能、专家和连接器正在维护') }
|
||||
for (const channel of [
|
||||
'skills:list', 'skills:read', 'skills:enable', 'skills:remove', 'skills:import', 'skills:export',
|
||||
'experts:list', 'experts:read', 'experts:activate', 'experts:remove', 'experts:open-path',
|
||||
'mcp:list', 'mcp:save', 'mcp:trust', 'mcp:connect', 'mcp:disconnect', 'mcp:enable',
|
||||
'mcp:tool-enable', 'mcp:remove'
|
||||
'experts:list', 'experts:read', 'experts:activate', 'experts:remove', 'experts:open-path'
|
||||
]) ipcMain.handle(channel, capabilityMaintenance)
|
||||
stopInstallBroadcast?.()
|
||||
stopInstallBroadcast = subscribeRecommendedSkillInstall(progress => broadcastChat('skills:install-progress', progress))
|
||||
const skillsChanged = (): void => broadcastChat('skills:changed', undefined)
|
||||
ipcMain.handle('skills:recommendations', () => getRecommendedSkills())
|
||||
ipcMain.handle('skills:install-state', () => getRecommendedSkillInstallState())
|
||||
ipcMain.handle('skills:cancel-install', (_event, id: string) => cancelRecommendedSkillInstall(id))
|
||||
ipcMain.handle('skills:install-recommended', async (_event, id: string, options: RecommendedSkillInstallOptions) => {
|
||||
assertRuntimeAvailable()
|
||||
const installed = await installRecommendedSkill(id, options)
|
||||
skillsChanged()
|
||||
return installed
|
||||
})
|
||||
ipcMain.handle('skills:list', (_event, cwd?: string) => skills.listSkills(cwd))
|
||||
ipcMain.handle('skills:read', (_event, id: string, cwd?: string) => skills.readSkill(id, cwd))
|
||||
ipcMain.handle('skills:enable', async (_event, id: string, enabled: boolean, cwd?: string) => {
|
||||
await skills.setSkillEnabled(id, enabled, cwd)
|
||||
skillsChanged()
|
||||
})
|
||||
ipcMain.handle('skills:remove', async (_event, id: string) => {
|
||||
await skills.removeSkill(id)
|
||||
skillsChanged()
|
||||
})
|
||||
ipcMain.handle('skills:import', async (_event, path?: string, overwrite = false, kind: 'directory' | 'zip' = 'directory') => {
|
||||
if (!path) {
|
||||
const selected = await dialog.showOpenDialog({ properties: [kind === 'zip' ? 'openFile' : 'openDirectory'], ...(kind === 'zip' ? { filters: [{ name: 'Skill ZIP', extensions: ['zip'] }] } : {}) })
|
||||
if (selected.canceled || !selected.filePaths[0]) return null
|
||||
path = selected.filePaths[0]
|
||||
}
|
||||
const installed = await skills.importSkill(path, overwrite)
|
||||
skillsChanged()
|
||||
return installed
|
||||
})
|
||||
ipcMain.handle('skills:export', async (_event, id: string, destination?: string) => {
|
||||
if (!destination) {
|
||||
const selected = await dialog.showSaveDialog({ defaultPath: 'skill.zip', filters: [{ name: 'Skill ZIP', extensions: ['zip'] }] })
|
||||
if (selected.canceled || !selected.filePath) return null
|
||||
destination = selected.filePath
|
||||
}
|
||||
return skills.exportSkill(id, destination)
|
||||
})
|
||||
ipcMain.handle('mcp:list', () => mcp.listMcpServers())
|
||||
ipcMain.handle('mcp:save', (_event, input) => mcp.saveMcpServer(input))
|
||||
ipcMain.handle('mcp:trust', (_event, id: string, hash: string) => mcp.trustMcpServer(id, hash))
|
||||
ipcMain.handle('mcp:connect', (_event, id: string) => withRuntimeStart(() => mcp.connectMcpServer(id)))
|
||||
ipcMain.handle('mcp:disconnect', (_event, id: string) => mcp.disconnectMcpServer(id))
|
||||
ipcMain.handle('mcp:enable', (_event, id: string, enabled: boolean) => withRuntimeStart(() => mcp.setMcpEnabled(id, enabled)))
|
||||
ipcMain.handle('mcp:tool-enable', (_event, id: string, tool: string, enabled: boolean) => mcp.setMcpToolEnabled(id, tool, enabled))
|
||||
ipcMain.handle('mcp:remove', (_event, id: string) => mcp.removeMcpServer(id))
|
||||
function senderWindow(event: Electron.IpcMainInvokeEvent): BrowserWindow {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window) throw new Error('宿主窗口已关闭')
|
||||
return window
|
||||
}
|
||||
ipcMain.handle('terminal:create', async (event, sessionId?: string, cols?: number, rows?: number) => terminalHost.createTerminal(senderWindow(event), await sessionRoot(sessionId), sessionId, cols, rows))
|
||||
ipcMain.handle('terminal:create', async (event, sessionId?: string, cols?: number, rows?: number) => withRuntimeStart(async () => terminalHost.createTerminal(senderWindow(event), await sessionRoot(sessionId), sessionId, cols, rows)))
|
||||
ipcMain.handle('terminal:get', (event, id: string, sessionId?: string) => terminalHost.getTerminal(senderWindow(event), id, sessionId))
|
||||
ipcMain.handle('terminal:write', (event, id: string, data: string, sessionId?: string) => terminalHost.writeTerminal(senderWindow(event), id, data, sessionId))
|
||||
ipcMain.handle('terminal:resize', (event, id: string, cols: number, rows: number, sessionId?: string) => terminalHost.resizeTerminal(senderWindow(event), id, cols, rows, sessionId))
|
||||
@@ -415,7 +531,11 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
ipcMain.handle('session:list-archived', () => sessions.listVisibleSessions(true))
|
||||
ipcMain.handle('session:create', (_event, title?: string, context: SessionContext = {}) => createManagedSession(projects, title, context))
|
||||
ipcMain.handle('session:update-context', (_event, id: string, patch: SessionContext) => withChatSessionLock(id, async () => {
|
||||
if ([...runTasks.values()].some((run) => run.sessionId === id)) throw new Error('请先停止当前运行')
|
||||
if ([...runTasks.values()].some((run) => run.sessionId === id)) {
|
||||
const next = nextRequestSettings(patch)
|
||||
if (next.modelId && !(await models.resolveModel(next.modelId))) throw new Error('模型不存在')
|
||||
return sessions.updateSessionContext(id, next)
|
||||
}
|
||||
const previous = await sessions.getSessionContext(id)
|
||||
patch = agentModeContextPatch(previous, patch)
|
||||
if (patch.projectId !== undefined && patch.projectId !== previous.projectId) throw new Error('请在目标项目新建任务')
|
||||
@@ -431,11 +551,16 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
if (patch.modelId && !(await models.resolveModel(patch.modelId))) throw new Error('模型不存在')
|
||||
if (patch.modelId !== undefined) context.modelId = patch.modelId
|
||||
if (patch.thinkingLevel !== undefined) context.thinkingLevel = thinkingLevel(patch.thinkingLevel)
|
||||
context.expertId = null
|
||||
if (patch.permissionMode !== undefined) {
|
||||
if (patch.permissionMode !== 'default-sandbox' && patch.permissionMode !== 'full-access') throw new Error('权限模式无效')
|
||||
context.permissionMode = patch.permissionMode
|
||||
}
|
||||
if (patch.expertId !== undefined) context.expertId = patch.expertId
|
||||
return sessions.updateSessionContext(id, context)
|
||||
}))
|
||||
ipcMain.handle('session:messages', (_event, sessionId: string) => sessions.getMessages(sessionId))
|
||||
const contextUsageService=createContextUsageService({messages:sessions.getMessages,
|
||||
summary: async id => cachedClaudeKernel(id)?.getContextBreakdown(),
|
||||
modelId:async id=>(await sessions.getSessionContext(id)).modelId,
|
||||
running:id=>[...runTasks.values()].some(run=>run.sessionId===id),
|
||||
persistIfCurrent:(id,assistantId,requestId,sample)=>withChatSessionLock(id,async()=>{
|
||||
@@ -472,13 +597,17 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
})
|
||||
ipcMain.handle('session:delete', async (_event, sessionId: string, options?: { onlyIfEmpty?: boolean }) => withChatSessionLock(sessionId, async () => {
|
||||
const tasks = [...runTasks.entries()].filter(([, task]) => task.sessionId === sessionId)
|
||||
if (options?.onlyIfEmpty && (tasks.length || (await sessions.getMessages(sessionId)).length)) {
|
||||
const queued = await promptQueue.get(sessionId)
|
||||
if (queued.items.some(item => item.status === 'delivering')) throw new Error('追加消息正在交付,请稍后删除会话')
|
||||
if (options?.onlyIfEmpty && (tasks.length || queued.items.length || queued.editingDraft || (await sessions.getMessages(sessionId)).length)) {
|
||||
throw new Error('该会话已有消息或正在运行,已保留任务')
|
||||
}
|
||||
await promptQueue.pause(sessionId, '会话正在删除')
|
||||
for (const [runId] of tasks) activeRuns.get(runId)?.abort()
|
||||
await disposeSessionKernel(sessionId)
|
||||
await Promise.all(tasks.map(([, task]) => task.promise))
|
||||
disposeSessionBrowsers(sessionId)
|
||||
await promptQueue.clear(sessionId)
|
||||
return sessions.deleteSession(sessionId)
|
||||
}))
|
||||
|
||||
@@ -515,7 +644,14 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
ipcMain.handle('chat:revert', (_event, sessionId: string, messageId: string, options?: RewindOptions) =>
|
||||
chatTurns.revert(sessionId, messageId, options))
|
||||
ipcMain.handle('chat:send', async (event, payload: SendPayload) => {
|
||||
const { completion: _completion, ...result } = await chatTurns.startTurn(payload, event.sender)
|
||||
if (payload.queueEditId) await promptQueue.assertEditing(payload.sessionId, payload.queueEditId)
|
||||
if (payload.command) {
|
||||
const queue = await promptQueue.get(payload.sessionId)
|
||||
if (queue.items.length || queue.editingDraft) throw new Error('请先处理待发送消息再压缩上下文')
|
||||
}
|
||||
const { completion, ...result } = await withRuntimeStart(() => chatTurns.startTurn(payload, event.sender))
|
||||
if (payload.queueEditId) await promptQueue.completeEdit(payload.sessionId, payload.queueEditId).catch(error => console.error('编辑取回记录清理失败,已保留待核对记录', error))
|
||||
void completion.then(done => done.status === 'done' ? promptQueue.notify(payload.sessionId) : promptQueue.pause(payload.sessionId, '运行停止或异常,已暂停自动发送')).catch(error => console.error('追加队列状态保存失败', error))
|
||||
return result
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,802 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { hostname } from 'node:os'
|
||||
import { dirname } from 'node:path'
|
||||
import type { SendPayload } from '../shared/ipc'
|
||||
import type { PromptQueueSnapshot, QueuedPrompt } from '../shared/prompt-queue'
|
||||
|
||||
interface Delivery {
|
||||
completion: Promise<{ status: 'done' | 'cancelled' | 'error' }>
|
||||
}
|
||||
interface Ports {
|
||||
file: string
|
||||
validate(payload: SendPayload): Promise<void>
|
||||
blocked(sessionId: string): Promise<boolean>
|
||||
deliver(payload: SendPayload, id: string): Promise<Delivery>
|
||||
deliverNow?(payload: SendPayload, id: string): Promise<void>
|
||||
/** Host-owned request settings resolved immediately before delivery; stored payload stays immutable. */
|
||||
resolveDispatch?(payload: SendPayload, id: string): Promise<SendPayload> | SendPayload
|
||||
changed(snapshot: PromptQueueSnapshot): void
|
||||
}
|
||||
interface DeliveryOwner {
|
||||
pid: number
|
||||
nonce: string
|
||||
hostname: string
|
||||
startedAt: number
|
||||
}
|
||||
interface StoredQueuedPrompt extends Omit<QueuedPrompt, 'status'> {
|
||||
status: QueuedPrompt['status'] | 'editing'
|
||||
editingUnknown?: boolean
|
||||
deliveryOwner?: DeliveryOwner
|
||||
}
|
||||
interface StoredQueue extends Omit<PromptQueueSnapshot, 'items'> {
|
||||
items: StoredQueuedPrompt[]
|
||||
}
|
||||
interface Store {
|
||||
version: 1
|
||||
queues: StoredQueue[]
|
||||
}
|
||||
interface LockMetadata {
|
||||
pid: number
|
||||
nonce: string
|
||||
hostname: string
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
}
|
||||
interface MemoryOverlay {
|
||||
itemId: string
|
||||
error: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
const MAX_ITEMS = 19
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024
|
||||
const LOCK_TIMEOUT_MS = 10_000
|
||||
const LOCK_LEASE_MS = 30_000
|
||||
const LOCK_HEARTBEAT_MS = 10_000
|
||||
const LOCAL_HOST = hostname()
|
||||
/** Shared by repository instances in this process so a fresh read preserves their active delivery. */
|
||||
const PROCESS_NONCE = randomUUID()
|
||||
const overlays = new Map<string, MemoryOverlay>()
|
||||
const lastKnownQueues = new Map<string, StoredQueue>()
|
||||
|
||||
const clone = <T>(value: T): T => structuredClone(value)
|
||||
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const queueKey = (file: string, sessionId: string): string => `${file}\0${sessionId}`
|
||||
const empty = (sessionId: string): StoredQueue => ({ sessionId, revision: 0, paused: false, items: [] })
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value)
|
||||
const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error)
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new Error(`追加队列文件损坏:${message}`)
|
||||
}
|
||||
|
||||
function validatePayload(value: unknown, sessionId: string): SendPayload {
|
||||
if (!object(value)) fail('消息载荷无效')
|
||||
if (value.sessionId !== sessionId) fail('消息载荷会话与队列会话不一致')
|
||||
if (typeof value.text !== 'string') fail('消息文本无效')
|
||||
if (value.command !== undefined || value.action !== undefined || value.fromMessageId !== undefined) {
|
||||
fail('普通队列不能包含命令或重新发送操作')
|
||||
}
|
||||
if (value.permissionMode !== undefined && value.permissionMode !== 'default-sandbox' && value.permissionMode !== 'full-access') {
|
||||
fail('权限模式无效')
|
||||
}
|
||||
if (value.capabilityLabels !== undefined) {
|
||||
const labels = value.capabilityLabels
|
||||
if (!object(labels) || Object.keys(labels).length > 100 ||
|
||||
Object.entries(labels).some(([key, label]) =>
|
||||
!key || key.length > 512 || typeof label !== 'string' || label.length > 1024)) {
|
||||
fail('能力标签字段无效')
|
||||
}
|
||||
}
|
||||
for (const key of ['resources', 'skillIds', 'mcpServerIds'] as const) {
|
||||
if (value[key] !== undefined && (!Array.isArray(value[key]) || value[key].some(item => typeof item !== 'string'))) {
|
||||
fail(`${key} 字段无效`)
|
||||
}
|
||||
}
|
||||
const attachments = Array.isArray(value.attachments) ? value.attachments : []
|
||||
if (value.attachments !== undefined) {
|
||||
if (!Array.isArray(value.attachments) || attachments.some(item => !object(item) ||
|
||||
typeof item.id !== 'string' || typeof item.name !== 'string' ||
|
||||
typeof item.mimeType !== 'string' || typeof item.size !== 'number' || !Number.isFinite(item.size))) {
|
||||
fail('附件字段无效')
|
||||
}
|
||||
}
|
||||
const quotes = Array.isArray(value.quotes) ? value.quotes : []
|
||||
if (value.quotes !== undefined) {
|
||||
if (!Array.isArray(value.quotes) || quotes.some(item => !object(item) ||
|
||||
typeof item.id !== 'string' || item.kind !== 'table' ||
|
||||
typeof item.markdown !== 'string' || typeof item.preview !== 'string')) {
|
||||
fail('引用字段无效')
|
||||
}
|
||||
}
|
||||
if (value.userContent !== undefined && !Array.isArray(value.userContent)) fail('内容顺序字段无效')
|
||||
if (value.queueEditId !== undefined &&
|
||||
(typeof value.queueEditId !== 'string' || !value.queueEditId.trim() || value.queueEditId.length > 256)) {
|
||||
fail('编辑取回标识无效')
|
||||
}
|
||||
if (value.planId !== undefined && typeof value.planId !== 'string') fail('计划标识无效')
|
||||
if (value.rollbackArtifacts !== undefined && typeof value.rollbackArtifacts !== 'boolean') fail('产物回退字段无效')
|
||||
if (!value.text.trim() && !attachments.length && !quotes.length &&
|
||||
!(Array.isArray(value.resources) && value.resources.length) &&
|
||||
!(Array.isArray(value.skillIds) && value.skillIds.length) &&
|
||||
!(Array.isArray(value.mcpServerIds) && value.mcpServerIds.length)) fail('消息为空')
|
||||
return value as unknown as SendPayload
|
||||
}
|
||||
|
||||
function validateIncomingPayload(value: unknown): SendPayload {
|
||||
if (!object(value) || typeof value.sessionId !== 'string' || !value.sessionId || value.sessionId.length > 256) {
|
||||
throw new Error('追加消息载荷无效')
|
||||
}
|
||||
try {
|
||||
return validatePayload(value, value.sessionId)
|
||||
} catch (error) {
|
||||
throw new Error(`追加消息无效:${errorMessage(error).replace(/^追加队列文件损坏:/, '')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateStore(value: unknown): Store {
|
||||
if (!object(value) || value.version !== 1 || !Array.isArray(value.queues)) fail('版本或顶层结构无效')
|
||||
const sessions = new Set<string>()
|
||||
const queues: StoredQueue[] = []
|
||||
for (const rawQueue of value.queues) {
|
||||
if (!object(rawQueue)) fail('队列不是对象')
|
||||
const sessionId = rawQueue.sessionId
|
||||
if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 256 || sessions.has(sessionId)) fail('会话标识缺失或重复')
|
||||
sessions.add(sessionId)
|
||||
if (!Number.isInteger(rawQueue.revision) || Number(rawQueue.revision) < 0) fail('revision 无效')
|
||||
if (typeof rawQueue.paused !== 'boolean') fail('暂停状态无效')
|
||||
if (rawQueue.reason !== undefined && typeof rawQueue.reason !== 'string') fail('暂停原因无效')
|
||||
if (!Array.isArray(rawQueue.items) || rawQueue.items.length > MAX_ITEMS + 1) fail('消息数量无效')
|
||||
const ids = new Set<string>()
|
||||
const items: StoredQueuedPrompt[] = rawQueue.items.map(rawItem => {
|
||||
if (!object(rawItem)) fail('队列消息不是对象')
|
||||
if (typeof rawItem.id !== 'string' || !rawItem.id || rawItem.id.length > 256 || ids.has(rawItem.id)) fail('消息标识缺失或重复')
|
||||
ids.add(rawItem.id)
|
||||
if (typeof rawItem.createdAt !== 'number' || !Number.isFinite(rawItem.createdAt) || rawItem.createdAt < 0) fail('消息时间无效')
|
||||
if (!['pending', 'delivering', 'unknown', 'editing'].includes(String(rawItem.status))) fail('消息状态无效')
|
||||
if (rawItem.error !== undefined && typeof rawItem.error !== 'string') fail('消息错误信息无效')
|
||||
const status = rawItem.status as StoredQueuedPrompt['status']
|
||||
if (status !== 'unknown' && status !== 'pending' && rawItem.error !== undefined) fail('消息恢复错误状态无效')
|
||||
if (rawItem.editingUnknown !== undefined && (status !== 'editing' || typeof rawItem.editingUnknown !== 'boolean')) fail('编辑交接状态无效')
|
||||
let deliveryOwner: DeliveryOwner | undefined
|
||||
if (rawItem.deliveryOwner !== undefined) {
|
||||
if (status !== 'delivering' && status !== 'editing' || !object(rawItem.deliveryOwner)) fail('交付所有权标记无效')
|
||||
const owner = rawItem.deliveryOwner
|
||||
if (!Number.isInteger(owner.pid) || Number(owner.pid) <= 0 || typeof owner.nonce !== 'string' ||
|
||||
!owner.nonce || typeof owner.hostname !== 'string' || !owner.hostname ||
|
||||
typeof owner.startedAt !== 'number' || !Number.isFinite(owner.startedAt)) fail('交付所有权内容无效')
|
||||
deliveryOwner = { pid: Number(owner.pid), nonce: owner.nonce, hostname: owner.hostname, startedAt: owner.startedAt }
|
||||
}
|
||||
return {
|
||||
id: rawItem.id,
|
||||
createdAt: rawItem.createdAt,
|
||||
status,
|
||||
...(rawItem.editingUnknown !== undefined ? { editingUnknown: rawItem.editingUnknown } : {}),
|
||||
payload: validatePayload(rawItem.payload, sessionId),
|
||||
...(rawItem.error !== undefined ? { error: rawItem.error } : {}),
|
||||
...(deliveryOwner ? { deliveryOwner } : {})
|
||||
}
|
||||
})
|
||||
if (items.filter(item => item.status !== 'editing').length > MAX_ITEMS || items.filter(item => item.status === 'editing').length > 1) fail('消息数量或编辑取回数量无效')
|
||||
queues.push({
|
||||
sessionId,
|
||||
revision: Number(rawQueue.revision),
|
||||
paused: rawQueue.paused,
|
||||
...(rawQueue.reason !== undefined ? { reason: rawQueue.reason } : {}),
|
||||
items
|
||||
})
|
||||
}
|
||||
return { version: 1, queues }
|
||||
}
|
||||
|
||||
async function readStore(file: string): Promise<Store> {
|
||||
try {
|
||||
const stat = await fs.stat(file)
|
||||
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) throw new Error('追加队列文件不是普通文件或过大')
|
||||
return validateStore(JSON.parse(await fs.readFile(file, 'utf8')))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, queues: [] }
|
||||
if (error instanceof SyntaxError) throw new Error('追加队列文件格式无效')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStore(file: string, value: Store): Promise<void> {
|
||||
const serialized = JSON.stringify(value)
|
||||
if (Buffer.byteLength(serialized, 'utf8') > MAX_FILE_BYTES) throw new Error('追加队列内容超过 10 MB,已保留原队列和输入草稿')
|
||||
await fs.mkdir(dirname(file), { recursive: true })
|
||||
const temporary = `${file}.${randomUUID()}.tmp`
|
||||
try {
|
||||
await fs.writeFile(temporary, serialized, { encoding: 'utf8', mode: 0o600 })
|
||||
await fs.rename(temporary, file)
|
||||
} catch (error) {
|
||||
throw new Error(`追加队列保存失败:${errorMessage(error)}`)
|
||||
} finally {
|
||||
await fs.rm(temporary, { force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function pidAlive(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
||||
}
|
||||
}
|
||||
|
||||
async function readLock(file: string): Promise<LockMetadata | undefined> {
|
||||
try {
|
||||
const value = JSON.parse(await fs.readFile(file, 'utf8'))
|
||||
if (!object(value) || !Number.isInteger(value.pid) || typeof value.nonce !== 'string' ||
|
||||
typeof value.hostname !== 'string' || typeof value.createdAt !== 'number' ||
|
||||
typeof value.expiresAt !== 'number') return undefined
|
||||
return value as unknown as LockMetadata
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function staleLock(lockPath: string): Promise<boolean> {
|
||||
const metadata = await readLock(lockPath)
|
||||
if (metadata) {
|
||||
// A live local process is never preempted merely because its heartbeat
|
||||
// stalled; callers get a clear timeout instead. Other hosts use the lease.
|
||||
return metadata.hostname === LOCAL_HOST
|
||||
? !pidAlive(metadata.pid)
|
||||
: metadata.expiresAt <= Date.now()
|
||||
}
|
||||
try {
|
||||
const stat = await fs.stat(lockPath)
|
||||
return Date.now() - stat.mtimeMs > LOCK_LEASE_MS
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
}
|
||||
}
|
||||
|
||||
async function reclaimLock(lockPath: string): Promise<boolean> {
|
||||
if (!await staleLock(lockPath)) return false
|
||||
const quarantine = `${lockPath}.stale.${randomUUID()}`
|
||||
try {
|
||||
await fs.rename(lockPath, quarantine)
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
}
|
||||
const moved = await readLock(quarantine)
|
||||
const stillActive = moved && (moved.hostname === LOCAL_HOST
|
||||
? pidAlive(moved.pid)
|
||||
: moved.expiresAt > Date.now())
|
||||
if (stillActive) {
|
||||
let restore: Awaited<ReturnType<typeof fs.open>> | undefined
|
||||
try {
|
||||
// Exclusive creation ensures a competing owner's lock is never replaced.
|
||||
restore = await fs.open(lockPath, 'wx', 0o600)
|
||||
await restore.writeFile(await fs.readFile(quarantine))
|
||||
await restore.sync()
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') await fs.rm(quarantine, { force: true }).catch(() => undefined)
|
||||
return false
|
||||
} finally {
|
||||
await restore?.close().catch(() => undefined)
|
||||
}
|
||||
await fs.rm(quarantine, { force: true }).catch(() => undefined)
|
||||
return false
|
||||
}
|
||||
await fs.rm(quarantine, { force: true }).catch(() => undefined)
|
||||
return true
|
||||
}
|
||||
|
||||
async function withFileLock<T>(file: string, operation: () => Promise<T>): Promise<T> {
|
||||
const lockPath = `${file}.lock`
|
||||
await fs.mkdir(dirname(file), { recursive: true })
|
||||
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
||||
let handle: Awaited<ReturnType<typeof fs.open>> | undefined
|
||||
const nonce = randomUUID()
|
||||
const metadata: LockMetadata = {
|
||||
pid: process.pid,
|
||||
nonce,
|
||||
hostname: LOCAL_HOST,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + LOCK_LEASE_MS
|
||||
}
|
||||
while (!handle) {
|
||||
try {
|
||||
handle = await fs.open(lockPath, 'wx', 0o600)
|
||||
await handle.writeFile(JSON.stringify(metadata), 'utf8')
|
||||
await handle.sync()
|
||||
} catch (error) {
|
||||
if (handle) {
|
||||
await handle.close().catch(() => undefined)
|
||||
await fs.rm(lockPath, { force: true }).catch(() => undefined)
|
||||
handle = undefined
|
||||
}
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
const exists = code === 'EEXIST' ||
|
||||
code === 'EPERM' && await fs.stat(lockPath).then(() => true, () => false)
|
||||
if (!exists) throw error
|
||||
if (await reclaimLock(lockPath)) continue
|
||||
if (Date.now() >= deadline) throw new Error('追加队列正被其他 YCode 实例使用,等待文件锁超时')
|
||||
await sleep(40)
|
||||
}
|
||||
}
|
||||
let released = false
|
||||
let heartbeatInFlight: Promise<void> | undefined
|
||||
const lockHandle = handle
|
||||
const refreshLock = (): void => {
|
||||
if (released || heartbeatInFlight) return
|
||||
const task = (async () => {
|
||||
if (released) return
|
||||
const bytes = Buffer.from(JSON.stringify({ ...metadata, expiresAt: Date.now() + LOCK_LEASE_MS }), 'utf8')
|
||||
// Keep using the original descriptor; reopening the path could overwrite a new owner.
|
||||
await lockHandle.truncate(0)
|
||||
await lockHandle.write(bytes, 0, bytes.length, 0)
|
||||
await lockHandle.sync()
|
||||
})().catch(() => undefined).finally(() => {
|
||||
if (heartbeatInFlight === task) heartbeatInFlight = undefined
|
||||
})
|
||||
heartbeatInFlight = task
|
||||
}
|
||||
const heartbeat = setInterval(() => {
|
||||
refreshLock()
|
||||
}, LOCK_HEARTBEAT_MS)
|
||||
heartbeat.unref()
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
released = true
|
||||
clearInterval(heartbeat)
|
||||
await heartbeatInFlight
|
||||
const current = await readLock(lockPath)
|
||||
if (current?.nonce === nonce) await fs.rm(lockPath, { force: true }).catch(() => undefined)
|
||||
await lockHandle.close().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function ownerIsActive(owner: DeliveryOwner | undefined): boolean {
|
||||
if (!owner) return false
|
||||
if (owner.hostname !== LOCAL_HOST) return Date.now() - owner.startedAt < LOCK_LEASE_MS
|
||||
if (owner.pid === process.pid) return owner.nonce === PROCESS_NONCE
|
||||
return pidAlive(owner.pid)
|
||||
}
|
||||
|
||||
function recoverQueues(state: Store): Set<string> {
|
||||
const changed = new Set<string>()
|
||||
for (const queue of state.queues) {
|
||||
let recovered = false
|
||||
for (const item of queue.items) {
|
||||
if (item.status === 'editing' && !ownerIsActive(item.deliveryOwner)) {
|
||||
item.status = item.editingUnknown ? 'unknown' : 'pending'
|
||||
if (item.editingUnknown) item.error = '原消息交付结果不明,编辑尚未提交,请核对历史后处理'
|
||||
delete item.editingUnknown
|
||||
delete item.deliveryOwner
|
||||
recovered = true
|
||||
continue
|
||||
}
|
||||
if (item.status !== 'delivering' || ownerIsActive(item.deliveryOwner)) continue
|
||||
item.status = 'unknown'
|
||||
item.error = '上次交付结果不明,请核对历史后编辑或删除'
|
||||
delete item.deliveryOwner
|
||||
recovered = true
|
||||
}
|
||||
if (recovered) {
|
||||
queue.paused = true
|
||||
queue.reason = '检测到未完成的交付,已暂停自动发送'
|
||||
queue.revision++
|
||||
changed.add(queue.sessionId)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
function applyOverlay(file: string, sessionId: string, queue: StoredQueue): boolean {
|
||||
const overlay = overlays.get(queueKey(file, sessionId))
|
||||
if (!overlay) return false
|
||||
const item = queue.items.find(value => value.id === overlay.itemId)
|
||||
if (!item) {
|
||||
overlays.delete(queueKey(file, sessionId))
|
||||
return false
|
||||
}
|
||||
const changed = item.status !== 'unknown' || item.error !== overlay.error ||
|
||||
!queue.paused || queue.reason !== overlay.reason
|
||||
if (changed) {
|
||||
item.status = 'unknown'
|
||||
item.error = overlay.error
|
||||
delete item.deliveryOwner
|
||||
delete item.editingUnknown
|
||||
queue.paused = true
|
||||
queue.reason = overlay.reason
|
||||
queue.revision++
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
function overlaySessions(file: string): string[] {
|
||||
const prefix = `${file}\0`
|
||||
return [...overlays.keys()].filter(key => key.startsWith(prefix)).map(key => key.slice(prefix.length))
|
||||
}
|
||||
function lastKnownSnapshots(file: string): StoredQueue[] {
|
||||
const prefix = `${file}\0`
|
||||
return [...lastKnownQueues.entries()]
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.map(([, queue]) => clone(queue))
|
||||
}
|
||||
|
||||
function snapshot(value: StoredQueue): PromptQueueSnapshot {
|
||||
const toPrompt = ({ deliveryOwner: _owner, editingUnknown, status, ...item }: StoredQueuedPrompt): QueuedPrompt => ({ ...clone(item), status: status === 'editing' ? editingUnknown ? 'unknown' : 'pending' : status })
|
||||
return {
|
||||
sessionId: value.sessionId,
|
||||
revision: value.revision,
|
||||
paused: value.paused,
|
||||
...(value.reason !== undefined ? { reason: value.reason } : {}),
|
||||
items: value.items.filter(item => item.status !== 'editing').map(toPrompt),
|
||||
...(value.items.some(item => item.status === 'editing') ? { editingDraft: (() => {
|
||||
const draft = toPrompt(value.items.find(item => item.status === 'editing')!)
|
||||
return { ...draft, payload: { ...draft.payload, queueEditId: draft.id } }
|
||||
})() } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function createPromptQueue(ports: Ports) {
|
||||
let serial: Promise<unknown> = Promise.resolve()
|
||||
let loadedOnce = false
|
||||
const pumping = new Set<string>()
|
||||
const locked = <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const result = serial.catch(() => {}).then(() => withFileLock(ports.file, operation))
|
||||
serial = result.catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
async function load(): Promise<Store> {
|
||||
let state: Store
|
||||
try {
|
||||
state = await readStore(ports.file)
|
||||
} catch (error) {
|
||||
const fallback = lastKnownSnapshots(ports.file)
|
||||
if (!overlaySessions(ports.file).length || !fallback.length) throw error
|
||||
state = { version: 1, queues: fallback }
|
||||
for (const queue of state.queues) {
|
||||
if (applyOverlay(ports.file, queue.sessionId, queue)) {
|
||||
queue.reason = `${queue.reason ?? '追加队列不可用'};读取失败:${errorMessage(error)}`
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
if (loadedOnce && !state.queues.length) {
|
||||
// The store can disappear between two operations in the same process.
|
||||
// Keep the last validated queues instead of silently dropping unrelated
|
||||
// sessions; a real restart starts with an empty in-memory fallback.
|
||||
state.queues = lastKnownSnapshots(ports.file)
|
||||
}
|
||||
for (const sessionId of overlaySessions(ports.file)) {
|
||||
if (state.queues.some(queue => queue.sessionId === sessionId)) continue
|
||||
const fallback = lastKnownQueues.get(queueKey(ports.file, sessionId))
|
||||
if (fallback) state.queues.push(clone(fallback))
|
||||
}
|
||||
for (const queue of state.queues) lastKnownQueues.set(queueKey(ports.file, queue.sessionId), clone(queue))
|
||||
const recovered = recoverQueues(state)
|
||||
if (!loadedOnce) {
|
||||
loadedOnce = true
|
||||
for (const queue of state.queues) {
|
||||
if (!queue.items.length || queue.paused) continue
|
||||
// A live delivery owned by this process is a fresh read, not a restart.
|
||||
if (queue.items.some(item => item.status === 'delivering' && ownerIsActive(item.deliveryOwner))) continue
|
||||
queue.paused = true
|
||||
queue.reason = '应用重启后队列已暂停,请确认后恢复'
|
||||
queue.revision++
|
||||
recovered.add(queue.sessionId)
|
||||
}
|
||||
}
|
||||
if (recovered.size) {
|
||||
try {
|
||||
await saveStore(ports.file, state)
|
||||
for (const sessionId of recovered) overlays.delete(queueKey(ports.file, sessionId))
|
||||
} catch (error) {
|
||||
for (const sessionId of recovered) {
|
||||
const queue = state.queues.find(value => value.sessionId === sessionId)
|
||||
const item = queue?.items.find(value => value.status === 'unknown')
|
||||
if (!queue || !item) continue
|
||||
overlays.set(queueKey(ports.file, sessionId), {
|
||||
itemId: item.id,
|
||||
error: `${item.error ?? '交付结果不明'};恢复状态保存失败:${errorMessage(error)}`,
|
||||
reason: `${queue.reason ?? '已暂停自动发送'};恢复状态保存失败:${errorMessage(error)}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const queue of state.queues) {
|
||||
applyOverlay(ports.file, queue.sessionId, queue)
|
||||
lastKnownQueues.set(queueKey(ports.file, queue.sessionId), clone(queue))
|
||||
}
|
||||
if (recovered.size) {
|
||||
for (const sessionId of recovered) {
|
||||
const queue = state.queues.find(value => value.sessionId === sessionId)
|
||||
if (queue) {
|
||||
try { ports.changed(snapshot(queue)) } catch { /* Persistence is independent of windows. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
async function mutate<T>(sessionId: string, edit: (queue: StoredQueue) => T | Promise<T>): Promise<T> {
|
||||
return locked(async () => {
|
||||
const state = await load()
|
||||
const current = state.queues.find(queue => queue.sessionId === sessionId)
|
||||
const queue = clone(current ?? empty(sessionId))
|
||||
const before = JSON.stringify(queue)
|
||||
const result = await edit(queue)
|
||||
if (JSON.stringify(queue) === before) return clone(result)
|
||||
queue.revision++
|
||||
state.queues = [...state.queues.filter(value => value.sessionId !== sessionId), queue]
|
||||
await saveStore(ports.file, state)
|
||||
overlays.delete(queueKey(ports.file, sessionId))
|
||||
lastKnownQueues.set(queueKey(ports.file, sessionId), clone(queue))
|
||||
try { ports.changed(snapshot(queue)) } catch { /* Window lifetime does not affect persistence. */ }
|
||||
return clone(result)
|
||||
})
|
||||
}
|
||||
|
||||
function item(queue: StoredQueue, id: string): StoredQueuedPrompt {
|
||||
const found = queue.items.find(value => value.id === id)
|
||||
if (!found) throw new Error('追加消息不存在')
|
||||
if (found.status === 'delivering') throw new Error('消息正在交付,不能编辑或删除')
|
||||
if (found.status === 'editing') throw new Error('消息已在输入框中编辑')
|
||||
return found
|
||||
}
|
||||
|
||||
async function setUnknown(sessionId: string, itemId: string, reason: string, detail: string): Promise<void> {
|
||||
try {
|
||||
await mutate(sessionId, queue => {
|
||||
const found = queue.items.find(value => value.id === itemId)
|
||||
if (!found) return queue
|
||||
found.status = 'unknown'
|
||||
found.error = detail
|
||||
delete found.deliveryOwner
|
||||
delete found.editingUnknown
|
||||
queue.paused = true
|
||||
queue.reason = reason
|
||||
return queue
|
||||
})
|
||||
} catch (error) {
|
||||
const known = lastKnownQueues.get(queueKey(ports.file, sessionId))
|
||||
if (!known) return
|
||||
overlays.set(queueKey(ports.file, sessionId), {
|
||||
itemId,
|
||||
error: `${detail};暂停状态保存失败:${errorMessage(error)}`,
|
||||
reason: `${reason};暂停状态保存失败:${errorMessage(error)}`
|
||||
})
|
||||
const fallback = clone(known)
|
||||
applyOverlay(ports.file, sessionId, fallback)
|
||||
lastKnownQueues.set(queueKey(ports.file, sessionId), fallback)
|
||||
}
|
||||
}
|
||||
|
||||
const pump = async (sessionId: string): Promise<void> => {
|
||||
if (pumping.has(sessionId)) return
|
||||
pumping.add(sessionId)
|
||||
let reserved: StoredQueuedPrompt | undefined
|
||||
try {
|
||||
for (;;) {
|
||||
reserved = await mutate(sessionId, async queue => {
|
||||
if (queue.paused || !queue.items.length || await ports.blocked(sessionId)) return undefined
|
||||
const first = queue.items.find(item => item.status !== 'editing')
|
||||
if (!first) return undefined
|
||||
if (first.status !== 'pending') return undefined
|
||||
first.status = 'delivering'
|
||||
delete first.error
|
||||
first.deliveryOwner = {
|
||||
pid: process.pid,
|
||||
nonce: PROCESS_NONCE,
|
||||
hostname: LOCAL_HOST,
|
||||
startedAt: Date.now()
|
||||
}
|
||||
return clone(first)
|
||||
})
|
||||
if (!reserved) return
|
||||
let delivery: Delivery
|
||||
try {
|
||||
const dispatchPayload = ports.resolveDispatch
|
||||
? await ports.resolveDispatch(clone(reserved.payload), reserved.id)
|
||||
: reserved.payload
|
||||
if (dispatchPayload.sessionId !== sessionId) throw new Error('派发配置会话与队列会话不一致')
|
||||
delivery = await ports.deliver(dispatchPayload, reserved.id)
|
||||
} catch (error) {
|
||||
await setUnknown(sessionId, reserved.id, '交付未确认,已暂停自动发送', errorMessage(error))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await mutate(sessionId, queue => {
|
||||
queue.items = queue.items.filter(value => value.id !== reserved!.id)
|
||||
return queue
|
||||
})
|
||||
} catch (error) {
|
||||
await setUnknown(sessionId, reserved.id, '交付已开始,但队列清理失败,已暂停自动发送', errorMessage(error))
|
||||
return
|
||||
}
|
||||
reserved = undefined
|
||||
let result: Awaited<Delivery['completion']>
|
||||
try {
|
||||
result = await delivery.completion
|
||||
} catch (error) {
|
||||
try {
|
||||
await mutate(sessionId, queue => {
|
||||
queue.paused = true
|
||||
queue.reason = `运行状态读取失败:${errorMessage(error)}`
|
||||
return queue
|
||||
})
|
||||
} catch { /* The next explicit resume reports storage failure. */ }
|
||||
return
|
||||
}
|
||||
if (result.status !== 'done') {
|
||||
try {
|
||||
await mutate(sessionId, queue => {
|
||||
queue.paused = true
|
||||
queue.reason = '运行停止或异常,已暂停自动发送'
|
||||
return queue
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('追加队列暂停状态保存失败', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pumping.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
const notify = (sessionId: string): void => {
|
||||
void pump(sessionId).catch(error => { console.error('追加队列派发失败', error) })
|
||||
}
|
||||
return {
|
||||
get: (sessionId: string) => locked(async () => snapshot((await load()).queues.find(queue => queue.sessionId === sessionId) ?? empty(sessionId))),
|
||||
async enqueue(payload: SendPayload): Promise<PromptQueueSnapshot> {
|
||||
const incoming = clone(validateIncomingPayload(payload))
|
||||
const result = await mutate(incoming.sessionId, async queue => {
|
||||
if (queue.items.filter(item => item.status !== 'editing').length >= MAX_ITEMS) throw new Error('最多追加 19 条消息,请先处理队列')
|
||||
let insertAt = queue.items.length
|
||||
if (payload.queueEditId) {
|
||||
const editingIndex = queue.items.findIndex(item => item.id === payload.queueEditId && item.status === 'editing')
|
||||
if (editingIndex < 0) throw new Error('取回的追加消息已提交或不再处于编辑状态')
|
||||
insertAt = editingIndex
|
||||
queue.items = queue.items.filter(item => item.id !== payload.queueEditId)
|
||||
}
|
||||
await ports.validate(incoming)
|
||||
// Host enrichment (for example display labels) is also untrusted input
|
||||
// until it crosses this boundary again.
|
||||
const storedPayload = clone(validatePayload(incoming, incoming.sessionId))
|
||||
delete storedPayload.queueEditId
|
||||
// A stop can leave an empty queue paused. The next independent run starts
|
||||
// with a clean dispatch state; an existing queue keeps its explicit pause.
|
||||
if (queue.items.length === 0) {
|
||||
queue.paused = false
|
||||
delete queue.reason
|
||||
}
|
||||
const item = { id: randomUUID(), createdAt: Date.now(), status: 'pending' as const, payload: storedPayload }
|
||||
queue.items.splice(Math.min(insertAt, queue.items.length), 0, item)
|
||||
return queue
|
||||
})
|
||||
notify(incoming.sessionId)
|
||||
return snapshot(result)
|
||||
},
|
||||
take: async (sessionId: string, id: string): Promise<QueuedPrompt> => locked(async () => {
|
||||
const state = await load()
|
||||
const current = state.queues.find(queue => queue.sessionId === sessionId)
|
||||
const queue = clone(current ?? empty(sessionId))
|
||||
const found = item(queue, id)
|
||||
const taken = snapshot({ ...empty(sessionId), items: [found] }).items[0]
|
||||
queue.items = queue.items.filter(value => value.status !== 'editing')
|
||||
found.editingUnknown = found.status === 'unknown'
|
||||
found.status = 'editing'
|
||||
delete found.error
|
||||
found.deliveryOwner = { pid: process.pid, nonce: PROCESS_NONCE, hostname: LOCAL_HOST, startedAt: Date.now() }
|
||||
queue.revision++
|
||||
state.queues = [...state.queues.filter(value => value.sessionId !== sessionId), queue]
|
||||
await saveStore(ports.file, state)
|
||||
overlays.delete(queueKey(ports.file, sessionId))
|
||||
lastKnownQueues.set(queueKey(ports.file, sessionId), clone(queue))
|
||||
try { ports.changed(snapshot(queue)) } catch { /* Window lifetime does not affect persistence. */ }
|
||||
return { ...taken, payload: { ...taken.payload, queueEditId: id } }
|
||||
}),
|
||||
restoreEdit: async (sessionId: string, id: string): Promise<PromptQueueSnapshot> => mutate(sessionId, queue => {
|
||||
const found = queue.items.find(item => item.id === id && item.status === 'editing')
|
||||
if (!found) throw new Error('编辑取回记录已不存在')
|
||||
found.status = found.editingUnknown ? 'unknown' : 'pending'
|
||||
if (found.editingUnknown) found.error = '原消息交付结果不明,请核对历史后处理'
|
||||
delete found.editingUnknown; delete found.deliveryOwner
|
||||
queue.paused = true; queue.reason = '编辑取回未完成,原消息已保留,请确认后恢复'
|
||||
return queue
|
||||
}).then(snapshot),
|
||||
async assertEditing(sessionId: string, id: string): Promise<void> {
|
||||
await locked(async () => {
|
||||
if (!(await load()).queues.find(queue => queue.sessionId === sessionId)?.items.some(item => item.id === id && item.status === 'editing')) throw new Error('编辑取回记录已提交或不存在')
|
||||
})
|
||||
},
|
||||
async completeEdit(sessionId: string, id: string): Promise<void> {
|
||||
try {
|
||||
await mutate(sessionId, queue => { queue.items = queue.items.filter(item => item.id !== id || item.status !== 'editing') })
|
||||
} catch (error) {
|
||||
await setUnknown(sessionId, id, '编辑消息已提交,但取回记录清理失败,请核对历史', errorMessage(error))
|
||||
throw error
|
||||
}
|
||||
},
|
||||
remove: async (sessionId: string, id: string): Promise<PromptQueueSnapshot> => mutate(sessionId, queue => {
|
||||
item(queue, id)
|
||||
queue.items = queue.items.filter(value => value.id !== id)
|
||||
return queue
|
||||
}).then(snapshot),
|
||||
reorder: async (sessionId: string, ids: string[]): Promise<PromptQueueSnapshot> => mutate(sessionId, queue => {
|
||||
if (!Array.isArray(ids) || ids.length !== queue.items.filter(item => item.status !== 'editing').length || new Set(ids).size !== ids.length ||
|
||||
ids.some(id => !queue.items.some(value => value.id === id && value.status !== 'editing'))) throw new Error('追加消息排序无效')
|
||||
if (queue.items.some(value => value.status === 'delivering')) throw new Error('正在交付时不能排序')
|
||||
queue.items = [...ids.map(id => queue.items.find(value => value.id === id)!), ...queue.items.filter(item => item.status === 'editing')]
|
||||
return queue
|
||||
}).then(snapshot),
|
||||
async resume(sessionId: string): Promise<PromptQueueSnapshot> {
|
||||
const result = await mutate(sessionId, queue => {
|
||||
if (queue.items.some(value => value.status === 'unknown')) throw new Error('请先核对并编辑或删除交付结果不明的消息')
|
||||
queue.paused = false
|
||||
delete queue.reason
|
||||
return queue
|
||||
})
|
||||
notify(sessionId)
|
||||
return snapshot(result)
|
||||
},
|
||||
async clear(sessionId: string): Promise<PromptQueueSnapshot> {
|
||||
return locked(async () => {
|
||||
const state = await load()
|
||||
const existing = state.queues.find(queue => queue.sessionId === sessionId)
|
||||
if (!existing) return snapshot(empty(sessionId))
|
||||
if (existing.items.some(item => item.status === 'delivering')) throw new Error('存在正在交付的消息,不能清空队列')
|
||||
state.queues = state.queues.filter(queue => queue.sessionId !== sessionId)
|
||||
await saveStore(ports.file, state)
|
||||
overlays.delete(queueKey(ports.file, sessionId))
|
||||
lastKnownQueues.delete(queueKey(ports.file, sessionId))
|
||||
const cleared = { ...empty(sessionId), revision: existing.revision + 1 }
|
||||
try { ports.changed(snapshot(cleared)) } catch { /* Window lifetime does not affect persistence. */ }
|
||||
return snapshot(cleared)
|
||||
})
|
||||
},
|
||||
pause: (sessionId: string, reason: string): Promise<void> => mutate(sessionId, queue => {
|
||||
queue.paused = true
|
||||
queue.reason = reason
|
||||
return queue
|
||||
}).then(() => undefined),
|
||||
async sendNow(sessionId: string, id: string): Promise<PromptQueueSnapshot> {
|
||||
if (!ports.deliverNow) throw new Error('当前内核尚未接入追加结果归属,暂不能立即发送')
|
||||
const reserved = await mutate(sessionId, async queue => {
|
||||
const found = item(queue, id)
|
||||
if (found.status !== 'pending') throw new Error('交付结果不明的消息不能立即重试')
|
||||
await ports.validate(found.payload)
|
||||
found.status = 'delivering'
|
||||
delete found.error
|
||||
found.deliveryOwner = { pid: process.pid, nonce: PROCESS_NONCE, hostname: LOCAL_HOST, startedAt: Date.now() }
|
||||
return clone(found)
|
||||
})
|
||||
try {
|
||||
await ports.deliverNow(reserved.payload, reserved.id)
|
||||
} catch (error) {
|
||||
if ((error as { submitted?: boolean })?.submitted === false) {
|
||||
await mutate(sessionId, queue => {
|
||||
const found = queue.items.find(value => value.id === id)
|
||||
if (found) { found.status = 'pending'; found.error = errorMessage(error); delete found.deliveryOwner }
|
||||
queue.paused = true; queue.reason = '立即发送未提交,请确认后恢复队列'
|
||||
})
|
||||
} else await setUnknown(sessionId, id, '引导消息交付未确认,已暂停自动发送', errorMessage(error))
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
const result = await mutate(sessionId, queue => { queue.items = queue.items.filter(value => value.id !== id); return queue })
|
||||
notify(sessionId)
|
||||
return snapshot(result)
|
||||
} catch (error) {
|
||||
await setUnknown(sessionId, id, '消息已进入历史,但队列清理失败,请核对后删除', errorMessage(error))
|
||||
throw error
|
||||
}
|
||||
},
|
||||
notify
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export async function listSessions(): Promise<SessionMeta[]> {
|
||||
return [...store.sessions]
|
||||
// 置顶任务优先(最近置顶在前),其余按更新时间倒序。
|
||||
.sort((a, b) => Number(b.pinnedAt ?? 0) - Number(a.pinnedAt ?? 0) || b.updatedAt - a.updatedAt)
|
||||
.map(({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }) => ({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }))
|
||||
.map(({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, permissionMode, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }) => ({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, permissionMode, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }))
|
||||
}
|
||||
|
||||
/** 侧栏可见任务:默认排除已归档;`includeArchived` 供归档视图使用。 */
|
||||
@@ -124,8 +124,8 @@ export async function setSessionArchived(sessionId: string, archived: boolean):
|
||||
}
|
||||
|
||||
function meta(session: SessionRecord): SessionMeta {
|
||||
const { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, claudeSessionId, claudeForkAt, pinnedAt, archivedAt } = session
|
||||
return { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }
|
||||
const { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, permissionMode, claudeSessionId, claudeForkAt, pinnedAt, archivedAt } = session
|
||||
return { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, permissionMode, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }
|
||||
}
|
||||
|
||||
export async function createSession(title = '新对话', context: SessionContext = {}): Promise<SessionMeta> {
|
||||
@@ -141,7 +141,7 @@ export async function createSession(title = '新对话', context: SessionContext
|
||||
export async function getSessionContext(id: string): Promise<SessionContext> {
|
||||
const session = (await load()).sessions.find((item) => item.id === id)
|
||||
if (!session) throw new Error('会话不存在')
|
||||
return { workspaceId: session.workspaceId, projectId: session.projectId, cwd: session.cwd, modelId: session.modelId, expertId: session.expertId, agentMode: agentMode(session.agentMode), thinkingLevel: session.thinkingLevel }
|
||||
return { permissionMode: session.permissionMode, workspaceId: session.workspaceId, projectId: session.projectId, cwd: session.cwd, modelId: session.modelId, expertId: session.expertId, agentMode: agentMode(session.agentMode), thinkingLevel: session.thinkingLevel }
|
||||
}
|
||||
export async function updateSessionContext(id: string, patch: SessionContext): Promise<SessionMeta> {
|
||||
const session = (await load()).sessions.find((item) => item.id === id)
|
||||
@@ -152,8 +152,8 @@ export async function updateSessionContext(id: string, patch: SessionContext): P
|
||||
for (const key of Object.keys(session)) if (!Object.hasOwn(previous, key)) delete (session as unknown as Record<string, unknown>)[key]
|
||||
Object.assign(session, previous); throw error
|
||||
}
|
||||
const { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level } = session
|
||||
return { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level }
|
||||
const { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, permissionMode } = session
|
||||
return { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, permissionMode }
|
||||
}
|
||||
|
||||
export async function getMessages(sessionId: string): Promise<ChatMessage[]> {
|
||||
@@ -266,4 +266,4 @@ export async function startTurn(sessionId: string, user: ChatMessage, assistant:
|
||||
export function deriveTitle(text: string): string {
|
||||
const flat = text.replace(/\s+/g, ' ').trim()
|
||||
return flat.length > 24 ? `${flat.slice(0, 24)}…` : flat || '新对话'
|
||||
}
|
||||
}
|
||||
@@ -176,3 +176,7 @@ export function rebindTerminal(owner: BrowserWindow, id: string, previousSession
|
||||
export function disposeTerminalHosts(owner?: BrowserWindow): void {
|
||||
for (const entry of terminals.values()) if (!owner || entry.owner === owner) remove(entry)
|
||||
}
|
||||
|
||||
export function activeTerminalCount(): number {
|
||||
return [...terminals.values()].filter(entry => !entry.view.exited).length
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface UpdateLifecyclePorts {
|
||||
quiesce(): Promise<void>
|
||||
restore(): void
|
||||
shutdown(): Promise<void>
|
||||
exit(): void
|
||||
log(error: unknown): void
|
||||
prepareTimeoutMs?: number
|
||||
quitTimeoutMs?: number
|
||||
}
|
||||
|
||||
export function createUpdateLifecycle(ports: UpdateLifecyclePorts): {
|
||||
beforeInstall(): Promise<boolean>
|
||||
installFailed(): void
|
||||
quitForUpdate(): Promise<void>
|
||||
} {
|
||||
let preparation: Promise<void> | null = null
|
||||
let committed = false
|
||||
const restore = (): void => { if (!committed) ports.restore() }
|
||||
const installFailed = (): void => {
|
||||
// Stopping work is not cancellable. Do not resume underneath an old stop.
|
||||
if (preparation) void preparation.then(restore, restore)
|
||||
else restore()
|
||||
}
|
||||
return {
|
||||
async beforeInstall() {
|
||||
if (committed) throw new Error('安装器已启动,请退出应用完成更新')
|
||||
if (preparation) throw new Error('上次更新准备仍在结束中,请稍后重试')
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const pending = ports.quiesce()
|
||||
preparation = pending
|
||||
void pending.finally(() => { if (preparation === pending) preparation = null }).catch(() => undefined)
|
||||
try {
|
||||
await Promise.race([
|
||||
pending,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('任务清理尚未结束,暂不能开始新任务;请稍后重试或重启应用')), ports.prepareTimeoutMs ?? 30_000)
|
||||
})
|
||||
])
|
||||
return true
|
||||
} finally { clearTimeout(timer) }
|
||||
},
|
||||
installFailed,
|
||||
async quitForUpdate() {
|
||||
committed = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
await Promise.race([
|
||||
ports.shutdown(),
|
||||
new Promise<void>(resolve => { timer = setTimeout(resolve, ports.quitTimeoutMs ?? 15_000) })
|
||||
])
|
||||
} catch (error) {
|
||||
ports.log(error)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
ports.exit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
let paused = false
|
||||
const pending = new Set<Promise<unknown>>()
|
||||
|
||||
export function assertRuntimeAvailable(): void {
|
||||
if (paused) throw new Error('正在准备重启更新,请稍后重试')
|
||||
}
|
||||
|
||||
export function withRuntimeStart<T>(operation: () => Promise<T>): Promise<T> {
|
||||
assertRuntimeAvailable()
|
||||
const task = Promise.resolve().then(operation)
|
||||
pending.add(task)
|
||||
void task.finally(() => pending.delete(task)).catch(() => undefined)
|
||||
return task
|
||||
}
|
||||
|
||||
export async function pauseRuntimeStarts(): Promise<void> {
|
||||
paused = true
|
||||
await Promise.allSettled([...pending])
|
||||
}
|
||||
|
||||
export function resumeRuntimeStarts(): void { paused = false }
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { mkdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import { isAbsolute, relative, resolve } from 'node:path'
|
||||
import { parseVersion } from './semver'
|
||||
import { UpdateError, type CachedInstaller } from './types'
|
||||
|
||||
export function installerCachePaths(cacheDirectory: string, version: string): CachedInstaller {
|
||||
parseVersion(version)
|
||||
const filename = `YCode-${version}-Setup.exe`
|
||||
const path = resolve(cacheDirectory, filename)
|
||||
const relation = relative(resolve(cacheDirectory), path)
|
||||
if (!relation || relation.startsWith('..') || isAbsolute(relation)) {
|
||||
throw new UpdateError('unsafe-cache-path', '安装包缓存路径无效')
|
||||
}
|
||||
const partPath = `${path}.part`
|
||||
return { version, path, partPath, size: 0, sha256: '' }
|
||||
}
|
||||
|
||||
export async function sha256File(filename: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(filename)) hash.update(chunk)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
export async function verifyInstallerFile(
|
||||
installer: CachedInstaller,
|
||||
expected: { size: number; sha256: string }
|
||||
): Promise<void> {
|
||||
let info
|
||||
try {
|
||||
info = await stat(installer.path)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new UpdateError('cache-missing', '安装包尚未下载')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (!info.isFile() || info.size !== expected.size) {
|
||||
throw new UpdateError('cache-invalid', '安装包缓存大小不匹配')
|
||||
}
|
||||
if (await sha256File(installer.path) !== expected.sha256) {
|
||||
throw new UpdateError('cache-invalid', '安装包缓存 SHA-256 不匹配')
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareCacheDirectory(cacheDirectory: string): Promise<void> {
|
||||
await mkdir(cacheDirectory, { recursive: true })
|
||||
}
|
||||
|
||||
export async function commitPartFile(installer: CachedInstaller): Promise<void> {
|
||||
await rm(installer.path, { force: true })
|
||||
await rename(installer.partPath, installer.path)
|
||||
}
|
||||
|
||||
export async function removeCacheFiles(installer: CachedInstaller): Promise<void> {
|
||||
await rm(installer.path, { force: true })
|
||||
await rm(installer.partPath, { force: true })
|
||||
}
|
||||
|
||||
export function assertInstallerInsideCache(cacheDirectory: string, filename: string): void {
|
||||
const relation = relative(resolve(cacheDirectory), resolve(filename))
|
||||
if (!relation || relation.startsWith('..') || isAbsolute(relation)) {
|
||||
throw new UpdateError('unsafe-cache-path', '安装包路径不在更新缓存目录中')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { app, BrowserWindow, ipcMain, shell } from 'electron'
|
||||
import { UpdateManager } from './manager'
|
||||
import { createNsisInstaller } from './installer'
|
||||
import { registerUpdateIpc } from './ipc'
|
||||
import { physicalRealPath, physicalStat } from './physical-files'
|
||||
import { FileUpdatePersistence } from './storage'
|
||||
import { UPDATE_MANIFEST_URL } from './types'
|
||||
|
||||
export interface RegisterUpdatesOptions {
|
||||
beforeInstall: () => Promise<boolean>
|
||||
quitForUpdate: () => Promise<void>
|
||||
installFailed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface RegisteredUpdates {
|
||||
start(): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
function publicKeyPath(): string {
|
||||
return app.isPackaged
|
||||
? join(process.resourcesPath, 'updates', 'public-key.pem')
|
||||
: join(app.getAppPath(), 'resources', 'updates', 'public-key.pem')
|
||||
}
|
||||
|
||||
async function readPublicKey(): Promise<string> {
|
||||
const value = await readFile(publicKeyPath(), 'utf8')
|
||||
if (value.length > 64 * 1024) throw new Error('更新公钥文件过大')
|
||||
return value
|
||||
}
|
||||
|
||||
export function registerUpdates(options: RegisterUpdatesOptions): RegisteredUpdates {
|
||||
const userData = app.getPath('userData')
|
||||
const cacheDirectory = join(userData, 'updates', 'cache')
|
||||
const logFile = join(userData, 'updates', 'updater.log')
|
||||
const supported = app.isPackaged && process.platform === 'win32' && process.arch === 'x64'
|
||||
const installer = createNsisInstaller({
|
||||
platform: process.platform,
|
||||
isPackaged: app.isPackaged,
|
||||
execPath: process.execPath,
|
||||
appPath: app.getAppPath(),
|
||||
cacheDirectory,
|
||||
resolveRealPath: physicalRealPath,
|
||||
readStat: physicalStat
|
||||
})
|
||||
const manager = new UpdateManager({
|
||||
currentVersion: app.getVersion(),
|
||||
manifestUrl: UPDATE_MANIFEST_URL,
|
||||
cacheDirectory,
|
||||
logFile,
|
||||
persistence: new FileUpdatePersistence(join(userData, 'updates', 'state.json')),
|
||||
publicKey: readPublicKey,
|
||||
supported,
|
||||
installer,
|
||||
beforeInstall: options.beforeInstall,
|
||||
quitForUpdate: options.quitForUpdate,
|
||||
installFailed: options.installFailed
|
||||
})
|
||||
|
||||
const ipc = registerUpdateIpc({
|
||||
manager,
|
||||
ipcMain,
|
||||
shell,
|
||||
getAllWindows: () => BrowserWindow.getAllWindows(),
|
||||
appPath: app.getAppPath(),
|
||||
rendererUrl: process.env.ELECTRON_RENDERER_URL,
|
||||
platform: process.platform
|
||||
})
|
||||
|
||||
let started = false
|
||||
let disposed = false
|
||||
return {
|
||||
start: () => {
|
||||
if (started || disposed) return
|
||||
started = true
|
||||
manager.start()
|
||||
},
|
||||
dispose: () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
manager.dispose()
|
||||
ipc.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { UpdateManager } from './manager'
|
||||
export { isTrustedUpdateRendererUrl } from './trust'
|
||||
export { UPDATE_MANIFEST_URL } from './types'
|
||||
@@ -0,0 +1,205 @@
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { realpath, stat, statfs } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, parse, resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { assertInstallerInsideCache, verifyInstallerFile } from './cache'
|
||||
import { UpdateError, type CachedInstaller, type InstallEnvironment, type InstallerAdapter } from './types'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// Current YCode NSIS uninstall registration under HKCU.
|
||||
export const NSIS_UNINSTALL_KEY = 'Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\8a4ee941-d893-569b-8bcb-2a55d270c04b'
|
||||
|
||||
export interface SpawnedInstallerProcess {
|
||||
once(event: 'spawn', listener: () => void): this
|
||||
once(event: 'error', listener: (error: Error) => void): this
|
||||
unref(): void
|
||||
}
|
||||
|
||||
export type SpawnInstaller = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options: { cwd: string; detached: boolean; windowsHide: boolean; stdio: 'ignore' }
|
||||
) => SpawnedInstallerProcess
|
||||
|
||||
export interface NsisInstallerOptions {
|
||||
platform?: NodeJS.Platform
|
||||
isPackaged: boolean
|
||||
execPath: string
|
||||
appPath: string
|
||||
cacheDirectory: string
|
||||
processId?: number
|
||||
spawn?: SpawnInstaller
|
||||
queryRegistryValue?: (name: string) => Promise<string | null>
|
||||
verifyFile?: (installer: CachedInstaller) => Promise<void>
|
||||
resolveRealPath?: (value: string) => Promise<string>
|
||||
readStat?: (value: string) => Promise<{ isFile(): boolean }>
|
||||
readDiskStats?: (value: string) => Promise<{ bsize: number | bigint; bavail: number | bigint }>
|
||||
}
|
||||
|
||||
function parseRegistryValue(output: string, name: string): string | null {
|
||||
const expression = new RegExp(`^\\s*${name}\\s+REG_\\w+\\s+(.+?)\\s*$`, 'mi')
|
||||
const match = expression.exec(output)
|
||||
if (!match) return null
|
||||
return match[1].replace(/^"(.*)"$/, '$1')
|
||||
}
|
||||
|
||||
export async function queryNsisRegistryValue(name: string): Promise<string | null> {
|
||||
if (process.platform !== 'win32') return null
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'reg.exe',
|
||||
['query', `HKCU\\${NSIS_UNINSTALL_KEY}`, '/v', name],
|
||||
{ encoding: 'utf8', windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 }
|
||||
)
|
||||
return parseRegistryValue(stdout, name)
|
||||
} catch (error) {
|
||||
const code = (error as { code?: unknown }).code
|
||||
if (code === 1 || code === '1') return null
|
||||
throw new UpdateError('installer-registry', '无法读取 NSIS 安装注册信息')
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedWindowsPath(value: string): string {
|
||||
return resolve(value).replace(/[\\/]+$/, '').toLowerCase()
|
||||
}
|
||||
|
||||
function registryExecutablePath(value: string): string | null {
|
||||
const quoted = /^\s*"([^"]+\.exe)"/i.exec(value)
|
||||
if (quoted) return quoted[1]
|
||||
const plain = /^\s*(.+?\.exe)(?:\s|$|,)/i.exec(value)
|
||||
return plain ? plain[1] : null
|
||||
}
|
||||
|
||||
export function createNsisInstaller(options: NsisInstallerOptions): InstallerAdapter {
|
||||
const platform = options.platform ?? process.platform
|
||||
const queryRegistryValue = options.queryRegistryValue ?? queryNsisRegistryValue
|
||||
const spawnInstaller: SpawnInstaller = options.spawn ?? ((command, args, spawnOptions) => spawn(
|
||||
command,
|
||||
[...args],
|
||||
spawnOptions
|
||||
))
|
||||
const verifyFile = options.verifyFile
|
||||
?? ((installer: CachedInstaller) => verifyInstallerFile(installer, {
|
||||
size: installer.size,
|
||||
sha256: installer.sha256
|
||||
}))
|
||||
const resolveRealPath = options.resolveRealPath ?? realpath
|
||||
const readStat = options.readStat ?? stat
|
||||
const readDiskStats = options.readDiskStats ?? statfs
|
||||
|
||||
async function validateEnvironment(requiredBytes = 0): Promise<InstallEnvironment> {
|
||||
if (platform !== 'win32') throw new UpdateError('unsupported', '自动更新仅支持 Windows')
|
||||
if (!options.isPackaged) throw new UpdateError('development-build', '开发模式禁止启动安装器')
|
||||
|
||||
let executablePath: string
|
||||
let appPath: string
|
||||
try {
|
||||
executablePath = await resolveRealPath(options.execPath)
|
||||
appPath = await resolveRealPath(options.appPath)
|
||||
} catch {
|
||||
throw new UpdateError('installer-environment', '无法解析当前安装位置')
|
||||
}
|
||||
if (basename(executablePath).toLowerCase() !== 'ycode.exe') {
|
||||
throw new UpdateError('installer-environment', '当前进程不是 YCode 安装版')
|
||||
}
|
||||
const installationDirectory = dirname(executablePath)
|
||||
const root = parse(installationDirectory).root
|
||||
if (normalizedWindowsPath(installationDirectory) === normalizedWindowsPath(root)) {
|
||||
throw new UpdateError('installer-environment', '安装目录不安全')
|
||||
}
|
||||
if (resolve(appPath) !== resolve(installationDirectory, 'resources', 'app.asar')) {
|
||||
throw new UpdateError('installer-environment', '应用资源目录与安装位置不一致')
|
||||
}
|
||||
|
||||
const uninstallString = await queryRegistryValue('UninstallString')
|
||||
const uninstallerPath = uninstallString ? registryExecutablePath(uninstallString) : null
|
||||
if (!uninstallerPath
|
||||
|| normalizedWindowsPath(dirname(uninstallerPath)) !== normalizedWindowsPath(installationDirectory)
|
||||
|| basename(uninstallerPath).toLowerCase() !== 'uninstall ycode.exe') {
|
||||
throw new UpdateError('installer-registry', '当前用户 NSIS 卸载注册信息不匹配')
|
||||
}
|
||||
const displayIcon = await queryRegistryValue('DisplayIcon')
|
||||
const iconPath = displayIcon ? registryExecutablePath(displayIcon.replace(/,\s*-?\d+\s*$/, '')) : null
|
||||
if (!iconPath
|
||||
|| normalizedWindowsPath(dirname(iconPath)) !== normalizedWindowsPath(installationDirectory)
|
||||
|| normalizedWindowsPath(iconPath) !== normalizedWindowsPath(executablePath)) {
|
||||
throw new UpdateError('installer-registry', '当前用户 YCode 图标注册信息不匹配')
|
||||
}
|
||||
|
||||
for (const marker of [
|
||||
uninstallerPath,
|
||||
executablePath,
|
||||
resolve(installationDirectory, 'resources', 'app.asar')
|
||||
]) {
|
||||
let info
|
||||
try {
|
||||
info = await readStat(marker)
|
||||
} catch {
|
||||
throw new UpdateError('installer-marker', '缺少 NSIS 安装标记文件')
|
||||
}
|
||||
if (!info.isFile()) {
|
||||
throw new UpdateError('installer-marker', `NSIS 安装标记不是文件:${basename(marker)}`)
|
||||
}
|
||||
}
|
||||
if (requiredBytes > 0) {
|
||||
const required = BigInt(Math.ceil(requiredBytes)) * 3n
|
||||
for (const directory of new Set([options.cacheDirectory, installationDirectory, tmpdir()])) {
|
||||
let disk
|
||||
try {
|
||||
disk = await readDiskStats(directory)
|
||||
} catch {
|
||||
throw new UpdateError('installer-space-check', '无法检查更新所需磁盘空间')
|
||||
}
|
||||
const available = BigInt(disk.bsize) * BigInt(disk.bavail)
|
||||
if (available < required) {
|
||||
throw new UpdateError('installer-space-check', '更新缓存、安装目录或临时目录磁盘空间不足')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installationDirectory,
|
||||
executablePath,
|
||||
registryKey: `HKCU\\${NSIS_UNINSTALL_KEY}`
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyInstaller(installer: CachedInstaller): Promise<void> {
|
||||
assertInstallerInsideCache(options.cacheDirectory, installer.path)
|
||||
await verifyFile(installer)
|
||||
}
|
||||
|
||||
async function launch(installer: CachedInstaller, environment: InstallEnvironment): Promise<void> {
|
||||
await verifyInstaller(installer)
|
||||
const args = [
|
||||
'/S',
|
||||
'--updated',
|
||||
'--force-run',
|
||||
'/currentuser',
|
||||
`/YCODEWAITPID=${options.processId ?? process.pid}`,
|
||||
`/D=${environment.installationDirectory}`
|
||||
]
|
||||
let child: SpawnedInstallerProcess
|
||||
try {
|
||||
child = spawnInstaller(installer.path, args, {
|
||||
cwd: environment.installationDirectory,
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
} catch {
|
||||
throw new UpdateError('installer-launch', '无法启动更新安装器')
|
||||
}
|
||||
await new Promise<void>((resolveSpawn, rejectSpawn) => {
|
||||
child.once('spawn', resolveSpawn)
|
||||
child.once('error', rejectSpawn)
|
||||
}).catch(() => {
|
||||
throw new UpdateError('installer-launch', '无法启动更新安装器')
|
||||
})
|
||||
child.unref()
|
||||
}
|
||||
|
||||
return { validateEnvironment, verifyInstaller, launch }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { BrowserWindow, type IpcMainInvokeEvent, type Shell } from 'electron'
|
||||
import type { UpdateState } from '../../shared/updates'
|
||||
import { releasePageUrl } from './manifest'
|
||||
import type { UpdateManager } from './manager'
|
||||
import { isTrustedUpdateRendererUrl, type TrustedRendererOptions } from './trust'
|
||||
|
||||
const HANDLER_CHANNELS = [
|
||||
'updates:get-state',
|
||||
'updates:check',
|
||||
'updates:download',
|
||||
'updates:install',
|
||||
'updates:dismiss',
|
||||
'updates:open-download-page',
|
||||
'updates:open-log'
|
||||
] as const
|
||||
|
||||
function isTrustedSender(event: IpcMainInvokeEvent, options: TrustedRendererOptions): boolean {
|
||||
if (event.senderFrame !== event.sender.mainFrame || event.sender.isDestroyed()) return false
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window || window.isDestroyed() || window.webContents !== event.sender) return false
|
||||
return isTrustedUpdateRendererUrl(event.sender.getURL(), options)
|
||||
}
|
||||
|
||||
export interface RegisterUpdateIpcOptions extends TrustedRendererOptions {
|
||||
manager: UpdateManager
|
||||
ipcMain: Pick<Electron.IpcMain, 'handle' | 'removeHandler'>
|
||||
shell: Pick<Shell, 'openExternal' | 'openPath'>
|
||||
getAllWindows: () => BrowserWindow[]
|
||||
}
|
||||
|
||||
function handle(
|
||||
options: RegisterUpdateIpcOptions,
|
||||
channel: typeof HANDLER_CHANNELS[number],
|
||||
callback: (...args: unknown[]) => Promise<unknown> | unknown
|
||||
): void {
|
||||
options.ipcMain.handle(channel, async (event, ...args) => {
|
||||
if (!isTrustedSender(event, options)) throw new Error('更新请求来源不受信任')
|
||||
return callback(...args)
|
||||
})
|
||||
}
|
||||
|
||||
function safeExternalUrl(value: string): string {
|
||||
const url = new URL(value)
|
||||
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) {
|
||||
throw new Error('更新页面地址不受信任')
|
||||
}
|
||||
return url.href
|
||||
}
|
||||
|
||||
export function registerUpdateIpc(options: RegisterUpdateIpcOptions): { dispose(): void } {
|
||||
for (const channel of HANDLER_CHANNELS) {
|
||||
switch (channel) {
|
||||
case 'updates:get-state':
|
||||
handle(options, channel, () => options.manager.getState())
|
||||
break
|
||||
case 'updates:check':
|
||||
handle(options, channel, () => options.manager.checkAndDownload(true))
|
||||
break
|
||||
case 'updates:download':
|
||||
handle(options, channel, () => options.manager.download())
|
||||
break
|
||||
case 'updates:install':
|
||||
handle(options, channel, () => options.manager.install())
|
||||
break
|
||||
case 'updates:dismiss':
|
||||
handle(options, channel, () => options.manager.dismiss())
|
||||
break
|
||||
case 'updates:open-download-page':
|
||||
handle(options, channel, async () => {
|
||||
await options.shell.openExternal(safeExternalUrl(releasePageUrl(options.manager.manifestUrl)))
|
||||
})
|
||||
break
|
||||
case 'updates:open-log':
|
||||
handle(options, channel, async () => {
|
||||
await options.manager.ensureLogFile()
|
||||
const failure = await options.shell.openPath(options.manager.logPath)
|
||||
if (failure) throw new Error(`无法打开更新日志:${failure}`)
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = options.manager.subscribe((state: UpdateState) => {
|
||||
for (const window of options.getAllWindows()) {
|
||||
try {
|
||||
if (window.isDestroyed() || !isTrustedUpdateRendererUrl(window.webContents.getURL(), options)) continue
|
||||
window.webContents.send('updates:state', state)
|
||||
} catch {
|
||||
// A window can close while the updater is broadcasting.
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
unsubscribe()
|
||||
for (const channel of HANDLER_CHANNELS) options.ipcMain.removeHandler(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { isTrustedUpdateRendererUrl }
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { appendFile, mkdir } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
function sanitizeUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
url.username = ''
|
||||
url.password = ''
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
} catch {
|
||||
return '<invalid-url>'
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeLogText(value: string): string {
|
||||
return value
|
||||
.replace(/https?:\/\/[^\s"'<>]+/gi, match => sanitizeUrl(match))
|
||||
.replace(/\b(authorization|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=<redacted>')
|
||||
.slice(0, 16_384)
|
||||
}
|
||||
|
||||
export class UpdateLogger {
|
||||
private queue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(readonly filePath: string) {}
|
||||
|
||||
info(message: string): void {
|
||||
this.write('INFO', message)
|
||||
}
|
||||
|
||||
error(message: string, error?: unknown): void {
|
||||
const detail = error instanceof Error ? error.message : error === undefined ? '' : String(error)
|
||||
this.write('ERROR', detail ? `${message}: ${detail}` : message)
|
||||
}
|
||||
|
||||
async ensureFile(): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true })
|
||||
await appendFile(this.filePath, '', { encoding: 'utf8', mode: 0o600 })
|
||||
}
|
||||
|
||||
private write(level: string, message: string): void {
|
||||
const line = `${new Date().toISOString()} ${level} ${sanitizeLogText(message)}\n`
|
||||
this.queue = this.queue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await mkdir(dirname(this.filePath), { recursive: true })
|
||||
await appendFile(this.filePath, line, { encoding: 'utf8', mode: 0o600 })
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
import { open, rm, stat } from 'node:fs/promises'
|
||||
import type { UpdateState } from '../../shared/updates'
|
||||
import {
|
||||
commitPartFile,
|
||||
installerCachePaths,
|
||||
prepareCacheDirectory,
|
||||
removeCacheFiles,
|
||||
sha256File,
|
||||
verifyInstallerFile
|
||||
} from './cache'
|
||||
import { UpdateLogger } from './logger'
|
||||
import { normalizeManifestUrl, verifySignedManifest } from './manifest'
|
||||
import {
|
||||
createRequestDeadline,
|
||||
fetchManifestBytes,
|
||||
fetchSameOrigin,
|
||||
parseContentRange,
|
||||
responseContentLength
|
||||
} from './network'
|
||||
import { compareVersions, parseVersion } from './semver'
|
||||
import {
|
||||
DEFAULT_CHECK_DELAY_MS,
|
||||
DEFAULT_CHECK_INTERVAL_MS,
|
||||
DEFAULT_DOWNLOAD_TIMEOUT_MS,
|
||||
DEFAULT_MANIFEST_TIMEOUT_MS,
|
||||
DEFAULT_UP_TO_DATE_RESET_MS,
|
||||
StaleReleaseError,
|
||||
UpdateCancelledError,
|
||||
UpdateError,
|
||||
type CachedInstaller,
|
||||
type FetchLike,
|
||||
type InstallEnvironment,
|
||||
type InstallerAdapter,
|
||||
type PersistedUpdateState,
|
||||
type ReleaseInfo,
|
||||
type StateListener,
|
||||
type UpdatePersistence
|
||||
} from './types'
|
||||
|
||||
export interface UpdateManagerOptions {
|
||||
currentVersion: string
|
||||
manifestUrl: string
|
||||
cacheDirectory: string
|
||||
logFile: string
|
||||
persistence: UpdatePersistence
|
||||
publicKey: () => Promise<string>
|
||||
supported: boolean
|
||||
installer: InstallerAdapter
|
||||
beforeInstall: () => Promise<boolean>
|
||||
quitForUpdate: () => Promise<void>
|
||||
installFailed?: () => void | Promise<void>
|
||||
fetch?: FetchLike
|
||||
now?: () => Date
|
||||
checkDelayMs?: number
|
||||
checkIntervalMs?: number
|
||||
upToDateResetMs?: number
|
||||
manifestTimeoutMs?: number
|
||||
downloadTimeoutMs?: number
|
||||
autoDownload?: boolean
|
||||
}
|
||||
|
||||
function emptyPersisted(): PersistedUpdateState {
|
||||
return { schemaVersion: 1 }
|
||||
}
|
||||
|
||||
function cloneState(state: UpdateState): UpdateState {
|
||||
return { ...state }
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof UpdateError && error.message) return error.message
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
return '更新操作失败'
|
||||
}
|
||||
|
||||
function identity(release: ReleaseInfo): string {
|
||||
return [
|
||||
release.payload.version,
|
||||
release.asset.url,
|
||||
release.asset.size,
|
||||
release.asset.sha256
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export class UpdateManager {
|
||||
private readonly logger: UpdateLogger
|
||||
private readonly fetchImpl: FetchLike
|
||||
private readonly now: () => Date
|
||||
private persisted: PersistedUpdateState = emptyPersisted()
|
||||
private state: UpdateState
|
||||
private release: ReleaseInfo | null = null
|
||||
private generation = 0
|
||||
private initialized: Promise<void> | null = null
|
||||
private checkPromise: Promise<UpdateState> | null = null
|
||||
private downloadPromise: Promise<UpdateState> | null = null
|
||||
private installPromise: Promise<UpdateState> | null = null
|
||||
private downloadAbort: AbortController | null = null
|
||||
private readonly disposedAbort = new AbortController()
|
||||
private disposed = false
|
||||
private started = false
|
||||
private explicitRequested = false
|
||||
private installCommitted = false
|
||||
private checkDelayTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private checkIntervalTimer: ReturnType<typeof setInterval> | null = null
|
||||
private upToDateTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private saveQueue: Promise<void> = Promise.resolve()
|
||||
private readonly listeners = new Set<StateListener>()
|
||||
|
||||
constructor(private readonly options: UpdateManagerOptions) {
|
||||
this.logger = new UpdateLogger(options.logFile)
|
||||
this.fetchImpl = options.fetch ?? fetch
|
||||
this.now = options.now ?? (() => new Date())
|
||||
this.state = {
|
||||
status: options.supported ? 'idle' : 'unsupported',
|
||||
currentVersion: options.currentVersion
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: StateListener): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
get manifestUrl(): string {
|
||||
return this.options.manifestUrl
|
||||
}
|
||||
|
||||
get logPath(): string {
|
||||
return this.options.logFile
|
||||
}
|
||||
|
||||
async ensureLogFile(): Promise<void> {
|
||||
await this.logger.ensureFile()
|
||||
}
|
||||
|
||||
async getState(): Promise<UpdateState> {
|
||||
await this.ensureInitialized()
|
||||
return cloneState(this.state)
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.started || this.disposed || !this.options.supported) return
|
||||
this.started = true
|
||||
void this.ensureInitialized().catch(error => this.logger.error('初始化更新模块失败', error))
|
||||
this.checkDelayTimer = setTimeout(() => {
|
||||
this.checkDelayTimer = null
|
||||
void this.checkAndDownload(false).catch(error => this.logger.error('后台检查更新失败', error))
|
||||
}, this.options.checkDelayMs ?? DEFAULT_CHECK_DELAY_MS)
|
||||
this.checkDelayTimer.unref?.()
|
||||
this.checkIntervalTimer = setInterval(() => {
|
||||
void this.checkAndDownload(false).catch(error => this.logger.error('后台检查更新失败', error))
|
||||
}, this.options.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS)
|
||||
this.checkIntervalTimer.unref?.()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.started = false
|
||||
if (this.checkDelayTimer) clearTimeout(this.checkDelayTimer)
|
||||
if (this.checkIntervalTimer) clearInterval(this.checkIntervalTimer)
|
||||
if (this.upToDateTimer) clearTimeout(this.upToDateTimer)
|
||||
this.checkDelayTimer = null
|
||||
this.checkIntervalTimer = null
|
||||
this.upToDateTimer = null
|
||||
this.disposedAbort.abort()
|
||||
this.downloadAbort?.abort()
|
||||
this.listeners.clear()
|
||||
}
|
||||
|
||||
async check(explicit = true): Promise<UpdateState> {
|
||||
await this.ensureInitialized()
|
||||
if (this.disposed || this.installCommitted) return cloneState(this.state)
|
||||
if (this.installPromise) return cloneState(this.state)
|
||||
if (this.checkPromise) {
|
||||
if (explicit) this.setExplicit(true)
|
||||
return this.checkPromise
|
||||
}
|
||||
if (!this.downloadPromise || explicit) this.setExplicit(explicit)
|
||||
if (!this.options.supported) return cloneState(this.state)
|
||||
|
||||
const task = this.runCheck()
|
||||
this.checkPromise = task
|
||||
try {
|
||||
return await task
|
||||
} finally {
|
||||
if (this.checkPromise === task) this.checkPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
async checkAndDownload(explicit = true): Promise<UpdateState> {
|
||||
const checked = await this.check(explicit)
|
||||
if (!this.options.supported || this.options.autoDownload === false || this.disposed) return checked
|
||||
if (checked.status !== 'available' || !checked.version) return checked
|
||||
if (this.persisted.dismissedVersion === checked.version) return checked
|
||||
void this.beginDownload(explicit).catch(error => this.logger.error('自动下载更新失败', error))
|
||||
return cloneState(this.state)
|
||||
}
|
||||
|
||||
async download(explicit = true): Promise<UpdateState> {
|
||||
await this.ensureInitialized()
|
||||
if (this.disposed || this.installCommitted) return cloneState(this.state)
|
||||
this.setExplicit(explicit)
|
||||
if (!this.options.supported) return cloneState(this.state)
|
||||
if (this.installPromise) return cloneState(this.state)
|
||||
return this.beginDownload(explicit)
|
||||
}
|
||||
|
||||
private beginDownload(explicit: boolean): Promise<UpdateState> {
|
||||
if (this.installCommitted) return Promise.resolve(cloneState(this.state))
|
||||
this.setExplicit(explicit)
|
||||
if (this.downloadPromise) return this.downloadPromise
|
||||
|
||||
const task = (async (): Promise<UpdateState> => {
|
||||
if (!this.release) {
|
||||
const checked = await this.check(explicit)
|
||||
if (!this.release || checked.status === 'unsupported' || checked.status === 'up-to-date'
|
||||
|| checked.status === 'error') return checked
|
||||
}
|
||||
const release = this.release
|
||||
if (!release) return cloneState(this.state)
|
||||
return this.runDownload(release)
|
||||
})()
|
||||
this.downloadPromise = task
|
||||
return task.finally(() => {
|
||||
if (this.downloadPromise === task) this.downloadPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
async install(): Promise<UpdateState> {
|
||||
await this.ensureInitialized()
|
||||
if (this.disposed || this.installCommitted) return cloneState(this.state)
|
||||
this.setExplicit(true)
|
||||
if (!this.options.supported) return cloneState(this.state)
|
||||
if (this.installPromise) return this.installPromise
|
||||
|
||||
const task = this.runInstall()
|
||||
this.installPromise = task
|
||||
try {
|
||||
return await task
|
||||
} finally {
|
||||
if (this.installPromise === task) this.installPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
async dismiss(): Promise<UpdateState> {
|
||||
await this.ensureInitialized()
|
||||
if (this.disposed || this.installCommitted || this.installPromise || this.state.status === 'installing') {
|
||||
return cloneState(this.state)
|
||||
}
|
||||
this.setExplicit(true)
|
||||
const version = this.state.version ?? this.release?.payload.version
|
||||
if (!version) return cloneState(this.state)
|
||||
this.downloadAbort?.abort()
|
||||
this.persisted = { ...this.persisted, dismissedVersion: version }
|
||||
await this.queuePersist(this.persisted).catch(error => this.logger.error('保存忽略版本失败', error))
|
||||
this.setState({
|
||||
status: 'idle',
|
||||
currentVersion: this.options.currentVersion,
|
||||
version,
|
||||
notes: this.state.notes,
|
||||
lastChecked: this.state.lastChecked,
|
||||
dismissedVersion: version,
|
||||
unfinishedVersion: this.persisted.unfinishedVersion
|
||||
})
|
||||
return cloneState(this.state)
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (!this.initialized) this.initialized = this.initialize()
|
||||
return this.initialized
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
let persisted = emptyPersisted()
|
||||
try {
|
||||
persisted = await this.options.persistence.load()
|
||||
} catch (error) {
|
||||
this.logger.error('读取更新状态失败,已使用默认状态', error)
|
||||
}
|
||||
this.persisted = persisted
|
||||
|
||||
if (!this.options.supported) {
|
||||
this.setState({ status: 'unsupported', currentVersion: this.options.currentVersion })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
parseVersion(this.options.currentVersion)
|
||||
} catch (error) {
|
||||
this.logger.error('当前应用版本无效', error)
|
||||
this.setState({
|
||||
status: 'error',
|
||||
currentVersion: this.options.currentVersion,
|
||||
error: '当前应用版本号无效'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let changed = false
|
||||
if (persisted.dismissedVersion) {
|
||||
try {
|
||||
if (compareVersions(persisted.dismissedVersion, this.options.currentVersion) <= 0) {
|
||||
persisted = { ...persisted, dismissedVersion: undefined }
|
||||
changed = true
|
||||
}
|
||||
} catch {
|
||||
persisted = { ...persisted, dismissedVersion: undefined }
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (persisted.target) {
|
||||
try {
|
||||
if (compareVersions(persisted.target.version, this.options.currentVersion) <= 0) {
|
||||
const { target: _target, unfinishedVersion: _unfinished, ...rest } = persisted
|
||||
persisted = rest
|
||||
changed = true
|
||||
}
|
||||
} catch {
|
||||
const { target: _target, unfinishedVersion: _unfinished, ...rest } = persisted
|
||||
persisted = rest
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
this.persisted = persisted
|
||||
if (changed) await this.queuePersist(persisted).catch(error => this.logger.error('清理过期更新状态失败', error))
|
||||
|
||||
this.setState({
|
||||
status: 'idle',
|
||||
currentVersion: this.options.currentVersion,
|
||||
dismissedVersion: persisted.dismissedVersion,
|
||||
unfinishedVersion: persisted.unfinishedVersion
|
||||
})
|
||||
}
|
||||
|
||||
private async runCheck(): Promise<UpdateState> {
|
||||
this.clearUpToDateTimer()
|
||||
const preservedState = this.downloadPromise || this.state.status === 'ready'
|
||||
? cloneState(this.state)
|
||||
: null
|
||||
if (!preservedState) {
|
||||
this.setState({
|
||||
status: 'checking',
|
||||
currentVersion: this.options.currentVersion,
|
||||
dismissedVersion: this.persisted.dismissedVersion,
|
||||
unfinishedVersion: this.persisted.unfinishedVersion,
|
||||
lastChecked: this.state.lastChecked
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const publicKeyPem = await this.options.publicKey()
|
||||
const bytes = await fetchManifestBytes(this.options.manifestUrl, {
|
||||
fetch: this.fetchImpl,
|
||||
signal: this.disposedAbort.signal,
|
||||
timeout: this.options.manifestTimeoutMs ?? DEFAULT_MANIFEST_TIMEOUT_MS
|
||||
})
|
||||
const verified = verifySignedManifest(bytes, {
|
||||
manifestUrl: this.options.manifestUrl,
|
||||
publicKeyPem
|
||||
})
|
||||
const downloadingSameRelease = Boolean(
|
||||
this.downloadPromise
|
||||
&& this.release
|
||||
&& identity(this.release) === identity({ ...verified, generation: this.release.generation })
|
||||
)
|
||||
|
||||
if (downloadingSameRelease && this.release) {
|
||||
this.release = { ...verified, generation: this.release.generation }
|
||||
const lastChecked = this.now().toISOString()
|
||||
this.setState({
|
||||
...this.state,
|
||||
lastChecked
|
||||
})
|
||||
return cloneState(this.state)
|
||||
}
|
||||
|
||||
if (this.downloadPromise) this.downloadAbort?.abort()
|
||||
this.generation += 1
|
||||
this.release = { ...verified, generation: this.generation }
|
||||
const lastChecked = this.now().toISOString()
|
||||
const comparison = compareVersions(verified.payload.version, this.options.currentVersion)
|
||||
if (comparison <= 0) {
|
||||
this.setState({
|
||||
status: 'up-to-date',
|
||||
currentVersion: this.options.currentVersion,
|
||||
lastChecked,
|
||||
dismissedVersion: this.persisted.dismissedVersion,
|
||||
unfinishedVersion: this.persisted.unfinishedVersion
|
||||
})
|
||||
this.scheduleUpToDateReset()
|
||||
this.logger.info(`检查完成,当前已是最新版本 ${this.options.currentVersion}`)
|
||||
return cloneState(this.state)
|
||||
}
|
||||
|
||||
const cached = this.cachedInstaller(this.release)
|
||||
let ready = false
|
||||
try {
|
||||
await verifyInstallerFile(cached, this.release.asset)
|
||||
ready = true
|
||||
} catch (error) {
|
||||
if (error instanceof UpdateError && error.code === 'cache-invalid') {
|
||||
await removeCacheFiles(cached).catch(() => undefined)
|
||||
} else if (!(error instanceof UpdateError && error.code === 'cache-missing')) {
|
||||
this.logger.error('校验已有更新缓存失败', error)
|
||||
}
|
||||
}
|
||||
if (!this.release || cached.sha256 !== this.release.asset.sha256) return cloneState(this.state)
|
||||
this.setState({
|
||||
status: ready ? 'ready' : 'available',
|
||||
currentVersion: this.options.currentVersion,
|
||||
version: this.release.payload.version,
|
||||
notes: this.release.payload.notes.zh || this.release.payload.notes.en,
|
||||
...(ready ? { progress: 100 } : {}),
|
||||
lastChecked,
|
||||
dismissedVersion: this.persisted.dismissedVersion,
|
||||
unfinishedVersion: this.persisted.unfinishedVersion
|
||||
})
|
||||
this.logger.info(`发现更新 ${this.release.payload.version}`)
|
||||
return cloneState(this.state)
|
||||
} catch (error) {
|
||||
if (this.disposed || error instanceof UpdateCancelledError) return cloneState(this.state)
|
||||
const usableState = this.state.status === 'ready' || this.state.status === 'downloading'
|
||||
? this.state
|
||||
: preservedState
|
||||
if (usableState) {
|
||||
this.logger.error('检查更新失败,保留已校验安装包或当前下载', error)
|
||||
this.setState({ ...usableState, lastChecked: this.now().toISOString() })
|
||||
return cloneState(this.state)
|
||||
}
|
||||
const message = errorMessage(error)
|
||||
this.logger.error('检查更新失败', error)
|
||||
this.setState({
|
||||
status: 'error',
|
||||
currentVersion: this.options.currentVersion,
|
||||
error: message,
|
||||
lastChecked: this.now().toISOString(),
|
||||
dismissedVersion: this.persisted.dismissedVersion,
|
||||
unfinishedVersion: this.persisted.unfinishedVersion
|
||||
})
|
||||
return cloneState(this.state)
|
||||
}
|
||||
}
|
||||
|
||||
private async runDownload(release: ReleaseInfo): Promise<UpdateState> {
|
||||
const cached = this.cachedInstaller(release)
|
||||
this.assertCurrentRelease(release)
|
||||
this.clearUpToDateTimer()
|
||||
this.setState({
|
||||
status: 'downloading',
|
||||
currentVersion: this.options.currentVersion,
|
||||
version: release.payload.version,
|
||||
notes: release.payload.notes.zh || release.payload.notes.en,
|
||||
progress: 0,
|
||||
lastChecked: this.state.lastChecked,
|
||||
dismissedVersion: this.persisted.dismissedVersion,
|
||||
unfinishedVersion: this.persisted.unfinishedVersion
|
||||
})
|
||||
|
||||
const abort = new AbortController()
|
||||
this.downloadAbort = abort
|
||||
let handle: Awaited<ReturnType<typeof open>> | null = null
|
||||
try {
|
||||
await prepareCacheDirectory(this.options.cacheDirectory)
|
||||
try {
|
||||
await verifyInstallerFile(cached, release.asset)
|
||||
this.assertCurrentRelease(release)
|
||||
this.setState({ ...this.state, status: 'ready', progress: 100 })
|
||||
return cloneState(this.state)
|
||||
} catch (error) {
|
||||
if (error instanceof UpdateError && error.code === 'cache-invalid') {
|
||||
await rm(cached.path, { force: true })
|
||||
} else if (!(error instanceof UpdateError && error.code === 'cache-missing')) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
let start = 0
|
||||
try {
|
||||
const info = await stat(cached.partPath)
|
||||
if (!info.isFile()) await rm(cached.partPath, { force: true })
|
||||
else start = info.size
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
if (start > release.asset.size) {
|
||||
await rm(cached.partPath, { force: true })
|
||||
start = 0
|
||||
}
|
||||
if (start === release.asset.size) {
|
||||
if (await sha256File(cached.partPath) === release.asset.sha256) {
|
||||
await commitPartFile(cached)
|
||||
this.assertCurrentRelease(release)
|
||||
this.setState({ ...this.state, status: 'ready', progress: 100 })
|
||||
return cloneState(this.state)
|
||||
}
|
||||
await rm(cached.partPath, { force: true })
|
||||
start = 0
|
||||
}
|
||||
if (start > 0) this.setState({ ...this.state, progress: Math.floor(start / release.asset.size * 100) })
|
||||
|
||||
const deadline = createRequestDeadline(
|
||||
abort.signal,
|
||||
this.options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS
|
||||
)
|
||||
try {
|
||||
const { response } = await fetchSameOrigin(release.asset.url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
accept: 'application/octet-stream',
|
||||
...(start > 0 ? { range: `bytes=${start}-` } : {})
|
||||
},
|
||||
signal: deadline.signal,
|
||||
cache: 'no-store'
|
||||
}, {
|
||||
fetch: this.fetchImpl,
|
||||
allowedOrigin: new URL(this.options.manifestUrl).origin
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new UpdateError('download-http', `安装包下载失败(HTTP ${response.status})`)
|
||||
}
|
||||
|
||||
let writeStart = start
|
||||
if (start > 0 && response.status === 200) writeStart = 0
|
||||
if (start > 0 && response.status !== 206 && response.status !== 200) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new UpdateError('range-unsupported', '服务器未返回可续传的响应')
|
||||
}
|
||||
|
||||
let expectedSegment: number
|
||||
if (writeStart > 0) {
|
||||
const range = parseContentRange(response.headers.get('content-range'))
|
||||
if (!range || range.start !== writeStart || range.total !== release.asset.size
|
||||
|| range.end !== release.asset.size - 1) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new UpdateError('invalid-content-range', '断点续传 Content-Range 无效')
|
||||
}
|
||||
expectedSegment = range.end - range.start + 1
|
||||
const declared = responseContentLength(response)
|
||||
if (declared !== undefined && declared !== expectedSegment) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new UpdateError('invalid-content-length', '断点续传长度无效')
|
||||
}
|
||||
} else {
|
||||
if (response.status === 206) {
|
||||
const range = parseContentRange(response.headers.get('content-range'))
|
||||
if (!range || range.start !== 0 || range.total !== release.asset.size || range.end !== release.asset.size - 1) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new UpdateError('invalid-content-range', '安装包响应范围无效')
|
||||
}
|
||||
}
|
||||
expectedSegment = release.asset.size
|
||||
const declared = responseContentLength(response)
|
||||
if (declared !== undefined && declared !== expectedSegment) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new UpdateError('invalid-content-length', '安装包响应长度无效')
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.body) throw new UpdateError('empty-response', '安装包响应没有正文')
|
||||
handle = await open(cached.partPath, writeStart > 0 ? 'a' : 'w', 0o600)
|
||||
let written = writeStart
|
||||
let lastProgress = this.state.progress ?? 0
|
||||
for await (const raw of response.body as unknown as AsyncIterable<Uint8Array>) {
|
||||
this.assertCurrentRelease(release)
|
||||
if (abort.signal.aborted || this.disposed) throw new UpdateCancelledError()
|
||||
const chunk = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
|
||||
if (written + chunk.byteLength > release.asset.size) {
|
||||
throw new UpdateError('download-too-large', '安装包正文超过签名大小')
|
||||
}
|
||||
await handle.write(chunk)
|
||||
written += chunk.byteLength
|
||||
const progress = Math.min(99, Math.floor(written / release.asset.size * 100))
|
||||
if (progress !== lastProgress) {
|
||||
lastProgress = progress
|
||||
this.setState({ ...this.state, progress })
|
||||
}
|
||||
}
|
||||
await handle.sync()
|
||||
await handle.close()
|
||||
handle = null
|
||||
if (written !== release.asset.size) throw new UpdateError('download-incomplete', '安装包下载不完整')
|
||||
if (deadline.timedOut()) throw new UpdateError('download-timeout', '安装包下载超时')
|
||||
this.assertCurrentRelease(release)
|
||||
const digest = await sha256File(cached.partPath)
|
||||
if (digest !== release.asset.sha256) {
|
||||
await rm(cached.partPath, { force: true })
|
||||
throw new UpdateError('download-checksum', '安装包 SHA-256 校验失败')
|
||||
}
|
||||
await commitPartFile(cached)
|
||||
this.assertCurrentRelease(release)
|
||||
await verifyInstallerFile(cached, release.asset)
|
||||
this.setState({
|
||||
status: 'ready',
|
||||
currentVersion: this.options.currentVersion,
|
||||
version: release.payload.version,
|
||||
notes: release.payload.notes.zh || release.payload.notes.en,
|
||||
progress: 100,
|
||||
lastChecked: this.state.lastChecked,
|
||||
dismissedVersion: this.persisted.dismissedVersion,
|
||||
unfinishedVersion: this.persisted.unfinishedVersion
|
||||
})
|
||||
this.logger.info(`安装包下载并校验完成 ${release.payload.version}`)
|
||||
return cloneState(this.state)
|
||||
} catch (error) {
|
||||
if (deadline.timedOut()) throw new UpdateError('download-timeout', '安装包下载超时')
|
||||
throw error
|
||||
} finally {
|
||||
deadline.dispose()
|
||||
}
|
||||
} catch (error) {
|
||||
if (handle) await handle.close().catch(() => undefined)
|
||||
const releaseChanged = !this.release || this.release.generation !== release.generation
|
||||
if (abort.signal.aborted || this.disposed || error instanceof StaleReleaseError || releaseChanged) {
|
||||
if (releaseChanged) await rm(cached.partPath, { force: true }).catch(() => undefined)
|
||||
return cloneState(this.state)
|
||||
}
|
||||
if (error instanceof UpdateCancelledError) {
|
||||
if (this.state.status === 'downloading') {
|
||||
this.setState({ ...this.state, status: 'available', progress: undefined })
|
||||
}
|
||||
return cloneState(this.state)
|
||||
}
|
||||
const detail = (error instanceof UpdateError && error.code === 'download-timeout')
|
||||
? '安装包下载超时'
|
||||
: errorMessage(error)
|
||||
this.logger.error('下载更新失败', error)
|
||||
this.setState({
|
||||
status: 'error',
|
||||
currentVersion: this.options.currentVersion,
|
||||
version: release.payload.version,
|
||||
notes: release.payload.notes.zh || release.payload.notes.en,
|
||||
error: detail,
|
||||
lastChecked: this.state.lastChecked,
|
||||
dismissedVersion: this.persisted.dismissedVersion,
|
||||
unfinishedVersion: this.persisted.unfinishedVersion
|
||||
})
|
||||
return cloneState(this.state)
|
||||
} finally {
|
||||
if (this.downloadAbort === abort) this.downloadAbort = null
|
||||
}
|
||||
}
|
||||
|
||||
private async runInstall(): Promise<UpdateState> {
|
||||
if (!this.release && (this.state.status === 'available' || this.state.status === 'idle')) {
|
||||
await this.check()
|
||||
}
|
||||
if (!this.release) return cloneState(this.state)
|
||||
if (this.state.status === 'available') {
|
||||
const downloaded = await this.beginDownload(true)
|
||||
if (downloaded.status !== 'ready') return downloaded
|
||||
}
|
||||
if (this.state.status !== 'ready') return cloneState(this.state)
|
||||
|
||||
const release = this.release
|
||||
const cached = this.cachedInstaller(release)
|
||||
let environment: InstallEnvironment
|
||||
try {
|
||||
this.assertCurrentRelease(release)
|
||||
await verifyInstallerFile(cached, release.asset)
|
||||
environment = await this.options.installer.validateEnvironment(release.asset.size)
|
||||
} catch (error) {
|
||||
const message = errorMessage(error)
|
||||
this.logger.error('安装前校验失败', error)
|
||||
this.setState({
|
||||
...this.state,
|
||||
status: 'error',
|
||||
error: message
|
||||
})
|
||||
return cloneState(this.state)
|
||||
}
|
||||
|
||||
let accepted: boolean
|
||||
try {
|
||||
accepted = await this.options.beforeInstall() === true
|
||||
} catch (error) {
|
||||
this.logger.error('安装确认或运行任务清理失败', error)
|
||||
await this.notifyInstallFailed()
|
||||
this.setState({
|
||||
...this.state,
|
||||
status: 'error',
|
||||
error: errorMessage(error)
|
||||
})
|
||||
return cloneState(this.state)
|
||||
}
|
||||
if (!accepted) return cloneState(this.state)
|
||||
|
||||
try {
|
||||
this.assertCurrentRelease(release)
|
||||
environment = await this.options.installer.validateEnvironment(release.asset.size)
|
||||
await this.options.installer.verifyInstaller(cached)
|
||||
this.assertCurrentRelease(release)
|
||||
} catch (error) {
|
||||
this.logger.error('安装确认后复核失败', error)
|
||||
await this.notifyInstallFailed()
|
||||
this.setState({
|
||||
...this.state,
|
||||
status: 'error',
|
||||
error: errorMessage(error)
|
||||
})
|
||||
return cloneState(this.state)
|
||||
}
|
||||
|
||||
this.setState({
|
||||
status: 'installing',
|
||||
currentVersion: this.options.currentVersion,
|
||||
version: release.payload.version,
|
||||
notes: release.payload.notes.zh || release.payload.notes.en,
|
||||
progress: 100,
|
||||
lastChecked: this.state.lastChecked,
|
||||
dismissedVersion: this.persisted.dismissedVersion,
|
||||
unfinishedVersion: release.payload.version
|
||||
})
|
||||
this.persisted = {
|
||||
...this.persisted,
|
||||
unfinishedVersion: release.payload.version,
|
||||
target: {
|
||||
version: release.payload.version,
|
||||
size: release.asset.size,
|
||||
sha256: release.asset.sha256,
|
||||
manifestUrl: normalizeManifestUrl(this.options.manifestUrl)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.queuePersist(this.persisted)
|
||||
} catch (error) {
|
||||
this.logger.error('写入更新安装标记失败', error)
|
||||
await this.clearUnfinished()
|
||||
await this.notifyInstallFailed()
|
||||
this.setState({
|
||||
...this.state,
|
||||
status: 'error',
|
||||
error: '无法写入更新安装标记'
|
||||
})
|
||||
return cloneState(this.state)
|
||||
}
|
||||
|
||||
try {
|
||||
await this.options.installer.launch(cached, environment)
|
||||
} catch (error) {
|
||||
this.logger.error('启动更新安装器失败', error)
|
||||
await this.clearUnfinished()
|
||||
await this.notifyInstallFailed()
|
||||
this.setState({
|
||||
...this.state,
|
||||
status: 'error',
|
||||
error: errorMessage(error)
|
||||
})
|
||||
return cloneState(this.state)
|
||||
}
|
||||
this.installCommitted = true
|
||||
|
||||
try {
|
||||
await this.options.quitForUpdate()
|
||||
} catch (error) {
|
||||
this.logger.error('退出应用以安装更新失败', error)
|
||||
this.setState({
|
||||
...this.state,
|
||||
status: 'installing',
|
||||
error: '更新安装器已启动,必须退出 YCode 才能继续安装'
|
||||
})
|
||||
return cloneState(this.state)
|
||||
}
|
||||
return cloneState(this.state)
|
||||
}
|
||||
|
||||
private cachedInstaller(release: ReleaseInfo): CachedInstaller {
|
||||
const paths = installerCachePaths(this.options.cacheDirectory, release.payload.version)
|
||||
return {
|
||||
...paths,
|
||||
size: release.asset.size,
|
||||
sha256: release.asset.sha256
|
||||
}
|
||||
}
|
||||
|
||||
private assertCurrentRelease(release: ReleaseInfo): void {
|
||||
if (!this.release || this.release.generation !== release.generation || identity(this.release) !== identity(release)) {
|
||||
throw new StaleReleaseError()
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: UpdateState): void {
|
||||
if (this.disposed) return
|
||||
this.state = cloneState({ ...state, lastExplicit: this.explicitRequested })
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(cloneState(this.state))
|
||||
} catch {
|
||||
// A renderer subscriber must not break updater state transitions.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setExplicit(explicit: boolean): void {
|
||||
if (this.explicitRequested === explicit) return
|
||||
this.explicitRequested = explicit
|
||||
this.setState({ ...this.state, lastExplicit: explicit })
|
||||
}
|
||||
|
||||
private scheduleUpToDateReset(): void {
|
||||
this.clearUpToDateTimer()
|
||||
this.upToDateTimer = setTimeout(() => {
|
||||
this.upToDateTimer = null
|
||||
if (this.state.status === 'up-to-date' && !this.downloadPromise && !this.installPromise) {
|
||||
this.setState({
|
||||
status: 'idle',
|
||||
currentVersion: this.options.currentVersion,
|
||||
lastChecked: this.state.lastChecked,
|
||||
dismissedVersion: this.persisted.dismissedVersion,
|
||||
unfinishedVersion: this.persisted.unfinishedVersion
|
||||
})
|
||||
}
|
||||
}, this.options.upToDateResetMs ?? DEFAULT_UP_TO_DATE_RESET_MS)
|
||||
this.upToDateTimer.unref?.()
|
||||
}
|
||||
|
||||
private clearUpToDateTimer(): void {
|
||||
if (this.upToDateTimer) clearTimeout(this.upToDateTimer)
|
||||
this.upToDateTimer = null
|
||||
}
|
||||
|
||||
private queuePersist(state: PersistedUpdateState): Promise<void> {
|
||||
const snapshot: PersistedUpdateState = {
|
||||
schemaVersion: 1,
|
||||
...(state.dismissedVersion ? { dismissedVersion: state.dismissedVersion } : {}),
|
||||
...(state.unfinishedVersion ? { unfinishedVersion: state.unfinishedVersion } : {}),
|
||||
...(state.target ? { target: { ...state.target } } : {})
|
||||
}
|
||||
const task = this.saveQueue
|
||||
.catch(() => undefined)
|
||||
.then(() => this.options.persistence.save(snapshot))
|
||||
this.saveQueue = task
|
||||
return task
|
||||
}
|
||||
|
||||
private async clearUnfinished(): Promise<void> {
|
||||
const { target: _target, unfinishedVersion: _unfinished, ...rest } = this.persisted
|
||||
this.persisted = rest
|
||||
this.setState({ ...this.state, unfinishedVersion: undefined })
|
||||
await this.queuePersist(this.persisted).catch(error => this.logger.error('清除更新安装标记失败', error))
|
||||
}
|
||||
|
||||
private async notifyInstallFailed(): Promise<void> {
|
||||
if (!this.options.installFailed) return
|
||||
try {
|
||||
await this.options.installFailed()
|
||||
} catch (error) {
|
||||
this.logger.error('恢复更新失败后的后台服务失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { createHash, createPublicKey, verify } from 'node:crypto'
|
||||
import { posix } from 'node:path'
|
||||
import { parseVersion } from './semver'
|
||||
import {
|
||||
MAX_INSTALLER_BYTES,
|
||||
MAX_MANIFEST_BYTES,
|
||||
UPDATE_PLATFORM,
|
||||
UpdateError,
|
||||
type ReleaseInfo,
|
||||
type UpdateManifestPayload,
|
||||
type UpdatePlatformAsset
|
||||
} from './types'
|
||||
|
||||
const SHA256_RE = /^[a-f0-9]{64}$/
|
||||
const SIGNATURE_RE = /^[A-Za-z0-9+/]{86}==$/
|
||||
const EXE_NAME_RE = /^[A-Za-z0-9._-]+\.exe$/
|
||||
|
||||
function exactKeys(value: unknown, expected: string[], label: string): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new UpdateError('invalid-manifest', `${label} 必须是对象`)
|
||||
}
|
||||
const actual = Object.keys(value)
|
||||
if (actual.length !== expected.length || !expected.every(key => actual.includes(key))) {
|
||||
throw new UpdateError('invalid-manifest', `${label} 包含缺失或未知字段`)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeManifestUrl(value: string): string {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
throw new UpdateError('invalid-manifest-url', '更新清单地址无效')
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new UpdateError('invalid-manifest-url', '更新清单只支持 HTTP 或 HTTPS')
|
||||
}
|
||||
if (url.username || url.password || url.search || url.hash) {
|
||||
throw new UpdateError('invalid-manifest-url', '更新清单地址不能包含凭据、查询参数或片段')
|
||||
}
|
||||
if (!url.pathname.endsWith('/latest.json')) {
|
||||
throw new UpdateError('invalid-manifest-url', '更新清单地址必须以 latest.json 结尾')
|
||||
}
|
||||
return url.href
|
||||
}
|
||||
|
||||
export function releasePageUrl(manifestUrl: string): string {
|
||||
const normalized = normalizeManifestUrl(manifestUrl)
|
||||
return new URL('./', normalized).href
|
||||
}
|
||||
|
||||
function assertHttpArtifactUrl(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > 4096) {
|
||||
throw new UpdateError('invalid-manifest', '安装包 URL 无效')
|
||||
}
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
throw new UpdateError('invalid-manifest', '安装包 URL 无效')
|
||||
}
|
||||
if ((url.protocol !== 'http:' && url.protocol !== 'https:')
|
||||
|| url.username || url.password || url.search || url.hash) {
|
||||
throw new UpdateError('invalid-manifest', '安装包 URL 不安全')
|
||||
}
|
||||
return url.href
|
||||
}
|
||||
|
||||
function parsePublicKey(publicKeyPem: string) {
|
||||
if (typeof publicKeyPem !== 'string' || publicKeyPem.length === 0 || publicKeyPem.length > 64 * 1024
|
||||
|| publicKeyPem.includes('PRIVATE KEY')) {
|
||||
throw new UpdateError('invalid-public-key', '更新公钥无效')
|
||||
}
|
||||
let key
|
||||
try {
|
||||
key = createPublicKey(publicKeyPem)
|
||||
} catch {
|
||||
throw new UpdateError('invalid-public-key', '无法解析更新公钥')
|
||||
}
|
||||
if (key.asymmetricKeyType !== 'ed25519') {
|
||||
throw new UpdateError('invalid-public-key', '更新公钥必须是 Ed25519')
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function validatePayload(
|
||||
payload: Record<string, unknown>,
|
||||
manifestUrl: string
|
||||
): { payload: UpdateManifestPayload; asset: UpdatePlatformAsset } {
|
||||
exactKeys(payload, ['schemaVersion', 'version', 'publishedAt', 'notes', 'platforms'], '更新清单 payload')
|
||||
if (payload.schemaVersion !== 1) throw new UpdateError('invalid-manifest', '更新清单 schemaVersion 不受支持')
|
||||
if (typeof payload.version !== 'string') throw new UpdateError('invalid-manifest', '更新清单版本字段无效')
|
||||
parseVersion(payload.version)
|
||||
|
||||
if (typeof payload.publishedAt !== 'string') throw new UpdateError('invalid-manifest', 'publishedAt 必须是字符串')
|
||||
const published = new Date(payload.publishedAt)
|
||||
if (Number.isNaN(published.valueOf()) || published.toISOString() !== payload.publishedAt) {
|
||||
throw new UpdateError('invalid-manifest', 'publishedAt 必须是规范 UTC ISO 时间')
|
||||
}
|
||||
|
||||
exactKeys(payload.notes, ['zh', 'en'], '更新说明')
|
||||
const notes = payload.notes as Record<string, unknown>
|
||||
for (const language of ['zh', 'en']) {
|
||||
const text = notes[language]
|
||||
if (typeof text !== 'string' || text.trim().length === 0 || text.length > 16_384) {
|
||||
throw new UpdateError('invalid-manifest', `更新说明 ${language} 无效`)
|
||||
}
|
||||
}
|
||||
|
||||
exactKeys(payload.platforms, [UPDATE_PLATFORM], '更新平台列表')
|
||||
const platforms = payload.platforms as Record<string, unknown>
|
||||
const rawAsset = platforms[UPDATE_PLATFORM]
|
||||
exactKeys(rawAsset, ['url', 'size', 'sha256'], 'windows-x64 安装包')
|
||||
const asset = rawAsset as Record<string, unknown>
|
||||
|
||||
if (!Number.isSafeInteger(asset.size) || (asset.size as number) <= 0 || (asset.size as number) > MAX_INSTALLER_BYTES) {
|
||||
throw new UpdateError('invalid-manifest', '安装包大小无效')
|
||||
}
|
||||
if (typeof asset.sha256 !== 'string' || !SHA256_RE.test(asset.sha256)) {
|
||||
throw new UpdateError('invalid-manifest', '安装包 SHA-256 无效')
|
||||
}
|
||||
|
||||
const artifactUrl = assertHttpArtifactUrl(asset.url)
|
||||
const parsedUrl = new URL(artifactUrl)
|
||||
const filename = posix.basename(parsedUrl.pathname)
|
||||
if (!EXE_NAME_RE.test(filename)) throw new UpdateError('invalid-manifest', '安装包文件名不受支持')
|
||||
const baseUrl = new URL('./', normalizeManifestUrl(manifestUrl))
|
||||
const expected = new URL(`versions/${payload.version}/${filename}`, baseUrl).href
|
||||
if (artifactUrl !== expected) {
|
||||
throw new UpdateError('invalid-manifest', '安装包 URL 不在受控版本目录中')
|
||||
}
|
||||
|
||||
return {
|
||||
payload: payload as unknown as UpdateManifestPayload,
|
||||
asset: {
|
||||
url: artifactUrl,
|
||||
size: asset.size as number,
|
||||
sha256: asset.sha256
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function verifySignedManifest(
|
||||
raw: Uint8Array,
|
||||
options: { manifestUrl: string; publicKeyPem: string }
|
||||
): ReleaseInfo {
|
||||
if (raw.byteLength === 0 || raw.byteLength > MAX_MANIFEST_BYTES) {
|
||||
throw new UpdateError('invalid-manifest', '更新清单大小无效')
|
||||
}
|
||||
const manifestUrl = normalizeManifestUrl(options.manifestUrl)
|
||||
let text: string
|
||||
let document: unknown
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(raw)
|
||||
document = JSON.parse(text)
|
||||
} catch {
|
||||
throw new UpdateError('invalid-manifest', '更新清单不是有效 UTF-8 JSON')
|
||||
}
|
||||
|
||||
exactKeys(document, ['payload', 'signature'], '更新清单')
|
||||
const root = document as { payload: unknown; signature: unknown }
|
||||
if (!root.payload || typeof root.payload !== 'object' || Array.isArray(root.payload)) {
|
||||
throw new UpdateError('invalid-manifest', '更新清单 payload 必须是对象')
|
||||
}
|
||||
if (typeof root.signature !== 'string' || !SIGNATURE_RE.test(root.signature)) {
|
||||
throw new UpdateError('invalid-manifest', '更新清单签名格式无效')
|
||||
}
|
||||
const signature = Buffer.from(root.signature, 'base64')
|
||||
if (signature.length !== 64 || signature.toString('base64') !== root.signature) {
|
||||
throw new UpdateError('invalid-manifest', '更新清单签名必须使用规范 Base64')
|
||||
}
|
||||
|
||||
const publicKey = parsePublicKey(options.publicKeyPem)
|
||||
const canonicalPayload = Buffer.from(JSON.stringify(root.payload), 'utf8')
|
||||
let valid = false
|
||||
try {
|
||||
valid = verify(null, canonicalPayload, publicKey, signature)
|
||||
} catch {
|
||||
valid = false
|
||||
}
|
||||
if (!valid) throw new UpdateError('invalid-signature', '更新清单签名验证失败')
|
||||
|
||||
const validated = validatePayload(root.payload as Record<string, unknown>, manifestUrl)
|
||||
const fingerprint = createHash('sha256')
|
||||
.update(publicKey.export({ type: 'spki', format: 'der' }))
|
||||
.digest('hex')
|
||||
return {
|
||||
manifestUrl,
|
||||
payload: validated.payload,
|
||||
asset: validated.asset,
|
||||
signatureFingerprint: fingerprint,
|
||||
generation: 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
MAX_MANIFEST_BYTES,
|
||||
MAX_REDIRECTS,
|
||||
UpdateCancelledError,
|
||||
UpdateError,
|
||||
type FetchLike
|
||||
} from './types'
|
||||
|
||||
export interface RequestDeadline {
|
||||
signal: AbortSignal
|
||||
timedOut(): boolean
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function createRequestDeadline(parent: AbortSignal | undefined, timeoutMs: number): RequestDeadline {
|
||||
const controller = new AbortController()
|
||||
let timedOut = false
|
||||
const abortFromParent = (): void => controller.abort(parent?.reason)
|
||||
if (parent?.aborted) controller.abort(parent.reason)
|
||||
else parent?.addEventListener('abort', abortFromParent, { once: true })
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
controller.abort(new Error('request timeout'))
|
||||
}, timeoutMs)
|
||||
timer.unref?.()
|
||||
return {
|
||||
signal: controller.signal,
|
||||
timedOut: () => timedOut,
|
||||
dispose: () => {
|
||||
clearTimeout(timer)
|
||||
parent?.removeEventListener('abort', abortFromParent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkedUrl(value: string | URL, label: string): URL {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
throw new UpdateError('invalid-url', `${label}无效`)
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new UpdateError('invalid-url', `${label}只支持 HTTP 或 HTTPS`)
|
||||
}
|
||||
if (url.username || url.password) throw new UpdateError('invalid-url', `${label}不能包含凭据`)
|
||||
url.hash = ''
|
||||
return url
|
||||
}
|
||||
|
||||
export async function fetchSameOrigin(
|
||||
input: string | URL,
|
||||
init: RequestInit,
|
||||
options: { fetch: FetchLike; allowedOrigin?: string }
|
||||
): Promise<{ response: Response; url: URL }> {
|
||||
let current = checkedUrl(input, '请求地址')
|
||||
const allowedOrigin = options.allowedOrigin ?? current.origin
|
||||
if (current.origin !== allowedOrigin) throw new UpdateError('cross-origin', '请求地址不在更新服务器源内')
|
||||
|
||||
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
|
||||
const response = await options.fetch(current.href, {
|
||||
...init,
|
||||
redirect: 'manual'
|
||||
})
|
||||
if (response.url) {
|
||||
const effective = checkedUrl(response.url, '响应地址')
|
||||
if (effective.origin !== allowedOrigin) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new UpdateError('cross-origin', '更新服务器返回了跨源响应')
|
||||
}
|
||||
}
|
||||
if (![301, 302, 303, 307, 308].includes(response.status)) return { response, url: current }
|
||||
if (redirectCount === MAX_REDIRECTS) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new UpdateError('too-many-redirects', '更新请求重定向次数过多')
|
||||
}
|
||||
const location = response.headers.get('location')
|
||||
if (!location) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new UpdateError('invalid-redirect', '更新请求缺少重定向地址')
|
||||
}
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
const next = checkedUrl(new URL(location, current).href, '重定向地址')
|
||||
if (next.origin !== allowedOrigin) throw new UpdateError('cross-origin', '禁止跨源重定向')
|
||||
current = next
|
||||
}
|
||||
throw new UpdateError('too-many-redirects', '更新请求重定向次数过多')
|
||||
}
|
||||
|
||||
function contentLength(response: Response): number | undefined {
|
||||
const value = response.headers.get('content-length')
|
||||
if (value === null) return undefined
|
||||
if (!/^\d+$/.test(value)) throw new UpdateError('invalid-response', '响应 Content-Length 无效')
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed)) throw new UpdateError('invalid-response', '响应 Content-Length 无效')
|
||||
return parsed
|
||||
}
|
||||
|
||||
export async function readBoundedResponse(response: Response, maximum: number): Promise<Uint8Array> {
|
||||
const declared = contentLength(response)
|
||||
if (declared !== undefined && declared > maximum) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new UpdateError('response-too-large', '更新响应超过大小限制')
|
||||
}
|
||||
if (!response.body) throw new UpdateError('empty-response', '更新响应没有正文')
|
||||
const chunks: Uint8Array[] = []
|
||||
let total = 0
|
||||
for await (const raw of response.body as unknown as AsyncIterable<Uint8Array>) {
|
||||
const chunk = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
|
||||
total += chunk.byteLength
|
||||
if (total > maximum) throw new UpdateError('response-too-large', '更新响应超过大小限制')
|
||||
chunks.push(chunk)
|
||||
}
|
||||
const result = new Uint8Array(total)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function fetchManifestBytes(
|
||||
manifestUrl: string,
|
||||
options: { fetch: FetchLike; signal: AbortSignal; timeout: number }
|
||||
): Promise<Uint8Array> {
|
||||
const deadline = createRequestDeadline(options.signal, options.timeout)
|
||||
try {
|
||||
const { response } = await fetchSameOrigin(manifestUrl, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json' },
|
||||
signal: deadline.signal,
|
||||
cache: 'no-store'
|
||||
}, { fetch: options.fetch })
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new UpdateError('manifest-http', `更新清单请求失败(HTTP ${response.status})`)
|
||||
}
|
||||
return await readBoundedResponse(response, MAX_MANIFEST_BYTES)
|
||||
} catch (error) {
|
||||
if (options.signal.aborted) throw new UpdateCancelledError()
|
||||
if (deadline.timedOut()) throw new UpdateError('manifest-timeout', '检查更新超时')
|
||||
throw error
|
||||
} finally {
|
||||
deadline.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export function parseContentRange(value: string | null): { start: number; end: number; total: number } | null {
|
||||
if (!value) return null
|
||||
const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(value)
|
||||
if (!match) return null
|
||||
const start = Number(match[1])
|
||||
const end = Number(match[2])
|
||||
const total = Number(match[3])
|
||||
if (![start, end, total].every(Number.isSafeInteger) || start > end || end >= total) return null
|
||||
return { start, end, total }
|
||||
}
|
||||
|
||||
export function responseContentLength(response: Response): number | undefined {
|
||||
return contentLength(response)
|
||||
}
|
||||
|
||||
export { checkedUrl }
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
|
||||
// Electron's patched fs exposes app.asar as a virtual directory. Installer
|
||||
// identity checks must observe the physical archive instead.
|
||||
const originalFs = createRequire(process.execPath)('node:original-fs') as typeof NodeFs
|
||||
|
||||
export async function physicalRealPath(value: string): Promise<string> {
|
||||
return originalFs.promises.realpath(value)
|
||||
}
|
||||
|
||||
export async function physicalStat(value: string): Promise<NodeFs.Stats> {
|
||||
return originalFs.promises.stat(value)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { UpdateError } from './types'
|
||||
|
||||
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/
|
||||
|
||||
export interface ParsedVersion {
|
||||
major: bigint
|
||||
minor: bigint
|
||||
patch: bigint
|
||||
prerelease: string[]
|
||||
build: string[]
|
||||
}
|
||||
|
||||
export function parseVersion(value: string): ParsedVersion {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
|
||||
throw new UpdateError('invalid-version', '版本号格式无效')
|
||||
}
|
||||
const match = SEMVER_RE.exec(value)
|
||||
if (!match) throw new UpdateError('invalid-version', '版本号不是严格的 SemVer')
|
||||
const prerelease = match[4] ? match[4].split('.') : []
|
||||
for (const identifier of prerelease) {
|
||||
if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0')) {
|
||||
throw new UpdateError('invalid-version', 'SemVer 预发布数字不能包含前导零')
|
||||
}
|
||||
}
|
||||
return {
|
||||
major: BigInt(match[1]),
|
||||
minor: BigInt(match[2]),
|
||||
patch: BigInt(match[3]),
|
||||
prerelease,
|
||||
build: match[5] ? match[5].split('.') : []
|
||||
}
|
||||
}
|
||||
|
||||
function compareIdentifier(left: string, right: string): number {
|
||||
const leftNumeric = /^\d+$/.test(left)
|
||||
const rightNumeric = /^\d+$/.test(right)
|
||||
if (leftNumeric && rightNumeric) {
|
||||
const a = BigInt(left)
|
||||
const b = BigInt(right)
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
if (leftNumeric) return -1
|
||||
if (rightNumeric) return 1
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
export function compareVersions(left: string, right: string): number {
|
||||
const a = parseVersion(left)
|
||||
const b = parseVersion(right)
|
||||
for (const key of ['major', 'minor', 'patch'] as const) {
|
||||
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
|
||||
}
|
||||
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
|
||||
if (a.prerelease.length === 0) return 1
|
||||
if (b.prerelease.length === 0) return -1
|
||||
const length = Math.max(a.prerelease.length, b.prerelease.length)
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (a.prerelease[index] === undefined) return -1
|
||||
if (b.prerelease[index] === undefined) return 1
|
||||
const compared = compareIdentifier(a.prerelease[index], b.prerelease[index])
|
||||
if (compared !== 0) return compared
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { PersistedUpdateState, UpdatePersistence } from './types'
|
||||
|
||||
const MAX_STATE_BYTES = 64 * 1024
|
||||
|
||||
function emptyState(): PersistedUpdateState {
|
||||
return { schemaVersion: 1 }
|
||||
}
|
||||
|
||||
function validOptionalVersion(value: unknown): value is string | undefined {
|
||||
return value === undefined || typeof value === 'string'
|
||||
}
|
||||
|
||||
function validateState(value: unknown): PersistedUpdateState {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('更新状态文件格式无效')
|
||||
const candidate = value as Partial<PersistedUpdateState>
|
||||
if (candidate.schemaVersion !== 1) throw new Error('更新状态文件版本不受支持')
|
||||
if (!validOptionalVersion(candidate.dismissedVersion) || !validOptionalVersion(candidate.unfinishedVersion)) {
|
||||
throw new Error('更新状态文件版本字段无效')
|
||||
}
|
||||
const target = candidate.target
|
||||
if (target !== undefined) {
|
||||
if (!target || typeof target !== 'object' || Array.isArray(target)
|
||||
|| typeof target.version !== 'string'
|
||||
|| !Number.isSafeInteger(target.size) || target.size <= 0
|
||||
|| typeof target.sha256 !== 'string'
|
||||
|| typeof target.manifestUrl !== 'string') {
|
||||
throw new Error('更新目标记录无效')
|
||||
}
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
...(candidate.dismissedVersion ? { dismissedVersion: candidate.dismissedVersion } : {}),
|
||||
...(candidate.unfinishedVersion ? { unfinishedVersion: candidate.unfinishedVersion } : {}),
|
||||
...(target ? {
|
||||
target: {
|
||||
version: target.version,
|
||||
size: target.size,
|
||||
sha256: target.sha256,
|
||||
manifestUrl: target.manifestUrl
|
||||
}
|
||||
} : {})
|
||||
}
|
||||
}
|
||||
|
||||
export class FileUpdatePersistence implements UpdatePersistence {
|
||||
constructor(readonly filePath: string) {}
|
||||
|
||||
async load(): Promise<PersistedUpdateState> {
|
||||
let size: number
|
||||
try {
|
||||
const info = await stat(this.filePath)
|
||||
if (!info.isFile()) throw new Error('更新状态路径不是文件')
|
||||
size = info.size
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyState()
|
||||
throw error
|
||||
}
|
||||
if (size > MAX_STATE_BYTES) throw new Error('更新状态文件过大')
|
||||
return validateState(JSON.parse(await readFile(this.filePath, 'utf8')))
|
||||
}
|
||||
|
||||
async save(state: PersistedUpdateState): Promise<void> {
|
||||
const validated = validateState(state)
|
||||
const directory = dirname(this.filePath)
|
||||
const temporary = join(directory, `.${randomUUID()}.tmp`)
|
||||
await mkdir(directory, { recursive: true })
|
||||
const handle = await open(temporary, 'wx', 0o600)
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(validated, null, 2)}\n`, 'utf8')
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
try {
|
||||
await rename(temporary, this.filePath)
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export interface TrustedRendererOptions {
|
||||
appPath: string
|
||||
rendererUrl?: string
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
function comparablePath(value: string, platform: NodeJS.Platform): string {
|
||||
const resolved = resolve(value)
|
||||
return platform === 'win32' ? resolved.toLowerCase() : resolved
|
||||
}
|
||||
|
||||
export function isTrustedUpdateRendererUrl(raw: string, options: TrustedRendererOptions): boolean {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(raw)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (url.username || url.password) return false
|
||||
if (url.searchParams.get('window') === 'task') return false
|
||||
|
||||
if (url.protocol === 'file:') {
|
||||
let pathname: string
|
||||
try {
|
||||
pathname = fileURLToPath(url)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const platform = options.platform ?? process.platform
|
||||
const candidate = comparablePath(pathname, platform)
|
||||
return [
|
||||
resolve(options.appPath, 'out', 'renderer', 'index.html'),
|
||||
resolve(options.appPath, 'out', 'renderer', 'settings.html')
|
||||
].some(allowed => comparablePath(allowed, platform) === candidate)
|
||||
}
|
||||
|
||||
if ((url.protocol === 'http:' || url.protocol === 'https:') && options.rendererUrl) {
|
||||
let renderer: URL
|
||||
try {
|
||||
renderer = new URL(options.rendererUrl)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (renderer.username || renderer.password || renderer.origin !== url.origin) return false
|
||||
return url.pathname === '/'
|
||||
|| url.pathname === '/index.html'
|
||||
|| url.pathname === '/settings.html'
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { UpdateState } from '../../shared/updates'
|
||||
|
||||
export const UPDATE_MANIFEST_URL = 'http://192.168.10.6/ycode/releases/latest.json'
|
||||
export const UPDATE_PLATFORM = 'windows-x64' as const
|
||||
export const MAX_MANIFEST_BYTES = 1024 * 1024
|
||||
export const MAX_INSTALLER_BYTES = 4 * 1024 * 1024 * 1024
|
||||
export const MAX_REDIRECTS = 5
|
||||
export const DEFAULT_CHECK_DELAY_MS = 10_000
|
||||
export const DEFAULT_CHECK_INTERVAL_MS = 5 * 60_000
|
||||
export const DEFAULT_UP_TO_DATE_RESET_MS = 3_000
|
||||
export const DEFAULT_MANIFEST_TIMEOUT_MS = 20_000
|
||||
export const DEFAULT_DOWNLOAD_TIMEOUT_MS = 10 * 60_000
|
||||
|
||||
export interface UpdateNotes {
|
||||
zh: string
|
||||
en: string
|
||||
}
|
||||
|
||||
export interface UpdatePlatformAsset {
|
||||
url: string
|
||||
size: number
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export interface UpdateManifestPayload {
|
||||
schemaVersion: 1
|
||||
version: string
|
||||
publishedAt: string
|
||||
notes: UpdateNotes
|
||||
platforms: {
|
||||
'windows-x64': UpdatePlatformAsset
|
||||
}
|
||||
}
|
||||
|
||||
export interface SignedUpdateManifest {
|
||||
payload: UpdateManifestPayload
|
||||
signature: string
|
||||
}
|
||||
|
||||
export interface ReleaseInfo {
|
||||
manifestUrl: string
|
||||
payload: UpdateManifestPayload
|
||||
asset: UpdatePlatformAsset
|
||||
signatureFingerprint: string
|
||||
generation: number
|
||||
}
|
||||
|
||||
export interface CachedInstaller {
|
||||
version: string
|
||||
path: string
|
||||
partPath: string
|
||||
size: number
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export interface InstallEnvironment {
|
||||
installationDirectory: string
|
||||
executablePath: string
|
||||
registryKey: string
|
||||
}
|
||||
|
||||
export interface InstallerAdapter {
|
||||
validateEnvironment(requiredBytes?: number): Promise<InstallEnvironment>
|
||||
verifyInstaller(installer: CachedInstaller): Promise<void>
|
||||
launch(installer: CachedInstaller, environment: InstallEnvironment): Promise<void>
|
||||
}
|
||||
|
||||
export interface PersistedUpdateState {
|
||||
schemaVersion: 1
|
||||
dismissedVersion?: string
|
||||
unfinishedVersion?: string
|
||||
target?: {
|
||||
version: string
|
||||
size: number
|
||||
sha256: string
|
||||
manifestUrl: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdatePersistence {
|
||||
load(): Promise<PersistedUpdateState>
|
||||
save(state: PersistedUpdateState): Promise<void>
|
||||
}
|
||||
|
||||
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>
|
||||
export type StateListener = (state: UpdateState) => void
|
||||
|
||||
export class UpdateError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'UpdateError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export class StaleReleaseError extends UpdateError {
|
||||
constructor() {
|
||||
super('stale-release', '更新版本已变化')
|
||||
this.name = 'StaleReleaseError'
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateCancelledError extends UpdateError {
|
||||
constructor() {
|
||||
super('cancelled', '更新操作已取消')
|
||||
this.name = 'UpdateCancelledError'
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,20 @@ import type { DownloadPayload, ResourceRange } from '@shared/resources'
|
||||
import type { InteractionResponse } from '@shared/conversation'
|
||||
|
||||
const api: YCodeApi = {
|
||||
updates: {
|
||||
getState: () => ipcRenderer.invoke('updates:get-state'),
|
||||
check: () => ipcRenderer.invoke('updates:check'),
|
||||
download: () => ipcRenderer.invoke('updates:download'),
|
||||
install: () => ipcRenderer.invoke('updates:install'),
|
||||
dismiss: () => ipcRenderer.invoke('updates:dismiss'),
|
||||
openDownloadPage: () => ipcRenderer.invoke('updates:open-download-page'),
|
||||
openLog: () => ipcRenderer.invoke('updates:open-log'),
|
||||
onState: (listener) => {
|
||||
const handler = (_event: unknown, state: Parameters<typeof listener>[0]): void => listener(state)
|
||||
ipcRenderer.on('updates:state', handler)
|
||||
return () => ipcRenderer.removeListener('updates:state', handler)
|
||||
}
|
||||
},
|
||||
projects: {
|
||||
list: () => ipcRenderer.invoke('projects:list'),
|
||||
create: (input) => ipcRenderer.invoke('projects:create', input),
|
||||
@@ -23,6 +37,20 @@ const api: YCodeApi = {
|
||||
openPath: id => ipcRenderer.invoke('experts:open-path', id)
|
||||
},
|
||||
skills: {
|
||||
recommendations: () => ipcRenderer.invoke('skills:recommendations'),
|
||||
installRecommended: (id, options) => ipcRenderer.invoke('skills:install-recommended', id, options),
|
||||
cancelInstall: (id) => ipcRenderer.invoke('skills:cancel-install', id),
|
||||
installState: () => ipcRenderer.invoke('skills:install-state'),
|
||||
onInstallProgress: (listener) => {
|
||||
const handler = (_event: unknown, progress: Parameters<typeof listener>[0]): void => listener(progress)
|
||||
ipcRenderer.on('skills:install-progress', handler)
|
||||
return () => ipcRenderer.removeListener('skills:install-progress', handler)
|
||||
},
|
||||
onChanged: (listener) => {
|
||||
const handler = (): void => listener()
|
||||
ipcRenderer.on('skills:changed', handler)
|
||||
return () => ipcRenderer.removeListener('skills:changed', handler)
|
||||
},
|
||||
list: (cwd) => ipcRenderer.invoke('skills:list', cwd),
|
||||
importPath: (path, overwrite, kind) => ipcRenderer.invoke('skills:import', path, overwrite, kind),
|
||||
read: (id, cwd) => ipcRenderer.invoke('skills:read', id, cwd),
|
||||
@@ -216,6 +244,21 @@ const api: YCodeApi = {
|
||||
openWindow: (sessionId: string) => ipcRenderer.invoke('session:open-window', sessionId)
|
||||
},
|
||||
chat: {
|
||||
queue: {
|
||||
get: id => ipcRenderer.invoke('chat:queue:get', id),
|
||||
enqueue: payload => ipcRenderer.invoke('chat:queue:enqueue', payload),
|
||||
take: (sessionId, id) => ipcRenderer.invoke('chat:queue:take', sessionId, id),
|
||||
restoreEdit: (sessionId, id) => ipcRenderer.invoke('chat:queue:restore-edit', sessionId, id),
|
||||
remove: (sessionId, id) => ipcRenderer.invoke('chat:queue:remove', sessionId, id),
|
||||
reorder: (sessionId, ids) => ipcRenderer.invoke('chat:queue:reorder', sessionId, ids),
|
||||
resume: id => ipcRenderer.invoke('chat:queue:resume', id),
|
||||
sendNow: (sessionId, id) => ipcRenderer.invoke('chat:queue:send-now', sessionId, id),
|
||||
onChanged: listener => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, snapshot: import('../shared/prompt-queue').PromptQueueSnapshot): void => listener(snapshot)
|
||||
ipcRenderer.on('chat:queue:changed', handler)
|
||||
return () => ipcRenderer.removeListener('chat:queue:changed', handler)
|
||||
}
|
||||
},
|
||||
activeRun: (sessionId) => ipcRenderer.invoke('chat:active-run', sessionId),
|
||||
setSessionVisible: (sessionId) => ipcRenderer.invoke('chat:set-session-visible', sessionId),
|
||||
onStarted: (listener) => {
|
||||
|
||||
@@ -64,14 +64,14 @@ export default function App(): React.JSX.Element {
|
||||
const defaultWorkspace = useSettingsStore(state => state.workspace)
|
||||
const capabilityCwd = sessions.find(session => session.id === currentSessionId)?.cwd ?? draftContext.cwd ?? defaultWorkspace
|
||||
const useSkill = (skill: SkillRecord): void => {
|
||||
setRoute('chat')
|
||||
window.dispatchEvent(new CustomEvent('ycode:select-skill', { detail: { skill, owner: currentSessionId ?? `new-${draftId}` } }))
|
||||
if (!skill.enabled || skill.error || !skill.userInvocable) return
|
||||
creationIntent('', skill)
|
||||
}
|
||||
const creationIntent = (text: string, skill?: Pick<SkillRecord, 'id' | 'name'>): void => {
|
||||
const state = useChatStore.getState()
|
||||
const settings = useSettingsStore.getState()
|
||||
const current = state.sessions.find(session => session.id === state.currentSessionId)
|
||||
state.startTask(current ? { workspaceId: current.workspaceId, cwd: current.cwd, modelId: current.modelId ?? settings.selectedModelId ?? undefined } : { ...state.draftContext, expertId: null, ...(settings.selectedModelId ? { modelId: settings.selectedModelId } : {}) })
|
||||
state.startTask(current ? { workspaceId: current.workspaceId, cwd: current.cwd, modelId: current.modelId ?? settings.selectedModelId ?? undefined, expertId: null } : { ...state.draftContext, expertId: null, modelId: state.draftContext.modelId ?? settings.selectedModelId ?? undefined })
|
||||
setRoute('chat')
|
||||
// The chat pane remains mounted. Publish after its new draft owner commits.
|
||||
const owner = `new-${useChatStore.getState().draftId}`
|
||||
@@ -330,13 +330,12 @@ export default function App(): React.JSX.Element {
|
||||
|
||||
// 全局快捷键(TODO 12):与「设置 → 快捷键」中已接入的行保持一致。
|
||||
// Ctrl+, 设置;Ctrl+K 聚焦全局搜索;Ctrl+B / Ctrl+Shift+B 切换左/右侧栏;
|
||||
// Ctrl+N 新建对话;生成中按 Esc 停止。其余键位只在该页面展示,未接入时不宣称生效。
|
||||
// Escape belongs to menus, previews and dialogs, never generation cancellation.
|
||||
const shortcutActions = useRef({
|
||||
openSettings: (): void => {},
|
||||
focusSearch: (): void => {},
|
||||
toggleSidebar: (): void => {},
|
||||
toggleRightSidebar: (): void => {},
|
||||
stopRun: (): void => {}
|
||||
})
|
||||
shortcutActions.current = {
|
||||
openSettings,
|
||||
@@ -349,18 +348,13 @@ export default function App(): React.JSX.Element {
|
||||
if (narrow.isNarrowForSidebar) setLeftDrawerOpen((value) => !value)
|
||||
else setSidebarCollapsed((value) => !value)
|
||||
},
|
||||
toggleRightSidebar: () => setRightVisible((value) => !value),
|
||||
stopRun: () => { if (useChatStore.getState().running) void useChatStore.getState().cancel() }
|
||||
toggleRightSidebar: () => setRightVisible((value) => !value)
|
||||
}
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (event.isComposing) return
|
||||
if (document.querySelector('.yc-skill-modal-host')) return
|
||||
const actions = shortcutActions.current
|
||||
if (event.key === 'Escape' && !event.defaultPrevented) {
|
||||
// 与 WorkBuddy 一致:Esc 只在生成中停止本次运行,不吞掉其他弹层的 Esc。
|
||||
if (useChatStore.getState().running) { event.preventDefault(); actions.stopRun() }
|
||||
return
|
||||
}
|
||||
if ((!event.ctrlKey && !event.metaKey) || event.altKey) return
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === ',') { event.preventDefault(); actions.openSettings(); return }
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function CapabilityCenter(props: CapabilityCenterProps): React.JS
|
||||
</header>
|
||||
<div className="capability-page-host" role="tabpanel" aria-label={{ experts: '专家', skills: '技能', connectors: '连接器' }[tab]}>
|
||||
{tab === 'experts' && <ExpertsPage />}
|
||||
{tab === 'skills' && <SkillsPage />}
|
||||
{tab === 'skills' && <SkillsPage cwd={props.cwd} onUseSkill={props.onUseSkill} onCreateSkill={props.onCreateSkill} onFindSkill={props.onFindSkill} />}
|
||||
{tab === 'connectors' && <ConnectorsPage />}
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
@@ -1,5 +1,101 @@
|
||||
import CapabilityMaintenance from './CapabilityMaintenance'
|
||||
import { useCallback, useEffect, useRef, useState, type ComponentProps, type ComponentType } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { McpServerView } from '@shared/capabilities'
|
||||
import { CapabilityToolbar } from './CapabilityCenter'
|
||||
import { SearchIcon, ClearFilledIcon } from './SkillSourceIcons'
|
||||
import { ChevronIcon, CloseIcon, ConnectorAddIcon, ConnectorEmptyIcon, RefreshIcon, CopyIcon, ConnectorEditIcon, McpAddIcon, McpHubIcon, McpCloseIcon, McpSearchIcon, McpClearIcon, SOURCE_BUDDY_ICON } from './ConnectorSourceIcons'
|
||||
import { MonacoEditor, configureMonaco, themeManager } from '../workbuddy/workbench/source-code-assets/wrapper.mjs'
|
||||
import { mcpEditorDocument, parseMcpEditor, mcpIconColor } from './connectors-helpers'
|
||||
import '../workbuddy/workbench/sourceCode.css'
|
||||
import './connectors-source.css'
|
||||
import './connectors-adapters.css'
|
||||
|
||||
function Search({ value, onChange, placeholder, mcp = false }: { value: string; onChange(value: string): void; placeholder: string; mcp?: boolean }): React.JSX.Element {
|
||||
if (mcp) return <div className="cb-search-input cb-search-input--small"><div className="cb-search-input__icon"><McpSearchIcon /></div><input className="cb-search-input__input" value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} aria-label={placeholder} /><button className={'cb-search-input__clear' + (value ? ' cb-search-input__clear--visible' : '')} aria-label="清除搜索" onClick={() => onChange('')}><McpClearIcon /></button></div>
|
||||
return <span className="wb-input-wrapper wb-input-wrapper--medium yc-mcp-search"><span className="wb-input__prefix"><SearchIcon /></span><input className="wb-input__inner" value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} aria-label={placeholder} />{value && <button className="yc-mcp-search-clear" aria-label="清除搜索" onClick={() => onChange('')}><ClearFilledIcon /></button>}</span>
|
||||
}
|
||||
function SourceButton({ children, icon, onClick, disabled = false, primary = false }: { children: React.ReactNode; icon?: React.ReactNode; onClick(): void; disabled?: boolean; primary?: boolean }): React.JSX.Element {
|
||||
return <button type="button" className={`wb-button wb-button--${primary ? 'primary' : 'secondary'} wb-button--medium`} onClick={onClick} disabled={disabled}>{icon && <span className="wb-button__icon wb-button__icon--left">{icon}</span>}<span className="wb-button__content">{children}</span></button>
|
||||
}
|
||||
function JsonEditor({ content, setContent }: { content: string; setContent(value: string): void }): React.JSX.Element {
|
||||
const [ready, setReady] = useState(false), [failed, setFailed] = useState(false)
|
||||
useEffect(() => { let active = true; void configureMonaco().then(() => { if (active) { themeManager().setTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'); setReady(true) } }).catch(() => { if (active) setFailed(true) }); return () => { active = false } }, [])
|
||||
// The recovered source wrapper already provides onChange; the preview-only declaration is narrower.
|
||||
const EditableMonaco = MonacoEditor as ComponentType<ComponentProps<typeof MonacoEditor> & { onChange(value: string | undefined): void }>
|
||||
return failed ? <textarea className="mcp-config-textarea" aria-label="MCP JSON 配置" spellCheck={false} value={content} onChange={event => setContent(event.target.value)} /> : !ready ? <div className="mcp-config-editor-loading">加载编辑器…</div> : <EditableMonaco value={content} onChange={value => setContent(value || '')} language="json" readOnly={false} minimap={false} lineNumbers="on" wordWrap="on" fontSize={13} height="100%" onMount={editor => {
|
||||
editor.updateOptions({ readOnly: false, automaticLayout: true, scrollBeyondLastLine: false, padding: { top: 8, bottom: 8 } })
|
||||
}} />
|
||||
}
|
||||
function ServerEditor({ server, onSaved, onBack }: { server?: McpServerView; onSaved(): Promise<void>; onBack(): void }): React.JSX.Element {
|
||||
const initial = useRef(mcpEditorDocument(server)), [content, setContent] = useState(initial.current), [saving, setSaving] = useState(false), [error, setError] = useState('')
|
||||
const dirty = content !== initial.current
|
||||
const back = (): void => { if (!saving && (!dirty || window.confirm('配置尚未保存,确定返回吗?'))) onBack() }
|
||||
const save = async (): Promise<void> => {
|
||||
if (saving) return
|
||||
setError('')
|
||||
let input
|
||||
try { input = parseMcpEditor(content, server) } catch (reason) { setError((reason as Error).message); return }
|
||||
setSaving(true)
|
||||
try { await window.ycode.mcp.save(input); await onSaved(); onBack() }
|
||||
catch (reason) { setError((reason as Error).message || '保存配置失败') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
return <div className="mcp-config-editor"><div className="mcp-config-editor-header"><button className="mcp-config-editor-back" onClick={back} disabled={saving}>‹ 返回列表</button><div className="mcp-config-editor-header-actions"><SourceButton onClick={back} disabled={saving}>取消</SourceButton><SourceButton onClick={() => void save()} primary disabled={saving || !dirty}>{saving ? '保存中…' : '保存'}</SourceButton></div></div>
|
||||
<div className="mcp-config-editor-path"><span className="mcp-config-editor-path-label">{server ? server.name : '添加 MCP'}</span>{dirty && <span className="mcp-config-editor-unsaved">未保存</span>}</div>
|
||||
<p className="yc-mcp-editor-note">每次配置一个 MCP 服务。已有环境变量和 Headers 留空保持不变;保存后需批准变更的配置。</p>{error && <div className="yc-mcp-error" role="alert">{error}</div>}
|
||||
<div className="mcp-config-editor-body"><div className="mcp-config-editor-monaco-wrapper"><JsonEditor content={content} setContent={setContent} /></div></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default function ConnectorsPage(): React.JSX.Element {
|
||||
return <CapabilityMaintenance title="连接器" />
|
||||
const [servers, setServers] = useState<McpServerView[]>([]), [query, setQuery] = useState(''), [listQuery, setListQuery] = useState('')
|
||||
const [loading, setLoading] = useState(true), [error, setError] = useState(''), [modal, setModal] = useState(false), [warningDismissed, setWarningDismissed] = useState(false)
|
||||
const [editor, setEditor] = useState<{ server?: McpServerView } | null>(null), [detail, setDetail] = useState<string | null>(null), [confirmRemove, setConfirmRemove] = useState<McpServerView | null>(null)
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set()), [busy, setBusy] = useState<Set<string>>(new Set())
|
||||
const busyRef = useRef(new Set<string>()), alive = useRef(true), opener = useRef<HTMLButtonElement>(null), refreshGeneration = useRef(0)
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
const generation = ++refreshGeneration.current
|
||||
const items = await window.ycode.mcp.list()
|
||||
if (alive.current && generation === refreshGeneration.current) setServers(items)
|
||||
}, [])
|
||||
useEffect(() => { alive.current = true; void refresh().catch(reason => setError(reason.message || '加载 MCP 配置失败')).finally(() => setLoading(false)); return () => { alive.current = false } }, [refresh])
|
||||
useEffect(() => { if (!servers.some(server => server.status === 'connecting')) return; const timer = setInterval(() => { void refresh().catch(() => undefined) }, 3000); return () => clearInterval(timer) }, [servers, refresh])
|
||||
const action = async (id: string, operation: () => Promise<unknown>): Promise<void> => {
|
||||
if (busyRef.current.has(id)) return
|
||||
busyRef.current.add(id); setBusy(new Set(busyRef.current)); setError('')
|
||||
try { await operation() } catch (reason) { if (alive.current) setError((reason as Error).message || 'MCP 操作失败') }
|
||||
finally { try { await refresh() } catch { if (alive.current) setError('刷新 MCP 配置失败') }; busyRef.current.delete(id); if (alive.current) setBusy(new Set(busyRef.current)) }
|
||||
}
|
||||
const close = (): void => { if (editor) { if (window.confirm('离开配置编辑器?未保存的修改将丢失。')) setEditor(null); return }; setModal(false); requestAnimationFrame(() => opener.current?.focus()) }
|
||||
const color = (server: McpServerView): string => !server.enabled ? '#8c8c8c' : server.status === 'connected' ? '#52c41a' : server.status === 'connecting' ? '#faad14' : server.status === 'error' ? '#ff6b6b' : '#8c8c8c'
|
||||
const status = (server: McpServerView): string => !server.enabled ? '已禁用' : ({ connected: '已连接', connecting: '连接中', disconnected: '未连接', error: '连接失败' })[server.status]
|
||||
const icon = (server: McpServerView): React.JSX.Element => <span style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: mcpIconColor(server.name) }}>{server.name.charAt(0).toUpperCase()}</span>
|
||||
const matches = (server: McpServerView, needle: string): boolean => server.name.toLowerCase().includes(needle.trim().toLowerCase())
|
||||
const visible = servers.filter(server => matches(server, query)), listed = servers.filter(server => matches(server, listQuery)), selected = servers.find(server => server.id === detail)
|
||||
const enabledTools = servers.filter(server => server.enabled).reduce((sum, server) => sum + server.tools.filter(tool => tool.enabled).length, 0)
|
||||
const approve = (server: McpServerView): Promise<void> => action(server.id, async () => { await window.ycode.mcp.trust(server.id, server.configHash); await window.ycode.mcp.setEnabled(server.id, true) })
|
||||
const portalHost = document.querySelector('.yc-capability-center .expert-center-page') ?? document.body
|
||||
return <div className="ycode-connectors connectors-page"><div className="connector-panel">
|
||||
<CapabilityToolbar><div className="ycode-connectors connectors-toolbar"><div className="ec-search-wrapper"><Search value={query} onChange={setQuery} placeholder="搜索连接器" /></div><button ref={opener} className="wb-button wb-button--primary wb-button--medium connector-topbar-action-btn" onClick={() => { setModal(true); void refresh().catch(() => undefined) }}><span className="wb-button__icon wb-button__icon--left"><ConnectorEditIcon /></span><span className="wb-button__content">自定义连接器</span></button></div></CapabilityToolbar>
|
||||
<div className="connector-panel-content">{error && <div className="yc-mcp-error" role="alert">{error}</div>}{loading ? <div className="connector-panel-empty">加载中…</div> : !visible.length ? <div className="connector-panel-empty"><ConnectorEmptyIcon /><p className="connector-panel-empty-title">{query ? '未找到连接器' : '暂无连接器'}</p><p className="connector-panel-empty-desc">{query ? '试试其他关键词' : '连接器目录尚未接入,可添加并管理自定义 MCP 服务。'}</p></div> : <div className="connector-grid">{visible.map(server => <div key={server.id} className="connector-card connector-card--installed" role="button" tabIndex={0} onClick={() => setDetail(server.id)} onKeyDown={event => { if (event.key === 'Enter') setDetail(server.id) }}><div className="connector-card-content"><div className="connector-card-icon">{icon(server)}</div><div className="connector-card-main"><div className="connector-card-name-row"><div className="connector-card-name">{server.name}</div><span className={`connector-card-status-dot connector-card-status-dot--${server.status}`} title={status(server)} /></div><div className="connector-card-desc">{server.tools.length ? `${server.tools.filter(tool => tool.enabled).length}/${server.tools.length} 个工具已启用` : '自定义 MCP 服务'}</div></div><div className="connector-card-action" onClick={event => event.stopPropagation()}><button className="connector-connect-btn" disabled={busy.has(server.id)} aria-label={`管理 ${server.name}`} onClick={() => { setModal(true); setExpanded(new Set([server.id])) }}><ConnectorAddIcon /></button></div></div></div>)}</div>}</div>
|
||||
</div>
|
||||
{modal && createPortal(<div className="ycode-connectors"><div className="mcp-modal-mask"><section className="mcp-modal" role="dialog" aria-modal="true" aria-label="MCP 服务管理"><header className="mcp-modal-header"><div className="mcp-modal-header-left"><div className="mcp-modal-header-text"><h2 className="mcp-modal-title">MCP 服务管理</h2><p className="mcp-modal-subtitle">安装 MCP 服务,为 AI 扩展更多工具能力</p></div></div><div className="mcp-modal-header-right"><SourceButton icon={<McpAddIcon />} onClick={() => setEditor({})} disabled={!!editor}>添加 MCP</SourceButton><button className="mcp-modal-close" aria-label="关闭 MCP 服务管理" onClick={close}><McpCloseIcon /></button></div></header><div className="mcp-modal-body">
|
||||
{/* The panel is portaled above the card list, so a failed approval, toggle,
|
||||
tool switch or removal has to be reported inside the modal itself. */}
|
||||
{error && <div className="yc-mcp-error" role="alert">{error}</div>}
|
||||
{editor ? <ServerEditor key={editor.server?.id || 'new'} server={editor.server} onSaved={refresh} onBack={() => setEditor(null)} /> : <div className="mcp-panel">
|
||||
<div className="mcp-panel-search" style={{ display: 'flex', alignItems: 'center', gap: 8 }}><div style={{ flex: 1 }}><Search mcp value={listQuery} onChange={setListQuery} placeholder="搜索MCP" /></div><SourceButton icon={<McpHubIcon />} onClick={() => void window.ycode.resources.openExternal('https://cloud.tencent.com/developer/mcp').catch(() => setError('无法打开 MCP Hub'))}>MCP Hub</SourceButton></div>
|
||||
{servers.length > 0 && <div className="mcp-panel-section-header"><h3 className="mcp-panel-section-title">我的 MCP<span className="mcp-installed-badge">({listed.length})</span></h3><span className="mcp-panel-status-summary">{servers.filter(server => server.enabled).length} 启用{servers.some(server => server.status === 'error') && <span className="status-count-error"> · {servers.filter(server => server.status === 'error').length} 失败</span>}</span></div>}
|
||||
{enabledTools > 80 && !warningDismissed && <div className="mcp-tools-warning"><div className="mcp-tools-warning-content"><span className="mcp-tools-warning-icon">⚠</span><div className="mcp-tools-warning-text"><strong>启用工具数量过多</strong><p>启用超过 80 个工具可能影响 AI 回复质量,建议禁用不常用的工具。</p></div></div><button className="mcp-tools-warning-dismiss" onClick={() => setWarningDismissed(true)}>知道了</button></div>}
|
||||
<div className="mcp-panel-content">{!listed.length ? <div className="mcp-panel-empty"><ConnectorEmptyIcon /><p className="mcp-panel-empty-title">{listQuery ? '未找到相关 MCP' : '暂无 MCP 服务'}</p><p className="mcp-panel-empty-desc">{listQuery ? '试试其他关键词' : '添加 MCP 服务,为 AI 扩展更多工具能力'}</p>{!listQuery && <SourceButton onClick={() => setEditor({})}>添加 MCP</SourceButton>}</div> : <div className="mcp-panel-list">{listed.map(server => {
|
||||
const open = expanded.has(server.id), working = busy.has(server.id)
|
||||
return <div className={'mcp-item' + (server.status === 'error' ? ' mcp-item--error' : '')} key={server.id}><div className="mcp-item-header" onClick={() => setExpanded(previous => { const next = new Set(previous); if (next.has(server.id)) next.delete(server.id); else next.add(server.id); return next })}><ChevronIcon expanded={open} /><div className="mcp-item-icon" style={{ background: mcpIconColor(server.name) }}>{server.name.charAt(0).toUpperCase()}</div><div className="mcp-item-info"><div className="mcp-item-name">{server.name}<span className={'mcp-item-status' + (server.status === 'connecting' ? ' mcp-item-status--connecting' : '')} style={{ backgroundColor: color(server) }} title={status(server)} /></div><div className="mcp-item-meta">{server.tools.length ? `${server.tools.filter(tool => tool.enabled).length}/${server.tools.length} 个工具已启用` : '工具'}</div></div><div className="mcp-item-actions" onClick={event => event.stopPropagation()}>{open && <><button className="mcp-item-action" title="编辑配置" aria-label={`编辑 ${server.name}`} onClick={() => setEditor({ server })} disabled={working}>编辑</button>{server.enabled && server.trusted && <button className="mcp-item-action" title="重新连接" aria-label={`重新连接 ${server.name}`} disabled={working || server.status === 'connecting'} onClick={() => void action(server.id, async () => { await window.ycode.mcp.disconnect(server.id); await window.ycode.mcp.connect(server.id) })}><RefreshIcon /></button>}<button className="mcp-item-action" title="删除" aria-label={`删除 ${server.name}`} onClick={() => setConfirmRemove(server)} disabled={working}>删除</button></>}<span className="mcp-toggle-wrap"><button className={'mcp-toggle' + (server.enabled && server.trusted ? ' mcp-toggle--checked' : '') + (!server.trusted || working ? ' mcp-toggle--disabled' : '')} type="button" role="switch" aria-label={`${server.name} 启用`} aria-checked={server.enabled && server.trusted} disabled={!server.trusted || working} onClick={() => void action(server.id, () => window.ycode.mcp.setEnabled(server.id, !server.enabled))}><span className="mcp-toggle-thumb" /></button></span></div></div>
|
||||
{!server.trusted && <div className="mcp-item-approval"><div className="mcp-item-approval-text">此 MCP 配置尚未批准</div><div className="mcp-item-approval-actions"><button className="mcp-item-approval-btn mcp-item-approval-btn--approve" disabled={working} onClick={() => void approve(server)}>批准配置</button></div></div>}
|
||||
{server.error && <div className="mcp-item-error"><div className="mcp-item-error-content">{server.error}</div><div className="mcp-item-error-actions"><button className="mcp-item-error-link" disabled={working || !server.trusted} onClick={() => void action(server.id, () => window.ycode.mcp.connect(server.id))}><RefreshIcon />重新连接</button><button className="mcp-item-error-link" onClick={() => void navigator.clipboard.writeText(server.error!).catch(() => setError('复制失败'))}><CopyIcon />复制日志</button></div></div>}
|
||||
{open && server.tools.length > 0 && <div className="mcp-item-tools">{server.tools.map(tool => <span className="mcp-item-tool-wrap" key={tool.name} title={tool.description}><button className={'mcp-item-tool' + (!tool.enabled ? ' mcp-item-tool--disabled' : '')} disabled={working || !server.enabled || !server.trusted} onClick={() => void action(server.id, () => window.ycode.mcp.setToolEnabled(server.id, tool.name, !tool.enabled))}>{tool.name}</button></span>)}</div>}
|
||||
</div>
|
||||
})}</div>}</div>
|
||||
</div>}</div></section></div>{confirmRemove && <div className="yc-mcp-confirm-mask"><section className="yc-mcp-confirm" role="alertdialog" aria-modal="true" aria-label="删除 MCP"><h3>删除 MCP 服务</h3><p>确定删除“{confirmRemove.name}”吗?</p><div><SourceButton onClick={() => setConfirmRemove(null)} disabled={busy.has(confirmRemove.id)}>取消</SourceButton><SourceButton primary disabled={busy.has(confirmRemove.id)} onClick={() => void action(confirmRemove.id, async () => { await window.ycode.mcp.remove(confirmRemove.id); setConfirmRemove(null) })}>删除</SourceButton></div></section></div>}</div>, portalHost)}
|
||||
{selected && createPortal(<div className="ycode-connectors"><div className="connector-detail-overlay connector-detail-modal-mask" onClick={event => { if (event.target === event.currentTarget) setDetail(null) }}><section className="connector-detail-modal connector-detail-modal--connected" role="dialog" aria-modal="true" aria-label={selected.name}><button className="connector-detail-close" type="button" aria-label="关闭详情" onClick={() => setDetail(null)}><CloseIcon /></button><div className="connector-detail-modal-body"><div className="connector-detail-flow"><div className="connector-detail-flow-avatar connector-detail-flow-avatar--workbuddy"><img src={SOURCE_BUDDY_ICON} alt="" draggable={false} /></div><span className="connector-detail-flow-icon"><span /><span /><span /></span><div className="connector-detail-flow-avatar connector-detail-flow-avatar--connector">{icon(selected)}</div></div><h3 className="connector-detail-name">{selected.name}</h3><p className="connector-detail-desc">{status(selected)} · {selected.tools.length} 个工具</p><div className="connector-detail-actions"><button className="connector-detail-btn connector-detail-btn--try" onClick={() => { setDetail(null); setModal(true); setExpanded(new Set([selected.id])) }}>管理 MCP</button></div>{selected.tools.length > 0 && <div className="connector-detail-expand"><div className="connector-detail-tools">{selected.tools.map(tool => <div className="connector-detail-tool-item" key={tool.name}><span className="connector-detail-tool-name">{tool.name}</span><span className="connector-detail-tool-desc">{tool.description}</span></div>)}</div></div>}</div></section></div></div>, portalHost)}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from 'react'
|
||||
import type { RecommendedSkill, SkillDocument, SkillInstallProgress, SkillRecord } from '@shared/capabilities'
|
||||
import { CardFrame, PlusIcon, UseSkillIcon } from './skills-source.mjs'
|
||||
import { SkillDetailShell } from './SkillDetailShell'
|
||||
import { SkillAvatar, skillAvatarUrl } from './SkillAvatar'
|
||||
import MarkdownContent from '../components/MarkdownContent'
|
||||
import './recommended-flow.css'
|
||||
|
||||
export interface RecommendedSkillsProps {
|
||||
cwd?: string
|
||||
installed: SkillRecord[]
|
||||
onUseSkill?(skill: SkillRecord): void
|
||||
onInstalledChange(): void
|
||||
onViewInstalled(): void
|
||||
}
|
||||
|
||||
type InstallPhase = SkillInstallProgress['phase'] | 'idle'
|
||||
const isRunning = (phase: string): boolean => ['downloading', 'validating', 'installing'].includes(phase)
|
||||
|
||||
interface InstallView {
|
||||
recommendation: RecommendedSkill
|
||||
operationId: string
|
||||
phase: InstallPhase
|
||||
receivedBytes: number
|
||||
totalBytes?: number
|
||||
error?: string
|
||||
overwrite: boolean
|
||||
}
|
||||
|
||||
/** The recovered card supports a source label, but its published type does not declare it. */
|
||||
const Card = CardFrame as ComponentType<{
|
||||
title: string
|
||||
description: string
|
||||
sourceLabel?: string
|
||||
iconUrl?: string
|
||||
disabled?: boolean
|
||||
onOpen?(): void
|
||||
actions?: React.ReactNode
|
||||
className?: string
|
||||
}>
|
||||
|
||||
const PHASE_LABEL: Record<InstallPhase, string> = {
|
||||
idle: '准备安装',
|
||||
downloading: '下载中',
|
||||
validating: '校验中',
|
||||
installing: '安装中',
|
||||
installed: '已安装',
|
||||
cancelled: '已取消',
|
||||
failed: '安装失败'
|
||||
}
|
||||
|
||||
function operationId(): string {
|
||||
const globalCrypto = window.crypto
|
||||
if (globalCrypto?.randomUUID) return globalCrypto.randomUUID()
|
||||
// The host rejects a non-UUID operation id, so derive a v4 UUID from the CSPRNG.
|
||||
const bytes = new Uint8Array(16)
|
||||
globalCrypto.getRandomValues(bytes)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
const hex = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
|
||||
/** Compare the recommendation against the installed list, honouring local edits. */
|
||||
export function matchInstalled(
|
||||
recommendation: RecommendedSkill,
|
||||
installed: SkillRecord[]
|
||||
): { skill?: SkillRecord; state: 'none' | 'current' | 'outdated' | 'modified' | 'conflict' } {
|
||||
const normalize = (value: string): string => value.normalize('NFKC').toLowerCase()
|
||||
// Only a managed user skill can be replaced; a workspace skill is never overwritten.
|
||||
const byName = installed.filter(skill => skill.scope === 'user' && normalize(skill.name) === normalize(recommendation.name))
|
||||
if (!byName.length) return { state: 'none' }
|
||||
const origin = byName.find(skill => skill.origin?.recommendationId === recommendation.id)
|
||||
if (!origin) return { skill: byName[0], state: 'conflict' }
|
||||
const installedOrigin = origin.origin!
|
||||
if (origin.userModified) return { skill: origin, state: 'modified' }
|
||||
const same = installedOrigin.version === recommendation.version
|
||||
&& installedOrigin.archiveSha256 === recommendation.sha256
|
||||
&& installedOrigin.commit === recommendation.commit
|
||||
&& installedOrigin.repository === recommendation.repository
|
||||
&& installedOrigin.packagePath === recommendation.packagePath
|
||||
return same && !origin.error ? { skill: origin, state: 'current' } : { skill: origin, state: 'outdated' }
|
||||
}
|
||||
|
||||
function InstallDisclosure({ recommendation, installed }: { recommendation: RecommendedSkill; installed?: SkillRecord }): React.JSX.Element {
|
||||
return <div className="yc-skill-install-disclosure">
|
||||
<strong>{installed ? '确认安装推荐版本并替换' : '确认安装此技能'}</strong>
|
||||
<p>将从 GitHub 下载固定版本 <b>{recommendation.repository}</b> 的 {recommendation.packagePath}(版本 {recommendation.version},提交 {recommendation.commit.slice(0, 12)})。</p>
|
||||
<p>{installed ? '替换后保留当前启停状态。' : '安装后默认启用,模型可以在需要时加载它。'}{recommendation.containsScripts ? '该技能包含脚本,运行仍受当前工具权限与审批规则约束。' : ''}安装过程只下载并保存文件,不会执行仓库中的任何安装命令。</p>
|
||||
{installed && <p>本机已存在「{installed.name}」{installed.userModified ? ',且本地内容已被修改' : ''}。当前来源:{installed.origin ? `${installed.origin.repository}@${installed.origin.commit.slice(0, 12)} · v${installed.origin.version}` : '本地导入'};替换为 v{recommendation.version},保留{installed.enabled ? '启用' : '禁用'}状态。</p>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function InstalledOverview({ skill, cwd }: { skill?: SkillRecord; cwd?: string }): React.JSX.Element | null {
|
||||
const [document, setDocument] = useState<SkillDocument | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setDocument(null); setError('')
|
||||
if (skill && !skill.error) void window.ycode.skills.read(skill.id, cwd).then(value => {
|
||||
if (alive) setDocument(value)
|
||||
}).catch(reason => { if (alive) setError(String(reason.message ?? reason)) })
|
||||
return () => { alive = false }
|
||||
}, [skill?.id, skill?.origin?.installedAt, skill?.userModified, skill?.error, cwd])
|
||||
if (!skill || skill.error) return null
|
||||
return <details className="yc-skill-source-details yc-skill-installed-overview">
|
||||
<summary>技能说明</summary>
|
||||
{error ? <p className="yc-skill-error" role="alert">{error}</p> : document
|
||||
? <MarkdownContent content={document.content} imageUrlResolver={async () => undefined} /> : <p role="status">正在读取技能说明…</p>}
|
||||
</details>
|
||||
}
|
||||
|
||||
export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChange, onViewInstalled }: RecommendedSkillsProps): React.JSX.Element {
|
||||
const [recommendations, setRecommendations] = useState<RecommendedSkill[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [unavailable, setUnavailable] = useState('')
|
||||
const [view, setView] = useState<InstallView | null>(null)
|
||||
const [detail, setDetail] = useState<RecommendedSkill | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState<RecommendedSkill | null>(null)
|
||||
const [foreign, setForeign] = useState<SkillInstallProgress | null>(null)
|
||||
const [cancelPending, setCancelPending] = useState(false)
|
||||
const [enabling, setEnabling] = useState(false)
|
||||
const latestProgress = useRef<SkillInstallProgress | null>(null)
|
||||
const submitting = useRef(false)
|
||||
const active = useRef<InstallView | null>(null)
|
||||
active.current = view
|
||||
const changedRef = useRef(onInstalledChange)
|
||||
changedRef.current = onInstalledChange
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
if (typeof window.ycode.skills.recommendations !== 'function') { setUnavailable('当前版本未提供推荐技能目录'); setLoading(false); return }
|
||||
void window.ycode.skills.recommendations().then(values => {
|
||||
if (!alive) return
|
||||
setRecommendations(values)
|
||||
setUnavailable(values.length ? '' : '当前没有可用的内置推荐技能')
|
||||
}).catch(reason => { if (alive) setUnavailable(String(reason.message ?? reason)) })
|
||||
.finally(() => { if (alive) setLoading(false) })
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!notice) return
|
||||
const timer = window.setTimeout(() => setNotice(null), 6000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [notice])
|
||||
|
||||
const applyProgress = useCallback((progress: SkillInstallProgress): void => {
|
||||
const previous = latestProgress.current
|
||||
if (previous && (progress.updatedAt < previous.updatedAt || progress.updatedAt === previous.updatedAt &&
|
||||
(progress.operationId !== previous.operationId || !isRunning(previous.phase)))) return
|
||||
latestProgress.current = progress
|
||||
const current = active.current
|
||||
if (current?.operationId === progress.operationId) {
|
||||
const next = { ...current, phase: progress.phase, receivedBytes: progress.receivedBytes, totalBytes: progress.totalBytes, error: progress.error }
|
||||
active.current = next
|
||||
setView(next)
|
||||
setForeign(null)
|
||||
if (!isRunning(progress.phase)) {
|
||||
submitting.current = false
|
||||
setCancelPending(false)
|
||||
if (progress.phase === 'installed') setNotice(current.recommendation)
|
||||
}
|
||||
} else setForeign(isRunning(progress.phase) ? progress : null)
|
||||
if (progress.phase === 'installed') changedRef.current()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const api = window.ycode.skills
|
||||
if (typeof api.onInstallProgress !== 'function') return
|
||||
const unsubscribe = api.onInstallProgress(progress => { if (alive) applyProgress(progress) })
|
||||
void api.installState().then(state => { if (alive && state) applyProgress(state) }).catch(() => undefined)
|
||||
return () => { alive = false; unsubscribe() }
|
||||
}, [applyProgress])
|
||||
|
||||
const matches = useMemo(() => recommendations.map(recommendation => ({ recommendation, match: matchInstalled(recommendation, installed) })), [recommendations, installed])
|
||||
const operationFor = (recommendation: RecommendedSkill): SkillInstallProgress | InstallView | null =>
|
||||
foreign?.recommendationId === recommendation.id ? foreign : view?.recommendation.id === recommendation.id ? view : null
|
||||
const running = !!(view && isRunning(view.phase) || foreign && isRunning(foreign.phase))
|
||||
|
||||
const begin = (recommendation: RecommendedSkill): void => {
|
||||
setDetail(recommendation)
|
||||
if (running || submitting.current) return
|
||||
setError('')
|
||||
const next: InstallView = { recommendation, operationId: operationId(), phase: 'idle', receivedBytes: 0, totalBytes: recommendation.expectedBytes, overwrite: false }
|
||||
active.current = next; setView(next)
|
||||
}
|
||||
const run = async (overwrite: boolean): Promise<void> => {
|
||||
const current = active.current
|
||||
if (!current || current.phase !== 'idle' || submitting.current || foreign && isRunning(foreign.phase)) return
|
||||
const operation = current.operationId
|
||||
submitting.current = true
|
||||
setError('')
|
||||
const next: InstallView = { ...current, phase: 'downloading', overwrite }
|
||||
active.current = next; setView(next)
|
||||
try {
|
||||
await window.ycode.skills.installRecommended(current.recommendation.id, { operationId: operation, overwrite })
|
||||
if (active.current?.operationId !== operation) return
|
||||
submitting.current = false
|
||||
const completed: InstallView = { ...active.current, phase: 'installed', error: undefined }
|
||||
active.current = completed; setView(completed)
|
||||
setNotice(current.recommendation)
|
||||
changedRef.current()
|
||||
} catch (reason) {
|
||||
if (active.current?.operationId !== operation) return
|
||||
submitting.current = false
|
||||
const state = await window.ycode.skills.installState().catch(() => null)
|
||||
if (active.current?.operationId !== operation) return
|
||||
if (state?.operationId === operation && !isRunning(state.phase)) { applyProgress(state); return }
|
||||
const failed: InstallView = { ...active.current, phase: 'failed', error: String((reason as Error).message ?? reason) }
|
||||
active.current = failed; setView(failed)
|
||||
}
|
||||
}
|
||||
const cancel = async (): Promise<void> => {
|
||||
const current = view && isRunning(view.phase) ? view : foreign
|
||||
if (!current || !isRunning(current.phase) || current.phase === 'installing' || cancelPending) return
|
||||
setCancelPending(true)
|
||||
try {
|
||||
await window.ycode.skills.cancelInstall(current.operationId)
|
||||
const state = await window.ycode.skills.installState()
|
||||
if (state) applyProgress(state)
|
||||
} catch (reason) { setError(String((reason as Error).message ?? reason)) }
|
||||
finally { setCancelPending(false) }
|
||||
}
|
||||
const useSkill = async (skill: SkillRecord): Promise<void> => {
|
||||
if (!onUseSkill || skill.error || !skill.userInvocable || enabling || running) return
|
||||
if (!skill.enabled) {
|
||||
setEnabling(true); setError('')
|
||||
try {
|
||||
await window.ycode.skills.setEnabled(skill.id, true, cwd)
|
||||
const refreshed = (await window.ycode.skills.list(cwd)).find(item => item.id === skill.id)
|
||||
changedRef.current()
|
||||
if (!refreshed?.enabled || refreshed.error || !refreshed.userInvocable) throw Error('技能当前不可使用,请刷新后重试')
|
||||
onUseSkill(refreshed); setDetail(null); setNotice(null)
|
||||
} catch (reason) { setError(String((reason as Error).message ?? reason)) }
|
||||
finally { setEnabling(false) }
|
||||
} else { onUseSkill(skill); setDetail(null); setNotice(null) }
|
||||
}
|
||||
const useButton = (recommendation: RecommendedSkill, skill: SkillRecord | undefined, primary = false): React.ReactNode =>
|
||||
skill && !skill.error && skill.userInvocable && onUseSkill && <button type="button"
|
||||
className={primary ? 'sm-skill-detail-btn sm-skill-detail-btn--primary' : 'yc-skill-recommendation-link'}
|
||||
aria-label={`使用推荐技能 ${recommendation.name}`} disabled={enabling || running}
|
||||
onClick={event => { event.stopPropagation(); void useSkill(skill) }}><UseSkillIcon />{enabling ? '正在启用…' : skill.enabled ? '去使用' : '启用后使用'}</button>
|
||||
|
||||
if (loading) return <div className="sm-empty" role="status">加载中…</div>
|
||||
if (unavailable) return <div className="sm-empty yc-skill-recommendation-unavailable"><p>推荐技能目录暂不可用</p><p>{unavailable}</p><button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={onViewInstalled}>查看已安装技能</button></div>
|
||||
|
||||
return <>
|
||||
{error && !detail && <p className="yc-skill-error" role="alert">{error}</p>}
|
||||
{notice && <div className="yc-skill-notice yc-skill-install-success" role="status"><span>已安装 {notice.name}</span>{useButton(notice, matchInstalled(notice, installed).skill)}<button type="button" className="yc-skill-recommendation-link" aria-label="关闭安装成功提示" onClick={() => setNotice(null)}>关闭</button></div>}
|
||||
{foreign && <div className="yc-skill-recommendation-progress" role="status" data-foreign-install="true" data-install-phase={foreign.phase}><span>其他窗口正在安装推荐技能({PHASE_LABEL[foreign.phase]})</span><button type="button" disabled={foreign.phase === 'installing' || cancelPending} onClick={() => void cancel()}>取消</button></div>}
|
||||
<div className="sm-grid yc-skill-recommendation-grid">{matches.map(({ recommendation, match }) => {
|
||||
const operation = operationFor(recommendation)
|
||||
const pending = !!operation && isRunning(operation.phase)
|
||||
const status = pending ? PHASE_LABEL[operation.phase] : match.state === 'current' ? '已安装' : match.state === 'modified' ? '已安装(本地已修改)' : match.state === 'outdated' ? '已安装(版本不同)' : match.state === 'conflict' ? '已存在同名本地技能' : '未安装'
|
||||
return <div key={recommendation.id} className="yc-skill-card-keyboard yc-skill-recommendation-card" data-recommendation-id={recommendation.id} data-recommendation-state={match.state} data-install-phase={operation?.phase} role="button" tabIndex={0} aria-label={`查看 ${recommendation.name}`}
|
||||
onKeyDown={event => { if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) { event.preventDefault(); setDetail(recommendation) } }}>
|
||||
<Card title={recommendation.name} description={recommendation.description} iconUrl={skillAvatarUrl(recommendation.id)} sourceLabel={status} onOpen={() => setDetail(recommendation)} actions={<div className="sm-status" onClick={event => event.stopPropagation()}><button type="button" className={'sm-add-btn' + (pending ? ' sm-add-btn--loading' : '')} aria-label={`安装 ${recommendation.name}`} disabled={running || match.state === 'current'} title={running ? '当前有推荐技能正在安装' : undefined} onClick={() => begin(recommendation)}>{pending ? <span className="yc-skill-recommendation-spinner" aria-hidden /> : <PlusIcon />}</button></div>} />
|
||||
<p className="yc-skill-recommendation-meta"><span>{recommendation.author}</span><span>v{recommendation.version}</span><span>{recommendation.license}</span>{foreign && <span className="yc-skill-recommendation-busy">其他窗口正在安装</span>}{useButton(recommendation, match.skill)}</p>
|
||||
</div>
|
||||
})}</div>
|
||||
<div className="yc-skill-recommendation-footer"><button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={onViewInstalled}>查看已安装技能</button></div>
|
||||
{detail && (() => {
|
||||
const match = matchInstalled(detail, installed)
|
||||
const operation = operationFor(detail)
|
||||
const pending = !!operation && isRunning(operation.phase)
|
||||
const confirming = operation?.phase === 'idle'
|
||||
const canInstall = match.state !== 'current'
|
||||
return <SkillDetailShell title={detail.name} description={detail.description} ariaLabel={`推荐技能详情 ${detail.name}`} recommendationId={detail.id}
|
||||
avatar={<SkillAvatar name={detail.name} recommendationId={detail.id} size={68} />}
|
||||
metadata={<><span>v{detail.version}</span><span>{detail.license}</span><span>{detail.author}</span>{detail.containsScripts && <span>包含脚本</span>}</>}
|
||||
onClose={() => { setDetail(null); if (active.current?.phase === 'idle') { active.current = null; setView(null) } }}
|
||||
actions={<>
|
||||
{pending ? <><button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" disabled><span className="yc-skill-recommendation-spinner" />{PHASE_LABEL[operation.phase]}</button><button type="button" className="sm-skill-detail-btn" disabled={operation.phase === 'installing' || cancelPending} onClick={() => void cancel()}>{cancelPending ? '正在请求取消…' : '取消安装'}</button></>
|
||||
: confirming ? <><button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label="确认安装推荐技能" onClick={() => void run(!!match.skill)}>{match.skill ? '替换并安装' : '确认安装'}</button><button type="button" className="sm-skill-detail-btn" onClick={() => { active.current = null; setView(null) }}>返回详情</button></>
|
||||
: <>{useButton(detail, match.skill, true)}{canInstall && <button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label={`安装推荐版本 ${detail.name}`} disabled={running} onClick={() => begin(detail)}>{operation?.phase === 'failed' || operation?.phase === 'cancelled' ? '重试安装' : match.skill ? '安装推荐版本' : '安装'}</button>}</>}
|
||||
</>}>
|
||||
{confirming && <InstallDisclosure recommendation={detail} installed={match.skill} />}
|
||||
{operation && operation.phase !== 'idle' && <div className="yc-skill-inline-install" role="status" aria-live="polite" data-install-phase={operation.phase} data-operation-id={operation.operationId}>
|
||||
<span>{PHASE_LABEL[operation.phase]}{operation.phase === 'downloading' && operation.totalBytes ? ` · ${Math.round(operation.receivedBytes / operation.totalBytes * 100)}%` : ''}</span>
|
||||
{pending && <progress max={operation.totalBytes || 1} value={operation.phase === 'downloading' ? operation.receivedBytes : operation.totalBytes || 1} />}
|
||||
{operation.phase === 'failed' && operation.error && <p className="yc-skill-error" role="alert">{operation.error}</p>}
|
||||
</div>}
|
||||
{error && <p className="yc-skill-error" role="alert">{error}</p>}
|
||||
<details className="yc-skill-source-details"><summary>来源与校验信息</summary><dl><dt>仓库</dt><dd><button type="button" className="yc-skill-recommendation-link" onClick={() => { void window.ycode.resources.openExternal(`https://github.com/${detail.repository}`).catch(() => undefined) }}>{detail.repository}</button></dd><dt>提交</dt><dd>{detail.commit}</dd><dt>安装包</dt><dd>{detail.packagePath}</dd><dt>SHA-256</dt><dd>{detail.sha256}</dd></dl></details>
|
||||
<InstalledOverview skill={match.skill} cwd={cwd} />
|
||||
</SkillDetailShell>
|
||||
})()}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import archifyAvatar from './assets/archify-owner-avatar.jpg'
|
||||
|
||||
export function skillAvatarUrl(recommendationId?: string): string | undefined {
|
||||
return recommendationId === 'archify' ? archifyAvatar : undefined
|
||||
}
|
||||
|
||||
export function SkillAvatar({ name, recommendationId, size = 48, borderRadius = 16 }: {
|
||||
name: string; recommendationId?: string; size?: number; borderRadius?: number
|
||||
}): React.JSX.Element {
|
||||
const source = skillAvatarUrl(recommendationId)
|
||||
const [failed, setFailed] = useState<string>()
|
||||
const style = { width: size, height: size, borderRadius }
|
||||
return source && failed !== source
|
||||
? <img className="yc-skill-avatar" src={source} alt="" style={{ ...style, objectFit: 'cover' }} onError={() => setFailed(source)} />
|
||||
: <span className="yc-skill-avatar" aria-hidden style={style}>{Array.from(name.trim())[0]?.toUpperCase() ?? 'S'}</span>
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useRef, useState, type ReactNode } from 'react'
|
||||
import { CloseIcon } from './skills-source.mjs'
|
||||
import { SkillModalPortal } from './SkillModalPortal'
|
||||
import { useModalFocus } from './skill-modal-focus'
|
||||
import './skill-detail-shell.css'
|
||||
|
||||
interface Props {
|
||||
title: string; description: string; avatar: ReactNode; metadata?: ReactNode; actions?: ReactNode
|
||||
secondary?: ReactNode; children?: ReactNode; ariaLabel?: string; recommendationId?: string
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
export function SkillDetailShell(props: Props): React.JSX.Element {
|
||||
return <SkillModalPortal><DetailPanel {...props} /></SkillModalPortal>
|
||||
}
|
||||
|
||||
function DetailPanel({ title, description, avatar, metadata, actions, secondary, children, ariaLabel, recommendationId, onClose }: Props): React.JSX.Element {
|
||||
const dialog = useRef<HTMLDivElement>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
useModalFocus(dialog, onClose)
|
||||
return <div className="sm-skill-detail-overlay" onClick={event => { if (event.target === event.currentTarget) onClose() }}>
|
||||
<div className="sm-skill-detail-card is-skill-view yc-skill-detail-shell" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={ariaLabel ?? title} data-recommendation-id={recommendationId}>
|
||||
<div className="sm-skill-detail-header"><div className="sm-skill-detail-icon-wrap">{avatar}</div><div className="sm-skill-detail-header-body">
|
||||
<div className="sm-skill-detail-title-row"><h2>{title}</h2><div className="sm-skill-detail-secondary">{secondary}<button type="button" className="sm-skill-detail-close" aria-label="关闭技能详情" onClick={onClose}><CloseIcon /></button></div></div>
|
||||
<div className="sm-skill-detail-action-row">{actions}</div>
|
||||
</div></div>
|
||||
<div className="yc-skill-detail-scroll">
|
||||
<div className="yc-skill-detail-description"><p className={expanded ? '' : 'is-collapsed'}>{description}</p>{description.length > 110 && <button type="button" className="yc-skill-recommendation-link" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>{expanded ? '收起' : '展开'}</button>}</div>
|
||||
{metadata && <div className="yc-skill-detail-metadata">{metadata}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useLayoutEffect, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
const hosts: HTMLElement[] = []
|
||||
const saved = new Map<HTMLElement, { inert: boolean; hidden: string | null }>()
|
||||
|
||||
function isolateBackground(): void {
|
||||
const top = hosts.at(-1)
|
||||
for (const child of document.body.children) {
|
||||
if (!(child instanceof HTMLElement) || child.tagName === 'SCRIPT') continue
|
||||
if (!saved.has(child)) saved.set(child, { inert: child.inert, hidden: child.getAttribute('aria-hidden') })
|
||||
child.inert = child !== top
|
||||
if (child !== top) child.setAttribute('aria-hidden', 'true')
|
||||
else child.removeAttribute('aria-hidden')
|
||||
}
|
||||
}
|
||||
|
||||
/** A body-level themed host escapes the capability center's isolated stacking context. */
|
||||
export function SkillModalPortal({ children }: { children: ReactNode }): React.JSX.Element {
|
||||
const [host] = useState(() => {
|
||||
const element = document.createElement('div')
|
||||
element.className = 'yc-capability-center yc-skill-modal-host'
|
||||
return element
|
||||
})
|
||||
const [theme, setTheme] = useState(() => document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')
|
||||
useLayoutEffect(() => {
|
||||
document.body.append(host); hosts.push(host); isolateBackground()
|
||||
const observer = new MutationObserver(isolateBackground)
|
||||
observer.observe(document.body, { childList: true })
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
hosts.splice(hosts.indexOf(host), 1); host.remove()
|
||||
if (hosts.length) isolateBackground()
|
||||
else {
|
||||
for (const [element, previous] of saved) {
|
||||
element.inert = previous.inert
|
||||
if (previous.hidden === null) element.removeAttribute('aria-hidden'); else element.setAttribute('aria-hidden', previous.hidden)
|
||||
}
|
||||
saved.clear()
|
||||
}
|
||||
}
|
||||
}, [host])
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => setTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'))
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
return createPortal(<div className={`expert-center-${theme} yc-skill-modal-theme`}>{children}</div>, host)
|
||||
}
|
||||
@@ -1,5 +1,123 @@
|
||||
import CapabilityMaintenance from './CapabilityMaintenance'
|
||||
import { cloneElement, useCallback, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
||||
import type { SkillDocument, SkillRecord } from '@shared/capabilities'
|
||||
import { CapabilitySubpageHeader, CapabilityToolbar } from './CapabilityCenter'
|
||||
import { SkillsIcon, PinIcon, UnpinIcon, SearchIcon, ClearFilledIcon } from './SkillSourceIcons'
|
||||
import { CardFrame, CardMoreIcon, PlusIcon, TrashIcon, FolderIcon, CloseIcon, UseSkillIcon, DocIcon, setSkillPrimitives } from './skills-source.mjs'
|
||||
import { RecommendedSkills } from './RecommendedSkills'
|
||||
import { useModalFocus } from './skill-modal-focus'
|
||||
import { SkillDetailShell } from './SkillDetailShell'
|
||||
import { SkillModalPortal } from './SkillModalPortal'
|
||||
import { SkillAvatar, skillAvatarUrl } from './SkillAvatar'
|
||||
import MarkdownContent from '../components/MarkdownContent'
|
||||
import './skills-source.css'
|
||||
import './skills-host.css'
|
||||
|
||||
export default function SkillsPage(): React.JSX.Element {
|
||||
return <CapabilityMaintenance title="技能" />
|
||||
export interface SkillsPageProps { cwd?: string; onUseSkill?(skill: SkillRecord): void; onCreateSkill?(): void; onFindSkill?(): void }
|
||||
function Avatar({ name, iconUrl, size = 48, borderRadius = 16 }: { name: string; iconUrl?: string; size?: number; borderRadius?: number }): React.JSX.Element {
|
||||
return <SkillAvatar name={name} recommendationId={iconUrl && iconUrl === skillAvatarUrl('archify') ? 'archify' : undefined} size={size} borderRadius={borderRadius} />
|
||||
}
|
||||
function Tooltip({ children, content }: { children: ReactElement<{ title?: string }>; content: ReactNode }): React.JSX.Element { return cloneElement(children, { title: typeof content === 'string' ? content : undefined }) }
|
||||
function BatchCheckbox({ selected }: { selected: boolean }): React.JSX.Element { return <input className="sm-card-batch-checkbox" type="checkbox" checked={selected} readOnly tabIndex={-1} aria-hidden /> }
|
||||
setSkillPrimitives({ Avatar, Tooltip, Checkbox: BatchCheckbox })
|
||||
function Button({ children, primary = false, className = '', ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { primary?: boolean }): React.JSX.Element {
|
||||
return <button type="button" {...props} className={`wb-button wb-button--${primary ? 'primary' : 'secondary'} wb-button--medium ${className}`}><span className="wb-button__content">{children}</span></button>
|
||||
}
|
||||
function Switch({ skill, disabled, large, onToggle }: { skill: SkillRecord; disabled?: boolean; large?: boolean; onToggle(): void }): React.JSX.Element {
|
||||
return <label className={`wb-switch${large ? ' wb-switch--large sm-skill-detail-toggle' : ''}`} title={skill.error ?? (skill.enabled ? '禁用技能' : '启用技能')}><span className="wb-switch__control"><input className="wb-switch__input" type="checkbox" role="switch" aria-label={`${skill.enabled ? '禁用' : '启用'} ${skill.name}`} checked={skill.enabled} disabled={disabled || !!skill.error} onChange={onToggle} /><span className="wb-switch__track"><span className="wb-switch__thumb" /></span></span></label>
|
||||
}
|
||||
function Search({ value, onChange }: { value: string; onChange(value: string): void }): React.JSX.Element {
|
||||
return <div className="ec-search-wrapper"><span className="wb-input-wrapper wb-input-wrapper--medium"><span className="wb-input__prefix"><SearchIcon /></span><input className="wb-input__inner" aria-label="搜索技能" placeholder="搜索技能" value={value} onChange={event => onChange(event.target.value)} /><button type="button" className={`wb-input__icon-button wb-input__clear${value ? '' : ' wb-input__clear--hidden'}`} aria-label="清空技能搜索" tabIndex={value ? 0 : -1} onClick={() => onChange('')}><ClearFilledIcon /></button></span></div>
|
||||
}
|
||||
/** Source layout and SVGs are recovered; native dialogs, storage, and accessibility are local host adapters. */
|
||||
function ImportModal({ busy, error, onClose, onImport }: { busy: boolean; error: string; onClose(): void; onImport(overwrite: boolean, kind: 'directory' | 'zip'): void }): React.JSX.Element {
|
||||
const dialog = useRef<HTMLDivElement>(null), [overwrite, setOverwrite] = useState(false)
|
||||
useModalFocus(dialog, () => { if (!busy) onClose() })
|
||||
return <div className="skill-upload-modal-mask" onClick={event => { if (event.target === event.currentTarget && !busy) onClose() }}><div ref={dialog} className="skill-upload-modal" role="dialog" aria-modal="true" aria-label="导入技能">
|
||||
<div className="skill-upload-modal-header"><span>导入技能</span><button type="button" className="skill-upload-modal-close" aria-label="关闭导入技能" disabled={busy} onClick={onClose}><CloseIcon /></button></div>
|
||||
<button type="button" className={'skill-upload-drop-zone' + (busy ? ' skill-upload-drop-zone--disabled' : '')} aria-label="选择文件夹" disabled={busy} onClick={() => onImport(overwrite, 'directory')}>
|
||||
{busy && <span className="skill-upload-overlay"><span className="skill-upload-overlay-spinner" /><span className="skill-upload-overlay-text">正在导入技能…</span></span>}
|
||||
<span className="skill-upload-drop-icon"><svg width="32" height="32" viewBox="0 0 32 32" fill="none"><rect x="3" y="6" width="26" height="20" rx="2" stroke="currentColor" strokeWidth="1.6" /><path d="M3 11h26" stroke="currentColor" strokeWidth="1.6" /><path d="M10 6V3M22 6V3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /><path d="M16 17v5M13 20l3-3 3 3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg></span><span className="skill-upload-drop-title">选择技能文件夹</span>
|
||||
</button>
|
||||
<div className="skill-upload-requirements"><div className="skill-upload-requirements-title">技能文件要求</div><ul className="skill-upload-requirements-list"><li>文件夹根目录包含 SKILL.md</li><li>SKILL.md 包含 name 和 description 元数据</li></ul><button type="button" className="skill-upload-pick-zip-link" disabled={busy} onClick={() => onImport(overwrite, 'zip')}>选择 ZIP 文件</button></div>
|
||||
<label className="yc-skill-overwrite"><input type="checkbox" checked={overwrite} disabled={busy} onChange={event => setOverwrite(event.target.checked)} />替换已导入的同名技能</label>
|
||||
<p className="yc-skill-local-note">导入到本机技能库;当前未连接远程安全扫描服务。</p>{error && <p className="yc-skill-error" role="alert">{error}</p>}
|
||||
</div></div>
|
||||
}
|
||||
function Detail({ skill, document, busy, error, onClose, onToggle, onUse, onExport, onRemove }: { skill: SkillRecord; document: SkillDocument | null; busy: boolean; error: string; onClose(): void; onToggle(): void; onUse?(): void; onExport(): void; onRemove(): void }): React.JSX.Element {
|
||||
return <SkillDetailShell title={skill.name} description={skill.description || skill.error || ''} onClose={onClose}
|
||||
avatar={<SkillAvatar name={skill.name} recommendationId={skill.origin?.recommendationId} size={68} />}
|
||||
secondary={<Switch skill={skill} large disabled={busy} onToggle={onToggle} />}
|
||||
metadata={<><span>{skill.origin ? '推荐安装' : skill.scope === 'workspace' ? '空间技能' : '本地导入'}</span>{skill.version && <span>v{skill.version}</span>}{skill.origin && <span>{skill.origin.repository}</span>}{skill.disableModelInvocation && <span>仅手动调用</span>}{!skill.userInvocable && <span>仅模型调用</span>}</>}
|
||||
actions={<>{!skill.error && skill.userInvocable && onUse && <button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label="使用技能" disabled={busy} onClick={onUse}><UseSkillIcon />{skill.enabled ? '去使用' : '启用后使用'}</button>}{skill.scope === 'user' && <><Button disabled={busy || !!skill.error} onClick={onExport}><FolderIcon />导出</Button><button type="button" className="sm-skill-detail-more-btn" aria-label="卸载技能" disabled={busy} onClick={onRemove}><TrashIcon /></button></>}</>}>
|
||||
{error && <p className="yc-skill-error" role="alert">{error}</p>}
|
||||
<section className="yc-skill-description-body"><h3 className="sm-skill-detail-section-title"><DocIcon />技能说明</h3>{document ? <MarkdownContent content={document.content} imageUrlResolver={async () => undefined} /> : <p>{skill.error || '正在读取技能说明…'}</p>}</section>
|
||||
{document && <details className="yc-skill-source-details"><summary>技能文件与来源</summary><div className="yc-skill-files">{document.files.map(file => <code key={file}>{file}</code>)}</div><p className="yc-skill-local-note">{skill.path}</p>{skill.origin && <p className="yc-skill-local-note">{skill.origin.repository}@{skill.origin.commit}</p>}</details>}
|
||||
</SkillDetailShell>
|
||||
}
|
||||
export default function SkillsPage({ cwd, onUseSkill, onCreateSkill, onFindSkill }: SkillsPageProps): React.JSX.Element {
|
||||
const [view, setView] = useState<'market' | 'installed'>('market'), [items, setItems] = useState<SkillRecord[]>([]), [query, setQuery] = useState(''), [category, setCategory] = useState('推荐'), [loading, setLoading] = useState(true), [busy, setBusy] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState(''), [addOpen, setAddOpen] = useState(false), [importOpen, setImportOpen] = useState(false), [importError, setImportError] = useState(''), [selectedId, setSelectedId] = useState<string | null>(null), [document, setDocument] = useState<SkillDocument | null>(null), [detailError, setDetailError] = useState(''), [menuId, setMenuId] = useState<string | null>(null), [batch, setBatch] = useState(false), [selected, setSelected] = useState<string[]>([]), [pendingRemove, setPendingRemove] = useState<SkillRecord[]>([])
|
||||
const currentCwd = useRef(cwd); currentCwd.current = cwd
|
||||
const generation = useRef(0), mounted = useRef(true), add = useRef<HTMLDivElement>(null), removeDialog = useRef<HTMLDivElement>(null)
|
||||
const key = 'ycode.skills.menu.v1:' + (cwd ?? '').replaceAll('\\', '/').toLowerCase()
|
||||
const [pinned, setPinned] = useState<string[]>([])
|
||||
useEffect(() => { if (!notice) return; const timer = window.setTimeout(() => setNotice(''), 3000); return () => window.clearTimeout(timer) }, [notice])
|
||||
useEffect(() => { try { setPinned(readPinned(key)) } catch { setPinned([]) } }, [key])
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; generation.current++ } }, [])
|
||||
const reload = useCallback(async () => { if (currentCwd.current !== cwd) return; const current = ++generation.current; const values = await window.ycode.skills.list(cwd); if (mounted.current && currentCwd.current === cwd && current === generation.current) setItems(values) }, [cwd])
|
||||
useEffect(() => { setLoading(true); setQuery(''); setSelectedId(null); setSelected([]); setMenuId(null); setError(''); setNotice(''); void reload().catch(reason => { if (mounted.current) setError(String(reason.message ?? reason)) }).finally(() => { if (mounted.current) setLoading(false) }) }, [reload])
|
||||
// The host pushes a change after every install, import, removal or enable switch.
|
||||
useEffect(() => {
|
||||
const api = window.ycode.skills
|
||||
if (typeof api.onChanged !== 'function') return
|
||||
return api.onChanged(() => { void reload().catch(reason => { if (mounted.current) setError(String(reason.message ?? reason)) }) })
|
||||
}, [reload])
|
||||
useEffect(() => { if (!addOpen && !menuId) return; const close = (event: PointerEvent): void => { if (add.current?.contains(event.target as Node) || (event.target as Element)?.closest?.('.sm-card-menu-wrap')) return; setAddOpen(false); setMenuId(null) }; const keydown = (event: KeyboardEvent): void => { if (event.key === 'Escape') { setAddOpen(false); setMenuId(null) } }; documentGlobal().addEventListener('pointerdown', close); window.addEventListener('keydown', keydown); return () => { documentGlobal().removeEventListener('pointerdown', close); window.removeEventListener('keydown', keydown) } }, [addOpen, menuId])
|
||||
const current = items.find(item => item.id === selectedId) ?? null
|
||||
useEffect(() => { let active = true; setDocument(null); setDetailError(''); if (current && !current.error) void window.ycode.skills.read(current.id, cwd).then(value => { if (active) setDocument(value) }).catch(reason => { if (active) setDetailError(String(reason.message ?? reason)) }); return () => { active = false } }, [current?.id, current?.error, current?.enabled, cwd])
|
||||
const run = async (operation: () => Promise<unknown>): Promise<void> => { if (busy) return; setBusy(true); setError(''); setNotice(''); try { await operation() } catch (reason) { if (mounted.current) setError(reason instanceof Error ? reason.message : String(reason)) } finally { try { await reload() } catch (reason) { if (mounted.current) setError(reason instanceof Error ? reason.message : String(reason)) }; if (mounted.current) setBusy(false) } }
|
||||
const matches = useMemo(() => items.filter(item => `${item.name}\n${item.description}`.toLowerCase().includes(query.trim().toLowerCase())).sort((a, b) => Number(pinned.includes(b.id)) - Number(pinned.includes(a.id))), [items, query, pinned])
|
||||
const pin = (id: string): void => { const next = pinned.includes(id) ? pinned.filter(value => value !== id) : [...pinned, id]; setPinned(next); let memory = {}; try { memory = JSON.parse(localStorage.getItem(key) ?? '{}') } catch { /* Invalid optional local memory is discarded. */ } localStorage.setItem(key, JSON.stringify({ ...memory, pinned: next })) }
|
||||
const useSkill = (skill: SkillRecord): void => {
|
||||
if (busy || !skill.userInvocable || skill.error || !onUseSkill) return
|
||||
if (skill.enabled) { setSelectedId(null); onUseSkill(skill); return }
|
||||
void run(async () => {
|
||||
await window.ycode.skills.setEnabled(skill.id, true, cwd)
|
||||
const refreshed = (await window.ycode.skills.list(cwd)).find(item => item.id === skill.id)
|
||||
if (!refreshed?.enabled || refreshed.error || !refreshed.userInvocable) throw Error('技能当前不可使用,请刷新后重试')
|
||||
setSelectedId(null); onUseSkill(refreshed)
|
||||
})
|
||||
}
|
||||
const importPackage = async (overwrite: boolean, kind: 'directory' | 'zip'): Promise<void> => { if (busy) return; setBusy(true); setImportError(''); try { const value = await window.ycode.skills.importPath(undefined, overwrite, kind); if (value) { await reload(); setImportOpen(false); setView('installed'); setNotice(`已导入 ${value.name}`) } } catch (reason) { setImportError(reason instanceof Error ? reason.message : String(reason)) } finally { setBusy(false) } }
|
||||
const toggle = (skill: SkillRecord): void => { void run(() => window.ycode.skills.setEnabled(skill.id, !skill.enabled, cwd)) }
|
||||
const exportPackage = (skill: SkillRecord): void => { void run(async () => { const target = await window.ycode.skills.export(skill.id); if (target) setNotice('已导出技能 ZIP') }) }
|
||||
const toggleSelected = (id: string): void => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : [...values, id])
|
||||
return <div className="skill-market yc-skills-page">
|
||||
{view === 'installed' && <CapabilitySubpageHeader title={`已安装${items.length ? ` (${items.length})` : ''}`} onBack={() => { setView('market'); setQuery(''); setBatch(false) }} />}
|
||||
<CapabilityToolbar><Search value={query} onChange={setQuery} />{view === 'market' ? <Button className="sm-topbar-action-btn" aria-label="已安装技能" onClick={() => { setView('installed'); setQuery('') }}><SkillsIcon />已安装{items.length > 0 && <span className="sm-topbar-installed-count">({items.length})</span>}</Button> : <button className="sm-installed-batch-manage" onClick={() => { setBatch(value => !value); setSelected([]) }}>{batch ? '完成' : '批量管理'}</button>}
|
||||
<div ref={add} className="yc-skill-add"><Button primary className="sm-topbar-action-btn" aria-label="添加技能" aria-expanded={addOpen} onClick={() => setAddOpen(value => !value)}><PlusIcon />添加技能</Button>{addOpen && <div className="wb-popover wb-dropdown yc-skill-add-menu" role="menu" onKeyDown={event => { if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); const rows = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')]; const index = rows.indexOf(window.document.activeElement as HTMLButtonElement); rows[event.key === 'Home' ? 0 : event.key === 'End' ? rows.length - 1 : (index + (event.key === 'ArrowUp' ? -1 : 1) + rows.length) % rows.length]?.focus() }}><div className="wb-dropdown__list">{[{ label: '查找技能', action: () => onFindSkill?.() }, { label: '上传技能', action: () => { setImportError(''); setImportOpen(true) } }, { label: '创建技能', action: () => onCreateSkill?.() }].map(row => <button type="button" role="menuitem" className="wb-dropdown__item" key={row.label} disabled={row.label === '查找技能' && !onFindSkill || row.label === '创建技能' && !onCreateSkill} onClick={() => { setAddOpen(false); add.current?.querySelector('button')?.focus(); row.action() }}><span className="wb-dropdown__label">{row.label}</span></button>)}</div></div>}</div>
|
||||
</CapabilityToolbar>
|
||||
<div className="yc-skills-scroll">{error && <div className="yc-skill-error" role="alert">{error}<button onClick={() => { void run(reload) }}>重试</button></div>}{notice && <p className="yc-skill-notice" role="status">{notice}</p>}
|
||||
{view === 'market' ? <><div className="sm-search-panel"><div className="sm-segment-bar"><div className="sm-segment-group" role="tablist" aria-label="技能来源">{['推荐', 'SkillHub', '套件'].map(value => <button key={value} role="tab" aria-selected={category === value} className={'sm-segment-item' + (category === value ? ' sm-segment-item--active' : '')} onClick={() => setCategory(value)}>{value}</button>)}</div></div>
|
||||
{category === '推荐'
|
||||
? <section className="sm-installed-section yc-skill-recommendation-section">
|
||||
<RecommendedSkills cwd={cwd} installed={items} onUseSkill={onUseSkill} onInstalledChange={() => { void reload().catch(reason => { if (mounted.current) setError(String(reason.message ?? reason)) }) }} onViewInstalled={() => { setView('installed'); setQuery('') }} />
|
||||
</section>
|
||||
: <div className="sm-empty yc-skill-market-unavailable"><SkillsIcon width={32} height={32} /><p>当前未连接{category}目录服务</p><p>你可以导入本地技能,或查看已安装技能。</p><Button onClick={() => { setView('installed'); setQuery('') }}>查看已安装技能</Button></div>}
|
||||
</div></> : <section className="sm-installed-section">
|
||||
{batch && <div className="sm-batch-toolbar"><div className="sm-batch-toolbar-left"><input type="checkbox" aria-label="全选技能" checked={matches.length > 0 && matches.every(item => selected.includes(item.id))} onChange={event => setSelected(event.target.checked ? matches.map(item => item.id) : [])} />已选择 {selected.length} 项</div><div className="sm-batch-toolbar-right"><Button disabled={busy || !selected.length} onClick={() => { void run(async () => { for (const item of items.filter(value => selected.includes(value.id) && !value.error)) await window.ycode.skills.setEnabled(item.id, true, cwd) }) }}>启用</Button><Button disabled={busy || !selected.length} onClick={() => { void run(async () => { for (const item of items.filter(value => selected.includes(value.id) && !value.error)) await window.ycode.skills.setEnabled(item.id, false, cwd) }) }}>禁用</Button><Button disabled={busy || !selected.some(id => items.some(item => item.id === id && item.scope === 'user'))} onClick={() => setPendingRemove(items.filter(item => selected.includes(item.id) && item.scope === 'user'))}>卸载</Button></div></div>}
|
||||
{loading ? <div className="sm-empty" role="status">加载中…</div> : !matches.length ? <div className="sm-empty">{query ? '没有找到相关技能' : '暂无已安装技能'}</div> : <div className="sm-installed-list">{(['workspace', 'user'] as const).map(scope => { const group = matches.filter(item => item.scope === scope); return !!group.length && <section key={scope} className="sm-installed-group"><div className="sm-installed-group-header"><div className="sm-installed-group-title"><span>{scope === 'workspace' ? '空间技能' : '自定义技能'}</span><span className="sm-installed-group-count">({group.length})</span></div></div><div className="sm-grid">{group.map(skill => <div className="yc-skill-card-keyboard" key={skill.id} data-skill-id={skill.id} role={batch ? undefined : 'button'} tabIndex={0} aria-label={`查看 ${skill.name}`} onKeyDown={event => { if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) { event.preventDefault(); if (batch) toggleSelected(skill.id); else setSelectedId(skill.id) } }}>
|
||||
<CardFrame iconUrl={skillAvatarUrl(skill.origin?.recommendationId)} title={skill.name} description={skill.error ? `不可用:${skill.error}` : skill.description} installed installedListMode disabled={!skill.enabled} batchMode={batch} selected={selected.includes(skill.id)} onToggleSelect={() => toggleSelected(skill.id)} onOpen={() => setSelectedId(skill.id)} actions={!batch && <div className="sm-status" onClick={event => event.stopPropagation()}><div className="sm-card-menu-wrap"><button type="button" className="sm-card-menu-btn sm-card-more" aria-label={`更多 ${skill.name}`} aria-expanded={menuId === skill.id} onClick={() => setMenuId(menuId === skill.id ? null : skill.id)}><CardMoreIcon /></button>{menuId === skill.id && <div className="sm-card-menu" role="menu">{!skill.error && skill.userInvocable && onUseSkill && <button className="sm-card-menu-item" onClick={() => { setMenuId(null); useSkill(skill) }}><UseSkillIcon /><span>{skill.enabled ? '去使用' : '启用后使用'}</span></button>}<button className="sm-card-menu-item" onClick={() => { setMenuId(null); setSelectedId(skill.id) }}><DocIcon /><span>查看详情</span></button>{skill.scope === 'user' && <><button className="sm-card-menu-item" disabled={busy || !!skill.error} onClick={() => { setMenuId(null); exportPackage(skill) }}><FolderIcon /><span>导出</span></button><button className="sm-card-menu-item sm-card-menu-item--danger" disabled={busy} onClick={() => { setMenuId(null); setPendingRemove([skill]) }}><TrashIcon /><span>卸载技能</span></button></>}</div>}</div><button type="button" className={'sm-card-pin' + (pinned.includes(skill.id) ? ' sm-card-pin--active' : '')} aria-label={pinned.includes(skill.id) ? '取消置顶' : '置顶'} aria-pressed={pinned.includes(skill.id)} onClick={() => pin(skill.id)}>{pinned.includes(skill.id) ? <UnpinIcon /> : <PinIcon />}</button><span className="sm-card-switch-wrap"><Switch skill={skill} disabled={busy} onToggle={() => toggle(skill)} /></span></div>} />
|
||||
</div>)}</div></section> })}</div>}
|
||||
</section>}
|
||||
</div>
|
||||
{importOpen && <SkillModalPortal><ImportModal busy={busy} error={importError} onClose={() => setImportOpen(false)} onImport={(overwrite, kind) => { void importPackage(overwrite, kind) }} /></SkillModalPortal>}
|
||||
{current && <Detail skill={current} document={document} busy={busy} error={detailError || error} onClose={() => setSelectedId(null)} onToggle={() => toggle(current)} onUse={onUseSkill ? () => useSkill(current) : undefined} onExport={() => exportPackage(current)} onRemove={() => setPendingRemove([current])} />}
|
||||
{pendingRemove.length > 0 && <SkillModalPortal><RemoveConfirm skills={pendingRemove} busy={busy} dialogRef={removeDialog} onClose={() => setPendingRemove([])} onConfirm={() => { void run(async () => { for (const skill of pendingRemove) await window.ycode.skills.remove(skill.id); setPendingRemove([]); setSelectedId(null); setSelected([]); setNotice('已卸载技能副本') }) }} /></SkillModalPortal>}
|
||||
</div>
|
||||
}
|
||||
function documentGlobal(): Document { return window.document }
|
||||
function readPinned(key: string): string[] { try { const values = JSON.parse(localStorage.getItem(key) ?? '{}')?.pinned; return Array.isArray(values) ? values.filter((id): id is string => typeof id === 'string').slice(0, 200) : [] } catch { return [] } }
|
||||
function RemoveConfirm({ skills, busy, dialogRef, onClose, onConfirm }: { skills: SkillRecord[]; busy: boolean; dialogRef: React.RefObject<HTMLDivElement | null>; onClose(): void; onConfirm(): void }): React.JSX.Element {
|
||||
useModalFocus(dialogRef, () => { if (!busy) onClose() })
|
||||
return <div className="sm-update-confirm-modal-mask"><div className="sm-update-confirm-modal" ref={dialogRef} role="dialog" aria-modal="true" aria-label="确认卸载技能"><div className="sm-update-confirm-modal-header"><span>卸载技能</span><button className="sm-update-confirm-modal-close" aria-label="关闭卸载确认" disabled={busy} onClick={onClose}><CloseIcon /></button></div><div className="sm-update-confirm-modal-content"><p className="sm-update-confirm-description">确定卸载{skills.length === 1 ? `「${skills[0].name}」` : `所选 ${skills.length} 项技能`}?仅删除本机导入副本,保留导入源。</p></div><div className="sm-update-confirm-modal-footer"><Button disabled={busy} onClick={onClose}>取消</Button><Button primary disabled={busy} aria-label="确认卸载" onClick={onConfirm}>{busy ? '正在卸载…' : '卸载'}</Button></div></div></div>
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,16 @@
|
||||
.yc-skill-install-disclosure{padding:16px;background:var(--wb-bg-secondary);border:1px solid var(--wb-border-subtle);border-radius:12px;font-size:13px;line-height:1.65}
|
||||
.yc-skill-install-disclosure p{margin:8px 0 0;overflow-wrap:anywhere}
|
||||
.yc-skill-inline-install{display:flex;flex-direction:column;gap:8px;color:var(--wb-color-text-secondary);font-size:13px}
|
||||
.yc-skill-inline-install progress{width:100%;height:4px;accent-color:var(--wb-color-text-primary)}
|
||||
.yc-skill-inline-install .yc-skill-error{margin:0}
|
||||
.yc-skill-source-details{font-size:13px;line-height:1.6;color:var(--wb-color-text-secondary);border-top:1px solid var(--wb-border-subtle);padding-top:16px}
|
||||
.yc-skill-source-details summary{cursor:pointer;color:var(--wb-color-text-primary);font-weight:500}
|
||||
.yc-skill-source-details dl{display:grid;grid-template-columns:64px minmax(0,1fr);gap:8px;margin:12px 0 0;padding:16px;background:var(--wb-bg-secondary);border-radius:12px}
|
||||
.yc-skill-source-details dt{color:var(--wb-color-text-tertiary)}
|
||||
.yc-skill-source-details dd{margin:0;overflow-wrap:anywhere}
|
||||
.yc-skill-installed-overview>.markdown-body{margin-top:12px}
|
||||
.yc-skill-notice.yc-skill-install-success{display:flex;align-items:center;gap:12px;pointer-events:auto}
|
||||
.yc-skill-install-success .yc-skill-recommendation-link{display:inline-flex;align-items:center;gap:4px}
|
||||
.yc-skill-recommendation-meta .yc-skill-recommendation-link{display:inline-flex;align-items:center;gap:4px}
|
||||
.yc-skill-recommendation-link svg{width:14px;height:14px;flex:none}
|
||||
.yc-skill-recommendation-link:disabled{cursor:default;opacity:.5}
|
||||
@@ -0,0 +1,14 @@
|
||||
.yc-capability-center.yc-skill-modal-host{position:fixed;inset:0;z-index:12000;height:100%;width:100%;display:block;pointer-events:none}
|
||||
.yc-skill-modal-theme{height:100%;pointer-events:none}
|
||||
.yc-skill-modal-host .sm-skill-detail-overlay,.yc-skill-modal-host .skill-upload-modal-mask,.yc-skill-modal-host .sm-update-confirm-modal-mask{pointer-events:auto}
|
||||
.yc-skill-modal-host .yc-skill-detail-shell{display:flex;flex-direction:column;max-height:calc(100dvh - 80px);height:auto;color:var(--wb-color-text-primary);font-family:var(--wb-font-family)}
|
||||
.yc-skill-detail-shell .sm-skill-detail-header{flex:none}
|
||||
.yc-skill-detail-scroll{overflow:auto;min-height:0;padding:20px 24px 24px;display:flex;flex-direction:column;gap:20px;overscroll-behavior:contain}
|
||||
.yc-skill-detail-description p{margin:0;font-size:14px;line-height:1.65;overflow-wrap:anywhere}
|
||||
.yc-skill-detail-description p.is-collapsed{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.yc-skill-detail-description button{margin-top:8px}
|
||||
.yc-skill-detail-metadata{display:flex;flex-wrap:wrap;gap:12px 24px;border-top:1px solid var(--wb-border-subtle);border-bottom:1px solid var(--wb-border-subtle);padding:16px 0;font-size:13px;color:var(--wb-color-text-secondary)}
|
||||
.yc-skill-detail-shell .sm-skill-detail-action-row{flex-wrap:wrap;gap:8px}
|
||||
.yc-skill-detail-shell .sm-skill-detail-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;white-space:nowrap}
|
||||
.yc-skill-detail-shell .yc-skill-description-body{font-size:13px;line-height:1.6;overflow-wrap:anywhere}
|
||||
@media(max-width:600px){.yc-skill-modal-host .yc-skill-detail-shell{max-height:calc(100dvh - 32px)}.yc-skill-detail-scroll{padding:16px 20px 20px}}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/** Shared focus trap for the recovered source modals. Only the topmost modal reacts to keys. */
|
||||
const modalStack: HTMLElement[] = []
|
||||
|
||||
export function useModalFocus(ref: React.RefObject<HTMLElement | null>, close: () => void): void {
|
||||
const onClose = useRef(close); onClose.current = close
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
const element = ref.current
|
||||
if (element) modalStack.push(element)
|
||||
ref.current?.querySelector<HTMLElement>('button:not(:disabled),input:not(:disabled)')?.focus()
|
||||
const keyboard = (event: KeyboardEvent): void => {
|
||||
if (modalStack.at(-1) !== ref.current) return
|
||||
if (event.isComposing) return
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose.current() }
|
||||
if (event.key === 'Tab') {
|
||||
const targets = [...(ref.current?.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled),select:not(:disabled),summary,[tabindex="0"],a[href]') ?? [])].filter(el => {
|
||||
if (!el.getClientRects().length || getComputedStyle(el).visibility === 'hidden') return false
|
||||
// Chromium can retain layout boxes inside a closed details element.
|
||||
for (let parent = el.parentElement; parent && parent !== ref.current; parent = parent.parentElement) {
|
||||
if (parent.tagName === 'DETAILS' && !parent.hasAttribute('open') && !parent.querySelector(':scope > summary')?.contains(el)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
const index = targets.indexOf(document.activeElement as HTMLElement)
|
||||
if (targets.length && (index < 0 || event.shiftKey && index === 0 || !event.shiftKey && index === targets.length - 1)) { event.preventDefault(); targets[event.shiftKey ? targets.length - 1 : 0].focus() }
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', keyboard, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', keyboard, true)
|
||||
if (element) { const index = modalStack.indexOf(element); if (index >= 0) modalStack.splice(index, 1) }
|
||||
if (previous?.isConnected) previous.focus()
|
||||
}
|
||||
}, [ref])
|
||||
}
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user