Compare commits

..

32 Commits

Author SHA1 Message Date
kdletters 34b1ad5d5c 新增 Godot 模板并补按模板建项的 Godot 分流
- 新增 4 个仓库内手写 Godot 4.7 模板源:godot-empty-2d、godot-empty-3d、godot-hello-world、godot-platformer-2d,entry 统一为 project.godot,各自带 meta.json 与 960×540 封面
- template_library.rs:复制模板后按 project.godot 识别 Godot 模板,改写工程显示名后走既有 Godot 导入并写入 godotProjectRoot,不再落到 Web 占位入口
- manifest.rs:新增 apply_godot_project_display_name,只改 [application] 段的 config/name 一行,其余字节逐字保留
- manifest.rs:放宽 Godot 工作区发现,一层子目录多命中按目录名排序取第一个,project.godot 允许是符号链接 / reparse point / 硬链接
- commands.rs:同步 Godot 导入错误文案,去掉「普通文件」限定
- RuntimeConfigDialog.tsx:内置插件行去掉手动启动 / 停止按钮,启动由前端自动启动承担、停止走该行启用开关
- import_tests.rs 与 tests/project.rs:补显示名改写、多命中确定性、链接标记与错误文案的定向回归
- 同步更新 PRD、模板包组织指南、模板库与模板建项技术方案、AGC Godot 编辑器插件接入方案、实施计划与共享决策记录
2026-09-21 17:25:34 +08:00
kdletters c5afc02e94 模板正文目录门禁:CLI 打包与后台上传统一拒绝身份/版本库/依赖/构建目录
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
- scripts/agc-template-library-publish.mjs:readProjectFiles 增加正文门禁,任意层级拒绝 .agent/.git/.svn/node_modules,根目录拒绝 dist/build/library/temp/local/.idea/.vscode
- scripts/agc-template-library-publish.test.mjs:新增夹具源目录与门禁用例,覆盖 13 条拒绝路径及 game/dist/**、game/.gitignore 放行
- server-rs/crates/api-server/src/admin_templates.rs:上传归档校验与 CLI 用同一份段名单,新增 template_import_archive_rejects_identity_and_build_directories
- docs/【模板规范】AGC模板包组织指南-2026-09-21.md:已知边界改为「已有机器门禁」,并说明同名目录段只在根目录受限
- docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md:CLI 与后台上传的校验口径补门禁,并追加 2026-09-21 证据(九个模板本地重打包摘要与线上清单逐条一致)
- docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md:验收标准 2 补充门禁证据
- docs/project-memory/shared-memory/decision-log.md:新增 2026-09-21 正文目录门禁决策记录
2026-09-21 16:25:35 +08:00
kdletters b5e1c0f1d6 新增模板包组织规范并接入文档索引
Project CI / AI game creator shell Rust crates (push) Successful in 2m48s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m37s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m5s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 8m59s
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
- 新增 docs/【模板规范】AGC模板包组织指南-2026-09-21.md:ZIP 根目录契约、html 与 Cocos 工程的建议结构与保留项、禁止放入的内容(.agent/.git/node_modules/dist/密钥/嵌套锁文件)、路径与体积上限、封面要求、元数据字段约束、版本不可变规则与发布前自检清单
- 说明两条发布路径的输入差异(CLI 源目录 vs 后台成品 ZIP 上传)与各自上限,并标注「工具不会自动拦截 .agent/.git」这一已知边界
- 主规范与模板库 README(会发布到 OSS templates/README.md)各加一条指向该指南的入口
- docs/README.md 登记新文档,check:doc-index 通过(194 份)
2026-09-21 16:16:21 +08:00
kdletters 15e7d6065e 记录后台模板上传里程碑的实现与证据
Project CI / AI game creator shell Rust crates (push) Successful in 2m58s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m51s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
- 里程碑 Status 更新为 implemented-awaiting-runtime-acceptance
- 补实现与证据表:路由/权限、写入前整批拒绝、批量原子性、版本一致性、CAS 与锁、保留语义、内容寻址与回读、界面用例与静态检查
- 明确仍未执行:真实 dev bucket 的批量写入 smoke(需用户确认,建议两个 smoke-import 模板验证后下架)
2026-09-21 16:10:55 +08:00
kdletters 2e23a54211 后台模板上传(页面):模板管理页支持批量选择 ZIP 与封面并整批提交
Project CI / AI game creator shell Rust crates (push) Successful in 3m2s
Project CI / AI game creator shell Rust smoke (push) Successful in 4m5s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
- 新增上传模型:文件名派生模板 ID、按同名匹配封面、逐行字段校验与 multipart 组装(标签去重与上限)
- 模板管理页新增「上传模板」入口与弹窗:多选 ZIP、按 ID 同名多选封面、逐行编辑 ID/名称/版本/运行时/entry、逐行错误、沿用写入确认与防重复提交
- 失败只在弹窗内交代并区分 409 刷新引导与 503 锁占用提示,401 仍交由会话处理;成功后用服务端快照刷新列表并展示逐条导入结果
- admin API client 支持 multipart 请求(不预设 JSON Content-Type),新增 importAdminAgcTemplates 与对应类型
- 主规范新增「后台模板上传」章节并收口原「不上传 ZIP」表述;决策记录补一条
- 验证:admin-web typecheck、页面/模型/客户端 40 项 vitest、check:doc-index、check:encoding、cargo fmt --check、git diff --check
2026-09-21 16:06:23 +08:00
kdletters 4863c8c066 后台模板上传(后端):新增批量导入接口与存储/领域支持
- shared-contracts 新增导入 manifest、逐条与结果 DTO(camelCase,拒绝未知字段)
- module-assets 新增 prepare_template_import:新 ID 默认上架,已存在条目就地更新并保留上架状态与未知扩展字段;同一 ID 同版本字节不同时按 CLI 同一句文案拒绝,字节一致时按内容复用既有对象
- platform-oss 为 application/zip 放宽单对象上限到 64 MiB(图片与元数据仍 5 MiB),测试替身同步该口径
- api-server 新增 POST /admin/api/agc-templates/import:multipart(manifest + zip_N/cover_N),逐项校验归档安全性、entry 存在性与封面(按字节嗅探格式,仅 PNG/JPEG/WebP)后才取发布锁
- 锁内流程:CAS 校验 revision → 内容寻址写入 zip/封面/元数据并回读 → 一次提交清单 → 释放锁;断连由独立任务持有,不确定结果保留锁并返回 503
- 路由挂 require_admin_auth 与 200 MiB body 上限,页签权限矩阵与路由契约测试同步
- 新增用例:清单与文件字段校验、归档越界与符号链接拒绝、内容寻址键与封面嗅探、zip 内容类型存储上限
- 新增「后台模板上传」里程碑与实施计划文档
- 验证:cargo test(module-assets 11 项、platform-oss 12 项、api-server 定向 4 项)、cargo fmt --check、check:doc-index、check:encoding、git diff --check
2026-09-21 16:06:23 +08:00
kdletters 33036b65fc 修复 AGC Windows 构建的 Tauri NSIS 工具链重复下载与截断失败
Project CI / AI game creator shell Rust crates (push) Successful in 3m3s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m50s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m16s
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
tauri-bundler 打包时现场从 GitHub 下载 nsis-3.11.zip 与 nsis_tauri_utils.dll 且不重试,
Checkout 的 git clean -fdx 又会删掉工作区内的 target/.tauri,于是每个构建都要重下,
响应一旦被截断只会抛 `io: unexpected end of file`,让发布在 Rust 编译数分钟后失败。

- Jenkins Checkout 的 git clean 增加 -e apps/ai-game-creator-shell/src-tauri/target/.tauri,只保留工具缓存
- 新增 nsis-toolset.mjs:按固定 SHA1 预置 target/.tauri/NSIS,带 4 次重试并把原始归档缓存到工作区外
- 新增 ensure-nsis-toolset.mjs 与 nsis:prepare 脚本,作为 Jenkins 预检入口
- buildRelease 在 Windows 目标打包前接入工具链预置,预置失败即失败关闭
- Jenkins 新增 Tauri NSIS toolchain 阶段,在编译前预置、跑回归测试并校验 makensis 可执行
- 新增 nsis-toolset.test.mjs 并补 3 项 build-release 预置接入测试
- 同步开发运维文档与 shared-memory 排障记录
2026-09-21 15:59:30 +08:00
kdletters f4ebc45612 模板库线上对齐仓库源:递增版本重发并把说明文档纳入发布
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust crates (push) Successful in 1m23s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 4m48s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m38s
Project CI / Frontend tests (push) Successful in 10m0s
Project CI / Native shell tests (push) Successful in 11m37s
Project CI / Repository checks (push) Successful in 8m49s
Project CI / Backend tests (push) Successful in 12m24s
Project CI / AI game creator shell web tests (push) Successful in 4m50s
- 7 个内容已变的模板 templateVersion 升到 0.1.1,blank-2d-canvas 与 blank-web 保持 0.1.0
- 发布脚本把仓库 README.md 覆盖写到 templates/README.md(上限 64 KiB,写在清单之前并回读校验),线上说明文档不再是手工副本
- 修复守卫拒绝时直接 process.exit(1) 触发 libuv 断言崩溃、看不到拒绝原因的问题
- 新增发布用例:说明文档随发布覆盖写且写在清单之前、源目录没有 README 时不写线上文档
- 模板库新增仓库侧 README.md 源文件(发布后与线上逐字节一致)
- 技术方案补说明文档契约与 2026-09-21 重发布记录;决策记录与踩坑记录各补一条
- 验证:node --test 27 项、真实发布 28 个对象回读通过、线上清单与 README 核对一致、AGC 壳 3 项线上用例重跑通过、check:doc-index、check:encoding、eslint、git diff --check
2026-09-21 14:51:39 +08:00
kdletters 19ed1776f1 收口 AGC 模板库客户端接入里程碑:补缓存回退与建项失败清理证据
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
- 客户端清单来源抽成可测判定:远端合法正文优先并标 network,远端失败回退本机缓存并标 cache,无缓存时暴露远端错误
- 远端已答话但正文非法(不是合法 UTF-8 或不符合 schema)与缓存自身损坏时一律失败关闭,不用缓存掩盖远端问题
- 补 5 项清单来源判定用例
- 大小 / 摘要不一致与越界归档用例补断言:拒绝后不留安装目录、不产生已下载判据
- 新增建项失败清理用例:复制阶段失败时删除刚创建的项目目录,不留半成品
- 主规范并入清单来源与安装判据口径,并写入 2026-09-21 复核证据
- 里程碑置为 accepted 并补 7 条验收项证据表,按工作流删除对应实施计划
- 验证:cargo test template_library 24 项与 --ignored 线上 3 项、前端 4 个模板用例文件 38 项、appSurface 首页模板 2 项、AGC typecheck、check:encoding、check:doc-index、cargo fmt --check、git diff --check
2026-09-21 14:40:15 +08:00
kdletters 4d313a44eb 收口后台模板管理里程碑:回填验收证据并删除临时实施计划
Project CI / AI game creator shell Rust crates (push) Successful in 2m58s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m58s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m32s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 8m26s
Project CI / Backend tests (push) Successful in 8m33s
Project CI / Repository checks (push) Successful in 4m50s
Project CI / Native shell tests (push) Successful in 9m20s
Project CI / Frontend tests (push) Successful in 5m57s
Project CI / AI game creator shell web tests (push) Successful in 3m19s
- 里程碑 Status 从 approved 更新为 implemented-awaiting-runtime-acceptance,并注明按验收口径未对真实 OSS 写入
- 新增验收证据:合同/权限/领域/存储/CLI 五层用例结果,本地 API 健康检查与未授权读取写入失败关闭,授权读取 9 个模板快照,受控浏览器桌面与 390×844 移动端 smoke
- 新增复审记录:写路径顺序与 409 语义、后台 DTO 结构上无法改动 ZIP/版本、封面公共域名与单 bucket 合同一致、迟到响应判据的覆盖方式
- 按实施计划第 4 步删除临时实施计划文件,里程碑文档保留为唯一验收依据
- 验证:check:doc-index(192 份)、check:encoding、git diff --check
2026-09-21 14:21:25 +08:00
kdletters 01ed9fadc6 修复手工 release 发布未透传 AGC 渠道
Project CI / AI game creator shell Rust crates (push) Successful in 2m45s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m36s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m5s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 7m54s
Project CI / Backend tests (push) Successful in 11m10s
Project CI / Repository checks (push) Successful in 6m17s
Project CI / Frontend tests (push) Successful in 8m0s
Project CI / Native shell tests (push) Successful in 11m32s
Project CI / AI game creator shell web tests (push) Has been cancelled
补充手工发布目标到 AGC 更新渠道的踩坑记录

补充 release 客户端包分区与补发流程说明
2026-09-21 14:04:02 +08:00
kdletters 02a7abeb3f 新增后台 AGC 模板管理与模板库内容寻址落库
Project CI / AI game creator shell Rust crates (push) Successful in 2m53s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m42s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
- 后台新增「模板管理」页:按权限查看并编辑模板名称/简介/标签/封面,支持上架与下架;路由、侧栏、tab 权限、API 客户端与 DTO 同步接入
- api-server 新增 /admin/api/agc-templates 读取与更新路由,复用现有后台鉴权与 tab 权限校验,权限不足按既有失败关闭口径拒绝
- module-assets 收敛模板库纯规则(active/inactive 投影、索引 schema 与重复项校验、ZIP 大小与摘要核对),platform-oss 承担对象存储读写、不可变对象复用与发布互斥锁
- shared-contracts 补后台模板 DTO;spacetime-module 账号存储适配保持既有 schema 不变
- AGC 壳内置模板索引与建项读取改为内容寻址对象,补模板安装、越界归档、索引不匹配等用例
- 模板库发布脚本支持定向发布与上架/下架合并、内容地址复用与锁语义,CLI 守卫用例同步;jenkins 两条客户端打包管线统一触发邮件通知 Job 并传递首装包链接
- 内置 5 个 Cocos 官方模板载荷与索引 fixture(内容地址对象,共 90 个文件)
- 补「后台模板管理」里程碑与实施计划,更新模板库技术方案、开发运维文档与共享记忆
- 验证:admin-web 39 项用例与 typecheck、发布脚本 25 项用例、api-server 与 AGC 壳编译、模板库定向 Rust 用例(AGC 壳 18 项、module-assets/platform-oss 16 项、后台权限 1 项)、check:encoding / check:doc-index / check:production-ops / rustfmt 全部通过
2026-09-21 13:56:20 +08:00
suzmii 6845cdcbc5 修复 Mac 包校验脚本对合法随包 npm 的误判
Project CI / AI game creator shell Rust smoke (push) Successful in 2m14s
Project CI / AI game creator shell Rust crates (push) Successful in 1m26s
Project CI / Frontend tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
- check-macos-bundle 的「包里不得出现 node_modules」门禁只放行了 game-runtime/node/node_modules/npm,但 recursive readdir 会把目录项 game-runtime/node/node_modules 也列出来,于是合法包必然断言失败
- 这条门禁是 #439 引入的,之后每次构建都死在更早的阶段(staging/测试),直到 2026-09-21 arm64 单架构跑通才第一次走到它:12:58、13:24、13:43 三轮都挂在这里
- 修复:放行 npm 目录自身、其子树与它的上级 node_modules 容器目录;已用真实 arm64 产物跑通完整 smoke(资源、架构、摘要、权限、Codex 正式查找、app-server 握手、缺组件拒绝全部通过)
2026-09-21 13:50:32 +08:00
suzmii 4044ffd89b 修复 Mac 发布 Job 手工触发时 Checkout 因未定义参数失败
- 手工「立即构建」不会注入可选参数(COMMIT_HASH / SKIP_IF_SUPERSEDED 未定义而非空值),set -u 下 `[ -n "$COMMIT_HASH" ]` 直接以 unbound variable 退出,Job 2026-09-21 12:54 那一轮就是这样在 Checkout 失败的
- 两段 Checkout 脚本开头统一兜底:SOURCE_BRANCH 默认 master、COMMIT_HASH 默认空、SKIP_IF_SUPERSEDED 默认 false,调度器显式传参时行为不变(仍按 pinned commit 构建并判让位)
2026-09-21 13:50:32 +08:00
suzmii 8d0c021126 修正 Mac 发布文档口径:自动触发与描述原文
- 运维文档原写「不设置 trigger;不接入现有每小时版本调度」,与现状矛盾:调度 Job 会先发号再把 SOURCE_BRANCH/COMMIT_HASH/AGC_UPDATE_CHANNEL/AGC_RELEASE_VERSION/SKIP_IF_SUPERSEDED 透传给 Mac Job,正常发布不需要手工点构建
- 把 Jenkins Job 描述与节点描述的当前原文(arm64 单架构 + 由调度器自动触发 + dry-run 默认关)写进文档,并要求每次改口径同步 Jenkins 侧描述,避免描述停留在 universal/手动触发的旧口径
2026-09-21 13:50:32 +08:00
kdletters 1cb91f9444 修复画布绑定查询与生成失败归因
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
- 画布绑定前置的项目列表查询改为 view=summary,站内与外部路由统一支持摘要视图(缺省 full 兼容,未知取值 400)
- 项目摘要投影收敛到 editor_project,外部 API 与 MCP 复用同一份实现,摘要不做内联媒体修复也不回传画布与全量资源
- 外部编辑器请求失败文案补 kind 语义与底层因链,区分请求超时、正文截断与非法 JSON,不拼接 URL
- 远端资源编辑终态文案带出稳定失败码并指向移出恢复队列这一唯一出口,上游原文仍不写入账本
- 补齐站内摘要视图与画布测试夹具的请求断言用例
- 更新共享决策记录与踩坑记录(图集切片上限条目归上游 #442,本提交不重复)
2026-09-21 13:33:23 +08:00
kdletters 2e31a485d8 修复 Stdb publish 事务确认超时被误判为失败
Project CI / AI game creator shell Rust crates (push) Successful in 3m0s
Project CI / AI game creator shell Rust smoke (push) Successful in 4m0s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m41s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 8m44s
Project CI / Backend tests (push) Successful in 8m52s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
- production-stdb-publish 新增 run_spacetime_publish:仅在出现 timeout waiting for transaction confirmation 时重试一次同版本 publish(SpacetimeDB 对已生效的同版本为幂等),重试成功即视为模块已在位,不再把生产留在维护态
- 其它失败原因保持原有失败关闭语义;生产运维门禁断言补上该重试口径
2026-09-21 13:27:37 +08:00
suzmii 4ad131d74d Merge pull request 'AGC 画布:锚点让位标题栏、生成浮层贴边、拖动误触点击、读秒 100ms 与图集参考口径' (#448) from fix/agc-canvas-bugfix into master
Project CI / AI game creator shell Rust smoke (push) Successful in 3m58s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m26s
Project CI / AI game creator shell Rust crates (push) Successful in 1m40s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 8m43s
Project CI / Frontend tests (push) Failing after 5m41s
Project CI / Backend tests (push) Successful in 7m29s
Project CI / Repository checks (push) Successful in 4m50s
Project CI / Native shell tests (push) Successful in 9m32s
Project CI / AI game creator shell web tests (push) Has been cancelled
Reviewed-on: #448
2026-09-21 13:04:11 +08:00
kdletters 886bdc06ed 去掉渠道环境变量用例里重复的 stageRuntime
Project CI / AI game creator shell Rust crates (push) Successful in 2m44s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m37s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m56s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 7m58s
Project CI / Backend tests (push) Successful in 6m33s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
- 两次独立修复(#444 与本仓库 55cb04765)在同一处各加了一个 stageRuntime,合并后成为重复对象键,Web-Build 的 ESLint(no-dupe-keys) 失败
- 保留一条带说明的 stageRuntime stub,用例语义不变
2026-09-21 12:49:34 +08:00
suzmii 562e1ba586 「生成任务」侧栏读秒改为 100ms 粒度:与对话侧共用同一份时钟
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m57s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 4m2s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m15s
Project CI / Repository checks (pull_request) Failing after 20s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m16s
Project CI / Frontend tests (pull_request) Successful in 7m30s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m1s
Project CI / Native shell tests (pull_request) Successful in 11m1s
- ResourceCanvasAssetGenerationTasksPanelView:删掉面板自带的 1000ms interval 与 nowMillis 状态;两条任务行的「已耗时」下沉为叶子组件(AssetGenerationElapsedLabel / ResourceEditElapsedLabel),各自订阅共享 hook useLiveNow(LIVE_TIMER_TICK_MS = 100)。
- 为什么不是面板级时钟:面板上通常同时挂着多条任务,100ms 的 tick 落在面板上等于每 0.1 秒重建整个侧栏;useLiveNow 的注释明确要求调用方是叶子节点。终态行不订阅,已耗时定格在账本给的结束时间上。
- 行为:不足一分钟的耗时本来就显示到 0.1 秒(formatElapsedDuration),现在每 100ms 真的更新一次,不再是「带小数却 1 秒跳一格」。
- 用例:新增「读秒按 100ms 刷新(0.0 → 0.1 → 0.5 秒)」「终态行不再走表,定格在结束时间」「秒表只订在运行中的那一行上(setInterval 只被调用一次)」;把 LIVE_TIMER_TICK_MS 临时改回 1000 时第一条用例即红,粒度确实被钉住。
2026-09-21 12:48:21 +08:00
suzmii e18067e028 Merge pull request 'macOS 发布改为只出 arm64 单架构(修复 Mac 管线 #7 起连续失败)' (#447) from feat/agc-macos-arm64-only into master
Project CI / AI game creator shell Rust smoke (push) Successful in 2m11s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Reviewed-on: #447
2026-09-21 12:38:03 +08:00
suzmii 613d62fdb2 同步里程碑计划:macOS 清单只登记 darwin-aarch64
Project CI / AI game creator shell Rust crates (pull_request) Failing after 53s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m52s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 7m40s
Project CI / Repository checks (pull_request) Failing after 2m0s
Project CI / Backend tests (pull_request) Successful in 7m42s
Project CI / Native shell tests (pull_request) Successful in 8m47s
Project CI / Frontend tests (pull_request) Successful in 6m54s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m1s
- docs/project-memory/plans 的渠道化里程碑仍写着 dev-mac universal 同时写两个平台键,与 2026-09-21 的单架构决策冲突,按当前口径更新并指向决策记录
2026-09-21 12:35:36 +08:00
suzmii b4ce6877c8 修掉「拖动占位卡仍触发点击」的真因:点击抑制的计时起点必须在松手那一刻
- useResourceCanvasGenerationPlaceholders:抑制状态在越过拖动阈值时只登记、不起计时器,改到 pointerup 才起 0ms 计时(deferDragClickSuppressionCleanup);pointercancel 与切项目走 clearDragClickSuppression 立刻收干净。
- 原实现(越过阈值时就起 0ms 计时器)在真实鼠标下不成立:一次拖动里 pointermove 横跨多轮宏任务,计时器早在松手前就把抑制清掉,收尾那次 click 照样生效——jsdom 同步发 move/up 看不见这一档,所以上一版用例是假绿。
- 用例补上真实时序:拖动过程中显式让时钟走一轮宏任务再松手;宿主用例还补上浏览器在 pointerup 之后补发的 lostpointercapture,并断言它不能把这次拖动的抑制一起清掉。
- 敏感性已双向验证:把计时器搬回「越过阈值那一刻」、以及让 lostpointercapture 清抑制,两条用例都会红;改用例前的同步版本对两种错法都会绿(所以上一版没抓住)。
- 注释写明计时起点为什么是松手那一刻,并与资源卡链路的 deferSkippedResourceCardClickCleanup 对齐口径。
2026-09-21 12:34:47 +08:00
suzmii e53992fdc2 macOS 发布改为只出 arm64 单架构
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
- build-macos-ci 固定 aarch64-apple-darwin:单架构构建、只跑 arm64 隔离 smoke、首装包命名 <产品名>_<版本>_aarch64.dmg,构建清单记录 smokes 与实际清单平台键
- stage-node-runtime 的 universal-apple-darwin 恢复失败关闭并说明原因:随包 Node 只有单架构官方发行版,按宿主架构放行会让 Intel 上这份侧车不可执行,且通用包自检按 process.arch 校验抓不到
- 守卫用例改为断言入口固定 arm64 目标与 _aarch64.dmg 后缀、universal 目标必须抛错
- Jenkinsfile 与三份技术/运维文档同步单架构口径:清单只登记 darwin-aarch64,Intel 非目标,恢复 universal 的前置条件写在文档里
- 共享记忆补 2026-09-21 决策记录,pitfalls 记下「只带宿主架构的通用包」陷阱,避免再次用过渡实现糊过去
2026-09-21 12:30:56 +08:00
suzmii a5ee06a4c7 Merge pull request '修复macOS主机上AGC发布测试未stub Node运行时staging' (#444) from fix/agc-test-node-runtime-stub into master
Project CI / AI game creator shell Rust smoke (push) Successful in 4m16s
Project CI / AI game creator shell Rust crates (push) Successful in 1m6s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m51s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 8m45s
Project CI / Repository checks (push) Failing after 1m55s
Project CI / Backend tests (push) Successful in 6m33s
Project CI / Native shell tests (push) Successful in 7m47s
Project CI / Frontend tests (push) Successful in 5m49s
Project CI / AI game creator shell web tests (push) Has been cancelled
Reviewed-on: #444
2026-09-21 12:23:52 +08:00
suzmii 7b57cba14d 补齐「图集(生成图标素材)为什么没有 @」的当面说明与依据
- 生成浮层:不给 @ 选择器的入口新增一段说明行(`data-resource-canvas-generation-reference-free`),讲清「该入口走平台图集通道,参考只有一张权威规范图(图标规范),不支持另挑参考图」——验收现场那条「生成图标素材没法 @」读起来像漏了按钮,原因其实在通道上。
- 样式:说明行允许折行、沿用次要文字色;与既有的「参考图 n/5」计数行区分开。
- 用例:图集入口断言说明行在场;逐条入口断言「说明行」与「@ 选择器」严格互补;原「不呈现参考选择」的判据从按词匹配改成按计数元素/计数值匹配(说明行本身会提到「参考图」三个字)。
- 文档:入口矩阵补「图集为什么没有 @」一条,写明图集走 `icon-spritesheets` 通道、提交体只有 referenceId + iconDescriptions(无 referenceImageSrcs)、结果绑定硬校验参考恰好一张,以及「平台 OpenAPI 该请求 schema 有 referenceImageSrcs 字段,放开属于产品 + 平台口径变更」。
2026-09-21 12:23:32 +08:00
kdletters 55cb047654 支持 macOS 通用包构建时的 Node 运行时暂存
Project CI / Frontend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
- stage-node-runtime 的 targetRuntime 支持 universal-apple-darwin:在 macOS 宿主上按宿主架构暂存官方 Node(通用包自检按 process.arch 校验),非 darwin 宿主仍失败关闭
- 同步 stage-node-runtime 测试期望:darwin 宿主可用、linux 宿主仍拒绝,Universal Node 合并(lipo)留作未完成项
- 共享记忆记录该限制:当前通用包侧车仍是宿主架构,Intel Mac 需等待真正的通用 Node
2026-09-21 12:15:28 +08:00
suzmii beebcca4de 修复 AGC 画布四项验收问题:锚点让位标题栏、浮层不再撞画布边缘、拖动不再触发点击、图标规范参考口径对齐
- 生成任务锚点新增「资源总览」档:资源总览页仍贴画布顶边内缩 0.85rem,资源栏目画布页让开顶部钉住的整宽栏目标题栏(42px + 0.5rem),两档切换走 margin-top 过渡(减动效下关掉)。
- 生成浮层的顶边与可用高度改为按占位卡的真实屏幕位置算(卡下沿到安全底边),不再假设卡片贴在安全带上沿——占位落到下半屏时浮层底边不再越过画布下沿被切掉。
- 生成浮层新增水平收口:按「浮层左右各留 12px」把锚点收进画布可视范围,卡片靠边时浮层不再有一半落到画布之外;宽度口径与面板 CSS 同源(min(560px, 100% - 24px))。
- 新增画布卡片手势的共享判据 resourceCanvasGestureExceededDragThreshold(阈值仍取 RESOURCE_CANVAS_DRAG_THRESHOLD):资源卡与生成占位卡共用同一份「拖动还是点击」判断。
- 生成占位卡修掉拖动收尾误触点击:越过阈值才算拖动、登记一次性点击抑制(consumeDragClick)由宿主消费,「拖动中」样式也从越过阈值那一刻起才亮。
- 生成占位卡的删除按钮起手手势不再被当成「点卡片」:按起手点判定,松手落回卡片时不触发开合生成浮层。
- 图标规范面板的参考选择注释按现行判据改写(只有图集 icon-spritesheet 不接受用户参考),入口矩阵文档同步补「参考图(@ 引用)」口径并修掉「本地 IPC 没有参考图入参」的旧说法。
- 补齐回归用例:手势模型判据、占位卡拖动/点击与阈值、宿主级「拖完不弹浮层且普通点击仍开合」、浮层顶边与水平收口、锚点分档与跨文件标题栏高度守卫、逐条生成入口的 @ 与判据一致、图标规范面板的 @ 与提交带引用。
- 共享记忆 pitfalls.md 记录两条排障口径:卡类手势必须只有一份阈值与点击抑制判据;画布浮层几何只能按真实屏幕位置算、分页锚点必须让开钉死的标题栏。
2026-09-21 11:58:13 +08:00
kdletters 9bf35e8745 修复 macOS 构建上的渠道环境变量用例宿主依赖
Project CI / AI game creator shell Rust crates (push) Successful in 2m58s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m50s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m0s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 8m5s
Project CI / Backend tests (push) Successful in 7m2s
Project CI / Native shell tests (push) Successful in 8m12s
Project CI / Repository checks (push) Successful in 4m8s
Project CI / Frontend tests (push) Successful in 5m15s
Project CI / AI game creator shell web tests (push) Failing after 6m13s
- build-release.test.mjs 的「packaged renderer receives the same channel as the updater manifest」用 windows target 却未注入 stageRuntime,真实 staging 要求宿主 Node 平台/架构与目标一致,导致 macOS(arm64)/Linux 上必然失败
- 该用例只断言传给 spawn 的 VITE_AGC_PLATFORM_CHANNEL,改为注入空 stageRuntime,与同文件其它用例一致
2026-09-21 11:53:32 +08:00
kdletters 90207bc1d5 minimal 备份增加 freeze 副本路径
Project CI / AI game creator shell Rust smoke (push) Successful in 2m35s
Project CI / AI game creator shell Rust crates (push) Successful in 1m14s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m4s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 7m28s
Project CI / Backend tests (push) Successful in 7m25s
Project CI / Frontend tests (push) Successful in 7m18s
Project CI / Repository checks (push) Successful in 5m51s
Project CI / Native shell tests (push) Successful in 9m16s
Project CI / AI game creator shell web tests (push) Successful in 3m41s
- database-backup-to-oss.mjs 新增 --freeze-dir:minimal 模式先把选中子集 rsync 成冻结副本(保持原始相对路径)再从副本上传,避免热备时最新 commitlog 持续追加触发 stat 漂移失败
- files-minimal systemd 模板与发布前 minimal 备份默认传入 freeze 目录
- 生产运维门禁同步 freeze 口径断言
2026-09-21 11:37:14 +08:00
kdletters 2f248ea0c3 新增 minimal 备份口径并切换 release 定时备份
Project CI / Frontend tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
- database-backup-to-oss.mjs 新增 --minimal/--retain-snapshots:按上游 retention 语义只备份最近 N 份 snapshot、覆盖最老保留 snapshot 的 commitlog 边界段及其后全部段,外加身份/配置/控制库/模块字节,且不停止任何服务
- 备份检查脚本补充 minimal 计划用例(保留次新+最新 snapshot、丢弃更早 snapshot 与 clog 段、状态路径齐全、保留数校验)
- 发布前备份默认走 minimal 热备(GENARRATIVE_STDB_PUBLISH_BACKUP_MINIMAL=0 可回到冷备),不再需要 44.7GiB 冷备空间
- Server-Provision 新增 DATABASE_BACKUP_PROFILE=files-minimal 与 systemd drop-in,release 推荐使用并继续禁止 files-history
- 运维文档与共享记忆记录上游 retention 依据、实测 40G→3.6G 与不停服热备口径
2026-09-21 11:33:39 +08:00
kdletters 117d482f93 发布前备份空间预检前置并支持自动降级
Project CI / AI game creator shell Rust smoke (push) Successful in 2m13s
Project CI / AI game creator shell Rust crates (push) Successful in 1m7s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m9s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m57s
Project CI / Native shell tests (push) Successful in 7m58s
Project CI / Repository checks (push) Successful in 5m13s
Project CI / Frontend tests (push) Successful in 7m19s
Project CI / Backend tests (push) Successful in 10m10s
Project CI / AI game creator shell web tests (push) Successful in 4m47s
- database-backup-to-oss.mjs 新增 --check-space-only:archive 按 data×1.1、files 按 max(data×0.05, 2GiB) 计算门槛,空间不足以退出码 3 返回
- production-stdb-publish.sh 在进入维护与停服务之前先做空间预检,archive 不足时自动降级 files(files 不支持 defer,async 收敛为 sync),可用环境变量关闭
- 尚未开始 publish 的失败自动恢复本次停掉的 API/controller/worker 并退出维护,只有真正开始发布之后的失败才保持维护态
- 备份检查脚本与生产运维门禁补上新口径与执行顺序断言,运维文档与共享记忆记录事故与规则
2026-09-21 10:57:29 +08:00
227 changed files with 28770 additions and 1317 deletions
@@ -3,12 +3,15 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
createAdminAccount,
executeAdminRechargeRefund,
getAdminAgcTemplates,
getAdminFeatureGateConfig,
getAdminUserDetail,
importAdminAgcTemplates,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
updateAdminAccount,
updateAdminAgcTemplate,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig,
upsertProfileWalletConfig,
@@ -18,6 +21,95 @@ afterEach(() => {
vi.unstubAllGlobals();
});
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
const library = { revision: 'revision-new', writable: true, templates: [] };
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: library }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
expect(await getAdminAgcTemplates('admin-token', controller.signal)).toEqual(
library,
);
const update = {
expectedRevision: 'revision-old',
title: '空白模板',
summary: '简介',
tags: ['2D'],
enabled: true,
cover: { contentType: 'image/png', dataBase64: 'aW1hZ2U=' },
};
expect(
await updateAdminAgcTemplate('admin-token', 'template/1', update),
).toEqual(library);
expect(fetchMock.mock.calls[0]).toEqual([
'/admin/api/agc-templates',
expect.objectContaining({
method: 'GET',
signal: controller.signal,
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
}),
]);
expect(fetchMock.mock.calls[1]).toEqual([
'/admin/api/agc-templates/template%2F1',
expect.objectContaining({
method: 'PUT',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Content-Type': 'application/json',
}),
body: JSON.stringify(update),
}),
]);
});
test('模板批量导入走 multipart,不预设 JSON Content-Type', async () => {
const imported = {
revision: 'rev-2',
writable: true,
templates: [],
imported: [
{
id: 'alpha',
templateVersion: '0.1.0',
zipSizeBytes: 4,
zipSha256: 'a'.repeat(64),
reusedObjects: false,
},
],
};
const fetchMock = vi
.fn()
.mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: imported }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const form = new FormData();
form.append(
'manifest',
JSON.stringify({ expectedRevision: 'rev-1', templates: [] }),
);
form.append(
'zip_0',
new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }),
);
expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('/admin/api/agc-templates/import');
expect(init.method).toBe('POST');
expect(init.body).toBe(form);
expect(init.headers).not.toHaveProperty('Content-Type');
expect(init.headers.Authorization).toBe('Bearer admin-token');
});
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
+37
View File
@@ -1,5 +1,6 @@
import type {
AdminAccountListResponse,
AdminAgcTemplateLibraryResponse,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
@@ -29,6 +30,7 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminImportAgcTemplatesResponse,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
@@ -47,6 +49,7 @@ import type {
AdminTrackingEventListResponse,
AdminUpdateAccountRequest,
AdminUpdateAccountResponse,
AdminUpdateAgcTemplateRequest,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
@@ -85,6 +88,8 @@ interface AdminRequestOptions {
method?: string;
token?: string;
body?: unknown;
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
formData?: FormData;
headers?: Record<string, string>;
signal?: AbortSignal;
}
@@ -172,6 +177,8 @@ export async function request<T>(
if (typeof options.body !== 'undefined') {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(options.body);
} else if (options.formData) {
init.body = options.formData;
}
const response = await fetch(buildRequestUrl(path), init);
@@ -1176,3 +1183,33 @@ export function saveAgcModelCatalog(
{ token, method: 'PUT', body },
);
}
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
token,
signal,
});
}
export function updateAdminAgcTemplate(
token: string,
id: string,
body: AdminUpdateAgcTemplateRequest,
) {
return request<AdminAgcTemplateLibraryResponse>(
`/admin/api/agc-templates/${encodeURIComponent(id)}`,
{ token, method: 'PUT', body },
);
}
/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */
export function importAdminAgcTemplates(token: string, formData: FormData) {
return request<AdminImportAgcTemplatesResponse>(
'/admin/api/agc-templates/import',
{
token,
method: 'POST',
formData,
},
);
}
+64
View File
@@ -1033,3 +1033,67 @@ export interface AdminAgcModelCatalog {
defaultModelId: string;
models: AdminAgcModel[];
}
export interface AdminAgcTemplatePayload {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
enabled: boolean;
coverUrl: string;
zipSizeBytes: number;
}
export interface AdminAgcTemplateLibraryResponse {
revision: string;
writable: boolean;
templates: AdminAgcTemplatePayload[];
}
export interface AdminUpdateAgcTemplateRequest {
expectedRevision: string;
title: string;
summary: string;
tags: string[];
enabled: boolean;
cover?: {
contentType: string;
dataBase64: string;
};
}
export interface AdminImportAgcTemplateItemPayload {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
entry: string;
zipField: string;
coverField: string;
}
export interface AdminImportAgcTemplatesManifest {
expectedRevision: string;
templates: AdminImportAgcTemplateItemPayload[];
}
export interface AdminImportAgcTemplateResult {
id: string;
templateVersion: string;
zipSizeBytes: number;
zipSha256: string;
reusedObjects: boolean;
}
export interface AdminImportAgcTemplatesResponse
extends AdminAgcTemplateLibraryResponse {
imported: AdminImportAgcTemplateResult[];
}
+7
View File
@@ -19,6 +19,7 @@ import {
} from '../auth/adminAuthStore';
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
@@ -294,6 +295,12 @@ export function AdminApp() {
{activeRouteId === 'agc-models' ? (
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{activeRouteId === 'agc-templates' ? (
<AdminAgcTemplatesPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-showcase' ? (
<AdminEditorShowcaseReviewPage
token={token}
+1
View File
@@ -53,6 +53,7 @@ const routeIcons = {
'project-snapshots': FolderArchive,
accounts: Users,
'agc-models': ListChecks,
'agc-templates': Images,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
@@ -147,3 +147,30 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
}),
).not.toContainEqual(route);
});
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
expect(adminRoutes).toContainEqual({
id: 'agc-templates',
label: '模板管理',
hash: '#agc-templates',
});
expect(resolveAdminRoute('#agc-templates')).toBe('agc-templates');
expect(routeHash('agc-templates')).toBe('#agc-templates');
expect(
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }).some(
(route) => route.id === 'agc-templates',
),
).toBe(true);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['agc-templates'],
}).map((route) => route.id),
).toEqual(['agc-templates']);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['editor-assets'],
}).some((route) => route.id === 'agc-templates'),
).toBe(false);
});
+2
View File
@@ -18,6 +18,7 @@ export type AdminRouteId =
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
| 'agc-templates'
| 'accounts';
export type AdminTabPermission = Exclude<
@@ -53,6 +54,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
hash: '#editor-generation-pricing',
},
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
+1
View File
@@ -1,3 +1,4 @@
import '@genarrative/shared/styles.css';
import './styles/admin.css';
import { StrictMode } from 'react';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,155 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import {
attachCoverFiles,
buildTemplateImportFormData,
deriveTemplateUploadRow,
parseTemplateTags,
sanitizeTemplateId,
TEMPLATE_IMPORT_MAX_BATCH,
type TemplateUploadRow,
validateTemplateUploadRows,
} from './adminAgcTemplateUploadModel';
function zipFile(name: string) {
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
type: 'application/zip',
});
}
function coverFile(name: string) {
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
type: 'image/png',
});
}
function row(zipName: string, patch: Partial<TemplateUploadRow> = {}) {
return { ...deriveTemplateUploadRow(zipFile(zipName)), ...patch };
}
describe('sanitizeTemplateId', () => {
it('lowercases, keeps whitelisted characters and drops traversal', () => {
expect(sanitizeTemplateId('Cocos Empty 2D.zip')).toBe('cocos-empty-2d');
expect(sanitizeTemplateId('../../evil.zip')).toBe('evil');
expect(sanitizeTemplateId('a..b.zip')).toBe('a.b');
expect(sanitizeTemplateId('__hidden__')).toBe('hidden__');
expect(sanitizeTemplateId(`${'x'.repeat(90)}.zip`)).toHaveLength(64);
});
});
describe('deriveTemplateUploadRow', () => {
it('starts from safe defaults so a batch only needs covers attached', () => {
const derived = row('my-template.zip');
expect(derived.id).toBe('my-template');
expect(derived.title).toBe('my-template');
expect(derived.runtime).toBe('html');
expect(derived.templateVersion).toBe('0.1.0');
expect(derived.entry).toBe('index.html');
expect(derived.coverFile).toBeNull();
});
});
describe('attachCoverFiles', () => {
it('matches covers by template id and ignores non-image files', () => {
const rows = [row('alpha.zip'), row('beta.zip')];
const covers = [
coverFile('alpha.png'),
coverFile('beta.webp'),
coverFile('notes.txt'),
];
const next = attachCoverFiles(rows, covers);
expect(next[0]?.coverFile?.name).toBe('alpha.png');
expect(next[1]?.coverFile?.name).toBe('beta.webp');
});
it('keeps an already matched cover when the new selection has no partner', () => {
const rows = [row('alpha.zip', { coverFile: coverFile('alpha.png') })];
const next = attachCoverFiles(rows, [coverFile('other.png')]);
expect(next[0]?.coverFile?.name).toBe('alpha.png');
});
});
describe('validateTemplateUploadRows', () => {
it('accepts a complete batch', () => {
const rows = [
row('alpha.zip', { coverFile: coverFile('alpha.png') }),
row('beta.zip', { coverFile: coverFile('beta.png'), runtime: 'cocos' }),
];
expect(validateTemplateUploadRows(rows)).toEqual({});
});
it('reports missing cover, duplicate id and invalid fields per row', () => {
const rows = [
row('alpha.zip'),
row('alpha.zip', { coverFile: coverFile('alpha.png'), key: 'second' }),
row('gamma.zip', {
coverFile: coverFile('gamma.png'),
runtime: 'docker',
templateVersion: 'Bad Version',
entry: '../escape.js',
title: ' ',
}),
];
const errors = validateTemplateUploadRows(rows);
expect(errors['alpha.zip']).toContain('缺少封面');
expect(errors.second).toContain('ID 在本批次内重复');
expect(errors['gamma.zip']).toContain('名称必须是 1-80 个字符');
});
it('rejects a batch larger than the server limit', () => {
const rows = Array.from(
{ length: TEMPLATE_IMPORT_MAX_BATCH + 1 },
(_, index) =>
row(`template-${index}.zip`, {
coverFile: coverFile(`template-${index}.png`),
}),
);
const errors = validateTemplateUploadRows(rows);
expect(Object.keys(errors)).toHaveLength(TEMPLATE_IMPORT_MAX_BATCH + 1);
expect(errors['template-0.zip']).toContain('单批最多上传');
});
});
describe('buildTemplateImportFormData', () => {
it('writes manifest field names and attaches zip / cover per row', () => {
const rows = [
row('alpha.zip', {
coverFile: coverFile('alpha.png'),
tags: '起步, 起步 2d',
title: ' Alpha ',
}),
row('beta.zip', { coverFile: coverFile('beta.png') }),
];
const form = buildTemplateImportFormData('a'.repeat(64), rows);
const manifest = JSON.parse(String(form.get('manifest')));
expect(manifest.expectedRevision).toBe('a'.repeat(64));
expect(manifest.templates).toHaveLength(2);
expect(manifest.templates[0]).toMatchObject({
id: 'alpha',
title: 'Alpha',
tags: ['起步', '2d'],
zipField: 'zip_0',
coverField: 'cover_0',
});
expect(manifest.templates[1]).toMatchObject({
id: 'beta',
zipField: 'zip_1',
coverField: 'cover_1',
});
expect((form.get('zip_0') as File).name).toBe('alpha.zip');
expect((form.get('cover_1') as File).name).toBe('beta.png');
});
it('parses tags with dedupe and caps them at the server limit', () => {
expect(parseTemplateTags(' a, ab c ')).toEqual(['a', 'b', 'c']);
const many = Array.from({ length: 20 }, (_, index) => `t${index}`).join(
',',
);
expect(parseTemplateTags(many)).toHaveLength(16);
});
});
@@ -0,0 +1,191 @@
import type {
AdminImportAgcTemplateItemPayload,
AdminImportAgcTemplatesManifest,
} from '../api/adminApiTypes';
/** 与服务端 module-assets 的导入上限保持一致;超出请分批或改走 CLI。 */
export const TEMPLATE_IMPORT_MAX_BATCH = 20;
export const TEMPLATE_UPLOAD_RUNTIMES = [
'html',
'unity',
'godot',
'cocos',
] as const;
const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u;
const ENTRY_PATTERN = /^(?![\\/:])(?!.*\.\.)[^\s\\:]+$/u;
const COVER_EXTENSION_PATTERN = /\.(png|jpe?g|webp)$/iu;
export interface TemplateUploadRow {
/** 稳定行标识:用 ZIP 文件名,重选文件后不会串行。 */
key: string;
zipFile: File;
coverFile: File | null;
id: string;
title: string;
summary: string;
tags: string;
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
entry: string;
}
/** 文件名 → 模板 ID:小写、只留白名单字符,并去掉 `..` 与开头的非字母数字。 */
export function sanitizeTemplateId(value: string) {
return value
.replace(/\.zip$/iu, '')
.trim()
.toLowerCase()
.replace(/\.{2,}/gu, '.')
.replace(/[^a-z0-9._-]+/gu, '-')
.replace(/^[^a-z0-9]+/u, '')
.slice(0, 64);
}
export function deriveTemplateUploadRow(zipFile: File): TemplateUploadRow {
const id = sanitizeTemplateId(zipFile.name);
return {
key: zipFile.name,
zipFile,
coverFile: null,
id,
title: id,
summary: '',
tags: '',
runtime: 'html',
engine: '',
engineVersion: '',
templateVersion: '0.1.0',
entry: 'index.html',
};
}
/** 封面按「与模板 ID 同名的图片」匹配,一次多选即可覆盖整批。 */
export function attachCoverFiles(
rows: TemplateUploadRow[],
coverFiles: File[],
): TemplateUploadRow[] {
const covers = new Map<string, File>();
for (const file of coverFiles) {
if (!COVER_EXTENSION_PATTERN.test(file.name)) continue;
const stem = sanitizeTemplateId(
file.name.replace(COVER_EXTENSION_PATTERN, ''),
);
if (!covers.has(stem)) covers.set(stem, file);
}
return rows.map((row) => {
const matched = covers.get(row.id.trim().toLowerCase()) ?? null;
return matched ? { ...row, coverFile: matched } : row;
});
}
export function parseTemplateTags(value: string) {
const seen = new Set<string>();
const tags: string[] = [];
for (const raw of value.split(/[,\s]+/u)) {
const tag = raw.trim();
if (!tag || seen.has(tag)) continue;
seen.add(tag);
tags.push(tag);
}
return tags.slice(0, 16);
}
/** 逐行校验:返回 row.key → 错误文案;空对象表示整批可以提交。 */
export function validateTemplateUploadRows(
rows: TemplateUploadRow[],
): Record<string, string> {
const errors: Record<string, string> = {};
const seen = new Set<string>();
const fail = (row: TemplateUploadRow, message: string) => {
if (!errors[row.key]) errors[row.key] = message;
};
if (rows.length === 0) return errors;
if (rows.length > TEMPLATE_IMPORT_MAX_BATCH) {
for (const row of rows) {
fail(row, `单批最多上传 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板`);
}
return errors;
}
for (const row of rows) {
const id = row.id.trim();
if (!TEMPLATE_ID_PATTERN.test(id)) {
fail(
row,
'ID 必须是 1-64 位小写字母、数字、点、下划线或连字符,且以字母数字开头',
);
} else if (seen.has(id)) {
fail(row, 'ID 在本批次内重复');
} else {
seen.add(id);
}
if (!row.title.trim() || row.title.trim().length > 80) {
fail(row, '名称必须是 1-80 个字符');
}
if (row.summary.trim().length > 1000) {
fail(row, '简介最多 1000 个字符');
}
if (!TEMPLATE_VERSION_PATTERN.test(row.templateVersion.trim())) {
fail(row, '版本号必须是 1-32 位小写字母、数字、点、下划线或连字符');
}
if (
!TEMPLATE_UPLOAD_RUNTIMES.includes(
row.runtime as (typeof TEMPLATE_UPLOAD_RUNTIMES)[number],
)
) {
fail(row, `运行时只能是 ${TEMPLATE_UPLOAD_RUNTIMES.join(' / ')}`);
}
if (!ENTRY_PATTERN.test(row.entry.trim())) {
fail(row, 'entry 必须是模板包内的相对路径');
}
if (!row.coverFile) {
fail(row, '缺少封面:请上传与模板 ID 同名的 PNG / JPEG / WebP');
}
}
return errors;
}
export function buildTemplateImportManifest(
expectedRevision: string,
rows: TemplateUploadRow[],
): AdminImportAgcTemplatesManifest {
return {
expectedRevision,
templates: rows.map((row, index) => {
const item: AdminImportAgcTemplateItemPayload = {
id: row.id.trim(),
title: row.title.trim(),
summary: row.summary.trim(),
tags: parseTemplateTags(row.tags),
runtime: row.runtime,
engine: row.engine.trim(),
engineVersion: row.engineVersion.trim(),
templateVersion: row.templateVersion.trim(),
entry: row.entry.trim(),
zipField: `zip_${index}`,
coverField: `cover_${index}`,
};
return item;
}),
};
}
export function buildTemplateImportFormData(
expectedRevision: string,
rows: TemplateUploadRow[],
): FormData {
const form = new FormData();
form.append(
'manifest',
JSON.stringify(buildTemplateImportManifest(expectedRevision, rows)),
);
rows.forEach((row, index) => {
form.append(`zip_${index}`, row.zipFile, row.zipFile.name);
if (row.coverFile) {
form.append(`cover_${index}`, row.coverFile, row.coverFile.name);
}
});
return form;
}
+131
View File
@@ -13,6 +13,137 @@
text-rendering: optimizeLegibility;
}
.admin-agc-templates {
min-width: 0;
}
.admin-agc-template-filters {
display: grid;
grid-template-columns: minmax(180px, 1fr) repeat(2, minmax(130px, 190px));
gap: 14px;
}
.admin-agc-template-table {
min-width: 850px;
}
.admin-agc-template-table td:nth-child(2) {
min-width: 230px;
max-width: 380px;
}
.admin-agc-template-cover {
display: block;
width: 88px;
height: 62px;
border-radius: 8px;
object-fit: cover;
background: #f8efe7;
}
.admin-agc-template-summary {
display: -webkit-box;
overflow: hidden;
margin: 8px 0;
color: #866954;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.admin-agc-template-tags,
.admin-agc-template-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.admin-agc-template-tags span {
padding: 3px 7px;
border-radius: 5px;
background: #f8efe7;
font-size: 12px;
}
.admin-agc-template-dialog {
border-radius: 10px;
}
.admin-agc-template-editor,
.admin-agc-template-conflict {
display: grid;
gap: 14px;
}
.admin-agc-template-cover-preview {
display: block;
width: min(100%, 300px);
max-height: 180px;
border-radius: 8px;
object-fit: contain;
background: #f8efe7;
}
.admin-agc-template-dialog .admin-confirm-backdrop {
z-index: 1100;
}
.admin-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.admin-agc-template-upload-dialog {
border-radius: 10px;
max-width: min(1180px, calc(100vw - 24px));
}
.admin-agc-template-upload-pickers {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.admin-agc-template-upload-picker {
display: grid;
gap: 6px;
font-size: 13px;
}
.admin-agc-template-upload-picker input[type='file'] {
width: 100%;
font-size: 12px;
}
/* 上传行数多时表格自己滚动,窄屏不撑坏整页布局。 */
.admin-agc-template-upload-scroll {
max-height: min(52vh, 520px);
overflow: auto;
}
.admin-agc-template-upload-file {
font-size: 12px;
word-break: break-all;
}
.admin-agc-template-upload-row-error {
margin-top: 4px;
color: #b3261e;
font-size: 12px;
}
@media (max-width: 680px) {
.admin-agc-template-filters {
grid-template-columns: minmax(0, 1fr);
}
.admin-agc-template-upload-dialog {
max-width: calc(100vw - 12px);
}
}
* {
box-sizing: border-box;
}
+2
View File
@@ -9,6 +9,7 @@
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "node scripts/build-release.mjs",
"release:upload": "node scripts/release-upload.mjs",
"nsis:prepare": "node scripts/ensure-nsis-toolset.mjs",
"skill-pack:check": "node scripts/check-skill-pack.mjs",
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
@@ -75,6 +76,7 @@
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@types/three": "^0.184.1",
"jszip": "^3.10.1",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"vitest": "^0.34.6"
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
import {
generateUpdateManifest,
prepareReleaseVersion,
resolveManifestPlatformKeys,
resolveReleaseContext,
resolveReleasePartition,
runTauriBuild,
@@ -20,10 +21,13 @@ import {
} from './verify-updater-signature.mjs';
/**
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 universal 包 → 双架构 smoke → 生成 universal DMG
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 arm64 单架构包 → arm64 隔离 smoke → 生成 arm64 DMG
* → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。
*
* 边界:
* - 只出 Apple Siliconarm64)单架构:清单只登记 `darwin-aarch64`。Intel 侧要可用,前提是随包 Node
* 也能按架构各带一份(`stage-node-runtime.mjs` 对 universal 目标失败关闭);在实现之前**不得**
* 把 arm64 产物登记成 `darwin-x86_64`,否则 Intel 客户端会装到跑不起来的包。
* - Apple 签名与公证暂缺:本入口剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,但**不能传
* `--no-sign`** —— 该标志同时会跳过 updater 的 minisign 签名,产物就没有 `.sig`
* 未签名 + 未公证必须显式记录而非静默通过;
@@ -94,11 +98,14 @@ process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
const dryRun = readReleaseDryRun();
process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
const context = resolveReleaseContext(['--target=universal-apple-darwin']);
// 单架构目标:清单侧 `resolveManifestPlatformKeys` 只为它登记 darwin-aarch64。
const macTarget = 'aarch64-apple-darwin';
const context = resolveReleaseContext([`--target=${macTarget}`]);
const partition = resolveReleasePartition(context.channel, context.target);
const version = await prepareReleaseVersion(context);
// 首装包名必须保持 `<产品名>_<版本>_universal.dmg`:清单侧按该后缀唯一匹配本次产物。
const firstInstallName = `${productName}_${version}_universal.dmg`;
// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
const firstInstallName = `${productName}_${version}_aarch64.dmg`;
// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则
// 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中);
@@ -117,7 +124,7 @@ for (const stale of [
}
const args = [
'--target=universal-apple-darwin',
`--target=${macTarget}`,
'--bundles',
'app',
'--ci',
@@ -132,16 +139,13 @@ const command = (binary, argv, options = {}) =>
runTauriBuild(args, context);
const app = path.join(context.bundleRoot, 'macos', appBundleName);
for (const architecture of ['arm64', 'x86_64']) {
command(process.execPath, [
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
app,
architecture,
'--universal',
]);
}
command(process.execPath, [
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
app,
'arm64',
]);
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_universal.dmg`。
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_aarch64.dmg`。
const dmgDirectory = path.join(context.bundleRoot, 'macos');
fs.mkdirSync(dmgDirectory, { recursive: true });
const dmg = path.join(dmgDirectory, firstInstallName);
@@ -170,7 +174,7 @@ const release = await generateUpdateManifest(context);
assert.equal(
path.resolve(release.downloadArtifact),
path.resolve(dmg),
'首装包必须锁定本次生成的 universal DMG',
'首装包必须锁定本次生成的 arm64 DMG',
);
// 上传前门禁:用产物里烘焙的公钥复核更新包签名。验不过就停在这里,绝不写 OSS。
@@ -266,8 +270,9 @@ fs.writeFileSync(
firstInstallSha256: dmgHash,
manifest: 'latest.json',
},
smokes: ['arm64', 'x86_64'],
intelSmoke: process.arch === 'arm64' ? 'Rosetta' : 'native',
// 单架构发布:只跑 arm64 隔离 smokeIntel 未支持(清单里没有 darwin-x86_64 键)。
smokes: ['arm64'],
manifestPlatformKeys: resolveManifestPlatformKeys(context.target),
},
null,
2,
@@ -13,7 +13,8 @@ import {
defaultEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
import { stageNodeRuntimeForTarget } from './stage-node-runtime.mjs';
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
import { stageNodeRuntime } from './stage-node-runtime.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
@@ -459,8 +460,7 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
export function runTauriBuild(
args = [],
context = resolveReleaseContext(args),
// 默认按发布目标 stage:universal 需要两份架构运行时,单架构目标行为不变。
{ spawn = spawnSync, stageRuntime = stageNodeRuntimeForTarget } = {},
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
) {
if (
explicitBuildTarget(args) &&
@@ -878,14 +878,19 @@ export async function buildRelease(
args = [],
{
prepareVersion = prepareReleaseVersion,
prepareToolset = prepareNsisToolsetForRelease,
build = runTauriBuild,
generateManifest = generateUpdateManifest,
} = {},
) {
const context = resolveReleaseContext(args);
if (!args.includes('--no-bundle')) await prepareVersion(context);
const bundling = !args.includes('--no-bundle');
if (bundling) await prepareVersion(context);
// Tauri bundler 下载 NSIS 工具链时不重试,网络截断会直接毁掉整次打包;
// 因此打包前先在 Windows 目标上预置(详见 nsis-toolset.mjs)。
if (bundling) await prepareToolset(context, { bundling });
build(args, context);
if (!args.includes('--no-bundle')) return generateManifest(context);
if (bundling) return generateManifest(context);
}
if (
@@ -590,6 +590,71 @@ test('no-bundle smoke skips version writes and manifest generation', async () =>
assert.deepEqual(steps, ['dev']);
});
test('Windows 打包在 Tauri 构建前预置 NSIS 工具链', async () => {
const events = [];
await buildRelease(['--target', windowsTarget], {
prepareVersion: () => {
events.push('version');
},
prepareToolset: (context, options) => {
events.push(`toolset:${context.target}:${options.bundling}`);
},
build: () => {
events.push('build');
},
generateManifest: () => {
events.push('manifest');
},
});
assert.deepEqual(events, [
'version',
`toolset:${windowsTarget}:true`,
'build',
'manifest',
]);
});
test('NSIS 工具链预置失败即失败关闭,不进入 Tauri 构建', async () => {
const events = [];
await assert.rejects(
buildRelease(['--target', windowsTarget], {
prepareVersion: () => {
events.push('version');
},
prepareToolset: () => {
throw new Error('NSIS 工具链预置失败:下载 nsis-3.11.zip 失败');
},
build: () => {
events.push('build');
},
generateManifest: () => {
events.push('manifest');
},
}),
/NSIS 工具链预置失败/u,
);
assert.deepEqual(events, ['version']);
});
test('--no-bundle 不预置 NSIS 工具链', async () => {
const steps = [];
await buildRelease(['--no-bundle', '--target', windowsTarget], {
prepareVersion: () => {
steps.push('version');
},
prepareToolset: () => {
steps.push('toolset');
},
build: () => {
steps.push('build');
},
generateManifest: () => {
steps.push('manifest');
},
});
assert.deepEqual(steps, ['build']);
});
test('release stages Node before Tauri and injects its resource mapping only for bundles', () => {
const context = resolveReleaseContext(['--target', windowsTarget]);
const events = [];
@@ -76,33 +76,6 @@ function run(command, args) {
return result;
}
/**
* 单独执行随包 Node 分片:分片架构与宿主一致时直接跑,不一致时用 `arch` 强制
* arm64 机器上的 x86_64 分片依赖 Rosetta,与 `.app` 双架构 smoke 的前提相同)。
*/
function runNodeSlice(directory, args) {
const native = process.arch === 'arm64' ? 'arm64' : 'x86_64';
const binary = path.join(directory, 'node');
const [command, argv] =
architecture === native
? [binary, args]
: ['/usr/bin/arch', [`-${architecture}`, binary, ...args]];
const result = spawnSync(command, argv, {
cwd: root,
env,
encoding: 'utf8',
timeout: 120_000,
maxBuffer: 1024 * 1024,
});
assert.ifError(result.error);
assert.equal(
result.status,
0,
`${directory} 随包 Node 执行失败:${result.stderr || result.stdout}`,
);
return result.stdout.trim();
}
/**
* APFS 上优先用 `ditto --clone`:整包按区块克隆,秒级完成且几乎不占额外空间。
* 跨卷或非 APFS 时回退到真实复制;两种路径都必须产出可独立改动的副本,
@@ -275,84 +248,37 @@ try {
}
}
assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md')));
// 随包 Node:单架构构建是扁平目录,universal 构建把两套架构运行时并列放进
// `game-runtime/node/<platform>-<arch>/`(与 Codex 侧车同形态,由 Rust 侧按运行架构选择)。
// 两套清单在本函数里都做完整性与架构校验;只执行与本次 smoke 架构一致的那一份,
// 另一份由另一次架构的 smoke 覆盖(build-macos-ci.mjs 会对 arm64 / x86_64 各跑一次)。
const nodeRoot = path.join(resources, 'game-runtime/node');
const nodeSlices = requireUniversal
? ['darwin-arm64', 'darwin-x64'].map((platform) => ({
platform,
directory: path.join(nodeRoot, platform),
}))
: [{ platform: null, directory: nodeRoot }];
for (const slice of nodeSlices) {
const { directory } = slice;
const nodeManifest = JSON.parse(
fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'),
const nodeManifest = JSON.parse(
fs.readFileSync(path.join(nodeRoot, 'manifest.json'), 'utf8'),
);
assert.equal(nodeManifest.schemaVersion, 'agc-node-runtime.v1');
assert.equal(nodeManifest.platform, 'darwin');
assert.equal(nodeManifest.arch, process.arch);
const runtimeFiles = fs
.readdirSync(nodeRoot, { recursive: true })
.filter(
(file) =>
fs.statSync(path.join(nodeRoot, file)).isFile() &&
file !== 'manifest.json',
);
assert.equal(nodeManifest.schemaVersion, 'agc-node-runtime.v1');
assert.equal(nodeManifest.platform, 'darwin');
if (slice.platform) {
// 目录名与清单架构必须一致:错位会让用户拿到跑不起来的运行时。
assert.equal(`darwin-${nodeManifest.arch}`, slice.platform);
assert.ok(
/^(arm64|x64)$/u.test(nodeManifest.arch),
`未知运行架构:${nodeManifest.arch}`,
);
} else {
assert.equal(nodeManifest.arch, process.arch);
}
const runtimeFiles = fs
.readdirSync(directory, { recursive: true })
.filter(
(file) =>
fs.statSync(path.join(directory, file)).isFile() &&
file !== 'manifest.json',
);
assert.deepEqual(
runtimeFiles.sort(),
Object.keys(nodeManifest.files).sort(),
slice.platform ?? 'flat',
);
for (const [file, digest] of Object.entries(nodeManifest.files)) {
assert.equal(await hashFile(path.join(directory, file)), digest, file);
}
assert.ok(nodeManifest.files['NODE-LICENSE']);
assert.ok(nodeManifest.files['node_modules/npm/LICENSE']);
fs.accessSync(path.join(directory, 'node'), fs.constants.X_OK);
// 二进制本身必须是本分片的单一架构:官方发行版不做 universal,lipo 能直接证明。
const sliceArchitecture = spawnSync(
'/usr/bin/lipo',
['-archs', path.join(directory, 'node')],
{ encoding: 'utf8' },
);
assert.equal(sliceArchitecture.status, 0, 'lipo -archs node');
assert.equal(
sliceArchitecture.stdout.trim(),
nodeManifest.arch === 'x64' ? 'x86_64' : 'arm64',
'随包 Node 的二进制架构必须等于清单架构',
);
// 只有与本次 smoke 架构一致的分片才执行;另一架构留给对应的那次 smoke。
if (
!requireUniversal ||
nodeManifest.arch === (architecture === 'arm64' ? 'arm64' : 'x64')
) {
assert.equal(
runNodeSlice(directory, ['--version']),
nodeManifest.nodeVersion,
`${slice.platform ?? 'flat'} node --version`,
);
assert.equal(
runNodeSlice(directory, [
path.join(directory, 'node_modules/npm/bin/npm-cli.js'),
'--version',
]),
nodeManifest.npmVersion,
`${slice.platform ?? 'flat'} npm --version`,
);
}
assert.deepEqual(runtimeFiles.sort(), Object.keys(nodeManifest.files).sort());
for (const [file, digest] of Object.entries(nodeManifest.files)) {
assert.equal(await hashFile(path.join(nodeRoot, file)), digest, file);
}
assert.ok(nodeManifest.files['NODE-LICENSE']);
assert.ok(nodeManifest.files['node_modules/npm/LICENSE']);
assert.equal(
run(path.join(nodeRoot, 'node'), ['--version']).stdout.trim(),
nodeManifest.nodeVersion,
);
assert.equal(
run(path.join(nodeRoot, 'node'), [
path.join(nodeRoot, 'node_modules/npm/bin/npm-cli.js'),
'--version',
]).stdout.trim(),
nodeManifest.npmVersion,
);
const plugin = path.join(resources, 'plugins/agc-cocos-editor');
for (const file of [
'plugin.json',
@@ -361,6 +287,13 @@ try {
]) {
assert.ok(fs.existsSync(path.join(plugin, file)), file);
}
// 随包 Node 的 npm 是包里唯一允许出现的 node_modules:除了 npm 目录自身与它的子项,
// 还要放行它的上级目录 `game-runtime/node/node_modules`recursive readdir 会列出目录项,
// 少了这一条会让整个门禁对合法包失败——#439 引入后一直没被跑到,直到 2026-09-21 才暴露)。
const allowedNodeModules = (file) =>
file === 'game-runtime/node/node_modules' ||
file === 'game-runtime/node/node_modules/npm' ||
file.startsWith('game-runtime/node/node_modules/npm/');
const packageFiles = fs.readdirSync(resources, { recursive: true });
assert.ok(
!packageFiles.some(
@@ -368,13 +301,7 @@ try {
/(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test(
file,
) ||
// 只有随包 Node 自带的 npm 允许出现 node_modules;两种布局都要放行:
// 单架构的 `game-runtime/node/node_modules/npm` 与 universal 的
// `game-runtime/node/<platform>-<arch>/node_modules/npm`。
(/(^|\/)node_modules(\/|$)/.test(file) &&
!/^game-runtime\/node\/((darwin-(arm64|x64))\/)?node_modules\/npm(\/|$)/u.test(
file,
)),
(/(^|\/)node_modules(\/|$)/.test(file) && !allowedNodeModules(file)),
),
);
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
@@ -0,0 +1,28 @@
// Jenkins Windows 预检入口:在数分钟的 Rust 编译之前完成 NSIS 工具链预置。
//
// 预置失败必须在此之前失败关闭,避免 bundler 用 `io: unexpected end of file`
// 把网络问题伪装成打包问题。
import { ensureNsisToolset, LOG_PREFIX } from './nsis-toolset.mjs';
// Jenkins 阶段用 `$ErrorActionPreference = 'Stop'` 执行 Powershell:重试告警走
// stderr 时可能被 PowerShell 当成终止错误,因此重试与进度一律写 stdout,只有
// 最终失败才写 stderr 并以退出码 1 失败关闭。
const logger = {
log: (message) => console.log(message),
warn: (message) => console.log(`${message}(将重试)`),
};
try {
const result = await ensureNsisToolset({ logger });
console.log(`${LOG_PREFIX} NSIS 工具链目录:${result.nsisDir}`);
console.log(`${LOG_PREFIX} NSIS 原始归档缓存:${result.cacheDir}`);
console.log(
result.reused
? `${LOG_PREFIX} NSIS 工具链复用已有目录,未访问网络`
: `${LOG_PREFIX} NSIS 工具链本次预置:${result.downloaded.join('、')}`,
);
} catch (error) {
console.error(`${LOG_PREFIX} NSIS 工具链预置失败:${error.message}`);
process.exit(1);
}
@@ -0,0 +1,342 @@
// Tauri Windows bundler 的 NSIS 工具链预置。
//
// 背景:`tauri build` 打 Windows NSIS 包时会现场从 GitHub 下载 `nsis-3.11.zip`
// 与 `nsis_tauri_utils.dll`(见 tauri-bundler `bundle/windows/nsis/mod.rs`)。
// 构建机每个检出(`git clean -fdx`)都会丢掉 `target/.tauri` 缓存,于是每次
// 发布都要重新下载;响应一旦被截断,bundler 只会报 `io: unexpected end of file`
// 整条流水线在 Rust 编译数分钟之后才失败。
//
// 这里在打包前用固定哈希 + 重试预置同一份工具链目录:bundler 检查到必需文件齐全
// 且 `nsis_tauri_utils.dll` 哈希一致后就不会再自行下载。原始归档(两个文件)
// 额外缓存在工作区之外,构建机重复构建时不再依赖 GitHub 连通性。
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import JSZip from 'jszip';
export const LOG_PREFIX = '[ai-game-creator-shell]';
/** bundler 把工具链解到 `<tools>/.tauri/NSIS``bundle.useLocalToolsDir: true`)。 */
export const NSIS_TOOLSET_DIR_NAME = 'NSIS';
export const NSIS_ARCHIVE_ASSET_NAME = 'nsis-3.11.zip';
export const NSIS_ARCHIVE_URL =
'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip';
export const NSIS_ARCHIVE_SHA1 = 'ef7ff767e5cbd9edd22add3a32c9b8f4500bb10d';
export const NSIS_ARCHIVE_TOP_LEVEL_DIR = 'nsis-3.11';
export const NSIS_TAURI_UTILS_ASSET_NAME = 'nsis_tauri_utils.dll';
export const NSIS_TAURI_UTILS_URL =
'https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll';
export const NSIS_TAURI_UTILS_SHA1 = '75197fee3c6a814fe035788d1c34ead39349b860';
export const NSIS_TAURI_UTILS_REQUIRED_FILE =
'Plugins/x86-unicode/additional/nsis_tauri_utils.dll';
/**
* 与 tauri-bundler 2.9.x 的 `NSIS_REQUIRED_FILES` 逐条对齐:少一条 bundler 就会
* 删掉整个目录重新下载,等于预置失效。升级 `@tauri-apps/cli` 时要同步核对。
*/
export const NSIS_REQUIRED_FILES = [
'makensis.exe',
'Bin/makensis.exe',
'Stubs/lzma-x86-unicode',
'Stubs/lzma_solid-x86-unicode',
NSIS_TAURI_UTILS_REQUIRED_FILE,
'Include/MUI2.nsh',
'Include/FileFunc.nsh',
'Include/x64.nsh',
'Include/nsDialogs.nsh',
'Include/WinMessages.nsh',
'Include/Win/COM.nsh',
'Include/Win/Propkey.nsh',
'Include/Win/RestartManager.nsh',
];
/** 需要预置的原始归档;测试可注入同结构描述替换其中的地址与哈希。 */
export const NSIS_ASSETS = [
{
assetName: NSIS_ARCHIVE_ASSET_NAME,
url: NSIS_ARCHIVE_URL,
sha1: NSIS_ARCHIVE_SHA1,
},
{
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
url: NSIS_TAURI_UTILS_URL,
sha1: NSIS_TAURI_UTILS_SHA1,
},
];
const DEFAULT_DOWNLOAD_ATTEMPTS = 4;
const DEFAULT_RETRY_DELAY_MS = 3000;
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 180_000;
export function defaultAppRoot() {
return fileURLToPath(new URL('..', import.meta.url));
}
export function resolveTauriToolsDir(appRoot = defaultAppRoot()) {
// 必须与 `src-tauri/tauri.windows.conf.json` 的 `bundle.useLocalToolsDir: true`
// 保持一致,否则预置的文件不在 bundler 的查找路径上。
return path.join(appRoot, 'src-tauri', 'target', '.tauri');
}
export function resolveNsisCacheDir(
env = process.env,
platform = process.platform,
) {
const explicit = env.AGC_TAURI_NSIS_CACHE_DIR?.trim();
if (explicit) return path.resolve(explicit);
// Jenkins Windows 节点以 SYSTEM 运行,ProgramData 稳定可写且不受工作区清理影响;
// 缓存里只有待解压的原始归档,不会从该目录执行任何程序。
if (platform === 'win32') {
const programData = env.ProgramData?.trim() || 'C:\\ProgramData';
return path.join(programData, 'genarrative', 'tauri-nsis-cache');
}
return path.join(os.homedir(), '.cache', 'genarrative', 'tauri-nsis-cache');
}
/** 与 tauri-bundler 相同的镜像开关语义,便于构建机绕过不可达的 GitHub。 */
export function resolveDownloadUrl(url, env = process.env) {
if (!url.startsWith('https://github.com/')) return url;
const template = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE?.trim();
const match =
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/releases\/download\/([^/]+)\/(.+)$/u.exec(
url,
);
if (template && match) {
return template
.replaceAll('<owner>', match[1])
.replaceAll('<repo>', match[2])
.replaceAll('<version>', match[3])
.replaceAll('<asset>', match[4]);
}
const base = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR?.trim();
if (base) return `${base.replace(/\/+$/u, '')}/${url}`;
return url;
}
export function sha1Of(data) {
return createHash('sha1').update(data).digest('hex');
}
function sha1OfFile(filePath) {
try {
return sha1Of(fs.readFileSync(filePath));
} catch {
return null;
}
}
export function verifyNsisToolset(
nsisDir,
{ utilsSha1 = NSIS_TAURI_UTILS_SHA1 } = {},
) {
const missing = NSIS_REQUIRED_FILES.filter(
(relativePath) => !fs.existsSync(path.join(nsisDir, relativePath)),
);
const hashMismatch =
missing.length === 0 &&
sha1OfFile(path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE)) !==
utilsSha1;
return { ok: missing.length === 0 && !hashMismatch, missing, hashMismatch };
}
/** 解析 zip 条目落盘位置,并拒绝 `../` 这类越界路径。 */
export function resolveArchiveEntryTarget(rootDir, entryName) {
const root = path.resolve(rootDir);
const target = path.resolve(root, entryName);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
throw new Error(`NSIS 归档包含越界路径:${entryName}`);
}
return target;
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function downloadBuffer(url, { fetchImpl, timeoutMs }) {
const response = await fetchImpl(url, {
redirect: 'follow',
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`.trim());
}
const data = Buffer.from(await response.arrayBuffer());
if (data.length === 0) throw new Error('响应为空');
return data;
}
async function downloadVerifiedAsset({
assetName,
url,
sha1,
env,
fetchImpl,
attempts,
retryDelayMs,
timeoutMs,
logger,
}) {
const downloadUrl = resolveDownloadUrl(url, env);
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const data = await downloadBuffer(downloadUrl, { fetchImpl, timeoutMs });
const actual = sha1Of(data);
if (actual !== sha1) {
throw new Error(`SHA1 不匹配(期望 ${sha1},实际 ${actual}`);
}
logger.log(
`${LOG_PREFIX} NSIS 工具链:已下载 ${assetName}${data.length} 字节,第 ${attempt} 次尝试)`,
);
return data;
} catch (error) {
lastError = error;
logger.warn(
`${LOG_PREFIX} NSIS 工具链:下载 ${assetName} 失败(第 ${attempt}/${attempts} 次):${error.message}`,
);
if (attempt < attempts) await sleep(retryDelayMs * attempt);
}
}
throw new Error(
`下载 ${assetName} 失败(已重试 ${attempts} 次):${lastError?.message ?? '未知错误'}\n` +
`下载地址:${downloadUrl}\n` +
`可先把该文件放入缓存目录(AGC_TAURI_NSIS_CACHE_DIR)或配置 ` +
`TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE 后重试。`,
);
}
async function ensureCachedAsset(options) {
const { assetName, sha1, cacheDir, logger } = options;
const cachePath = path.join(cacheDir, assetName);
if (sha1OfFile(cachePath) === sha1) {
logger.log(`${LOG_PREFIX} NSIS 工具链:命中缓存 ${cachePath}`);
return cachePath;
}
if (fs.existsSync(cachePath)) {
logger.warn(
`${LOG_PREFIX} NSIS 工具链:缓存文件校验失败,重新下载 ${cachePath}`,
);
}
const data = await downloadVerifiedAsset(options);
fs.mkdirSync(cacheDir, { recursive: true });
const tempPath = `${cachePath}.tmp-${process.pid}`;
fs.writeFileSync(tempPath, data);
fs.rmSync(cachePath, { force: true });
fs.renameSync(tempPath, cachePath);
return cachePath;
}
export async function extractNsisArchive(archivePath, destinationDir) {
const archive = await JSZip.loadAsync(fs.readFileSync(archivePath));
for (const [entryName, entry] of Object.entries(archive.files)) {
if (entry.dir) continue;
const target = resolveArchiveEntryTarget(destinationDir, entryName);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, await entry.async('nodebuffer'));
}
}
/**
* 预置 `target/.tauri/NSIS`:已就绪时零网络直接返回,否则用缓存或重试下载补齐。
* 返回结构用于测试与日志,不参与发布产物。
*/
export async function ensureNsisToolset({
appRoot = defaultAppRoot(),
toolsDir = resolveTauriToolsDir(appRoot),
cacheDir = resolveNsisCacheDir(process.env),
env = process.env,
fetchImpl = globalThis.fetch,
assets = NSIS_ASSETS,
attempts = DEFAULT_DOWNLOAD_ATTEMPTS,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS,
logger = console,
} = {}) {
const nsisDir = path.join(toolsDir, NSIS_TOOLSET_DIR_NAME);
// 生产路径下这里恒等于 bundler 固定的 `nsis_tauri_utils.dll` SHA1;测试注入
// 自己的归档描述时,校验口径必须与被注入的资产一致。
const missingAsset = [
NSIS_ARCHIVE_ASSET_NAME,
NSIS_TAURI_UTILS_ASSET_NAME,
].find((assetName) => !assets.some((asset) => asset.assetName === assetName));
if (missingAsset) throw new Error(`NSIS 资产描述缺少 ${missingAsset}`);
const utilsSha1 =
assets.find((asset) => asset.assetName === NSIS_TAURI_UTILS_ASSET_NAME)
?.sha1 ?? NSIS_TAURI_UTILS_SHA1;
const existing = verifyNsisToolset(nsisDir, { utilsSha1 });
if (existing.ok) {
logger.log(`${LOG_PREFIX} NSIS 工具链已就绪:${nsisDir}`);
return { nsisDir, toolsDir, cacheDir, reused: true, downloaded: [] };
}
logger.log(
`${LOG_PREFIX} NSIS 工具链需要预置:${nsisDir}` +
(existing.missing.length > 0
? `(缺少 ${existing.missing.length} 个文件)`
: '(哈希不符)'),
);
const downloadOptions = {
env,
fetchImpl,
attempts,
retryDelayMs,
timeoutMs,
cacheDir,
logger,
};
const assetPaths = {};
for (const asset of assets) {
assetPaths[asset.assetName] = await ensureCachedAsset({
...asset,
...downloadOptions,
});
}
fs.rmSync(nsisDir, { recursive: true, force: true });
await extractNsisArchive(assetPaths[NSIS_ARCHIVE_ASSET_NAME], toolsDir);
const extractedDir = path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR);
if (!fs.existsSync(extractedDir)) {
throw new Error(
`NSIS 归档结构不符合预期:${assetPaths[NSIS_ARCHIVE_ASSET_NAME]} 未解出 ${NSIS_ARCHIVE_TOP_LEVEL_DIR}`,
);
}
fs.renameSync(extractedDir, nsisDir);
const utilsTarget = path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE);
fs.mkdirSync(path.dirname(utilsTarget), { recursive: true });
fs.copyFileSync(assetPaths[NSIS_TAURI_UTILS_ASSET_NAME], utilsTarget);
const installed = verifyNsisToolset(nsisDir, { utilsSha1 });
if (!installed.ok) {
throw new Error(
`NSIS 工具链预置不完整:缺少 ${installed.missing.join(', ') || '无'}` +
`哈希不符=${installed.hashMismatch}`,
);
}
logger.log(`${LOG_PREFIX} NSIS 工具链预置完成:${nsisDir}`);
return {
nsisDir,
toolsDir,
cacheDir,
reused: false,
downloaded: assets.map((asset) => asset.assetName),
};
}
/** `buildRelease` 用:只在 Windows 目标且需要打包时预置 NSIS 工具链。 */
export async function prepareNsisToolsetForRelease(
context,
{ bundling = true, ...deps } = {},
) {
if (!bundling || !context.target.includes('windows')) return null;
return ensureNsisToolset(deps);
}
@@ -0,0 +1,331 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import JSZip from 'jszip';
import {
ensureNsisToolset,
extractNsisArchive,
NSIS_ARCHIVE_ASSET_NAME,
NSIS_ARCHIVE_TOP_LEVEL_DIR,
NSIS_REQUIRED_FILES,
NSIS_TAURI_UTILS_ASSET_NAME,
NSIS_TOOLSET_DIR_NAME,
prepareNsisToolsetForRelease,
resolveArchiveEntryTarget,
resolveDownloadUrl,
resolveNsisCacheDir,
resolveTauriToolsDir,
sha1Of,
verifyNsisToolset,
} from './nsis-toolset.mjs';
const appRoot = path.resolve(
path.dirname(new URL(import.meta.url).pathname),
'..',
);
const silentLogger = { log() {}, warn() {} };
function createSandbox() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'agc-nsis-toolset-'));
}
/** 与真实归档同构的最小 zip:只保留 bundler 必需文件。 */
async function createArchiveFixture(extraEntries = {}) {
const zip = new JSZip();
for (const relativePath of NSIS_REQUIRED_FILES) {
zip.file(
`${NSIS_ARCHIVE_TOP_LEVEL_DIR}/${relativePath}`,
`fixture:${relativePath}`,
);
}
for (const [name, contents] of Object.entries(extraEntries)) {
zip.file(name, contents);
}
return zip.generateAsync({ type: 'nodebuffer' });
}
function fixtureAssets({ archive, utils }) {
return [
{
assetName: NSIS_ARCHIVE_ASSET_NAME,
url: `https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/${NSIS_ARCHIVE_ASSET_NAME}`,
sha1: sha1Of(archive),
},
{
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
url: `https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/${NSIS_TAURI_UTILS_ASSET_NAME}`,
sha1: sha1Of(utils),
},
];
}
function fixtureFetch({ archive, utils, failures = 0 }) {
let remainingFailures = failures;
const calls = [];
const fetchImpl = async (url) => {
calls.push(url);
if (remainingFailures > 0) {
remainingFailures -= 1;
throw new Error('network truncated');
}
const body = url.includes(NSIS_TAURI_UTILS_ASSET_NAME) ? utils : archive;
return {
ok: true,
status: 200,
statusText: 'OK',
arrayBuffer: async () => body,
};
};
return { fetchImpl, calls };
}
test('NSIS 工具链目录与 Tauri useLocalToolsDir 配置保持一致', () => {
const config = JSON.parse(
fs.readFileSync(
path.join(appRoot, 'src-tauri', 'tauri.windows.conf.json'),
'utf8',
),
);
assert.equal(config.bundle.useLocalToolsDir, true);
assert.equal(
resolveTauriToolsDir(appRoot),
path.join(appRoot, 'src-tauri', 'target', '.tauri'),
);
});
test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => {
assert.equal(
resolveNsisCacheDir(
{ AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' },
'linux',
),
'/tmp/agc-cache',
);
assert.equal(
resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'),
path.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'),
);
assert.ok(
resolveNsisCacheDir({}, 'linux').endsWith(
path.join('.cache', 'genarrative', 'tauri-nsis-cache'),
),
);
});
test('下载地址支持 tauri bundler 的两套 GitHub 镜像开关', () => {
const url =
'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip';
assert.equal(resolveDownloadUrl(url, {}), url);
assert.equal(
resolveDownloadUrl(url, {
TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE:
'https://mirror.example.com/<owner>/<repo>/<version>/<asset>',
}),
'https://mirror.example.com/tauri-apps/binary-releases/nsis-3.11/nsis-3.11.zip',
);
assert.equal(
resolveDownloadUrl(url, {
TAURI_BUNDLER_TOOLS_GITHUB_MIRROR: 'https://mirror.example.com/',
}),
`https://mirror.example.com/${url}`,
);
});
test('工具链已就绪时零下载复用', async () => {
const sandbox = createSandbox();
const toolsDir = path.join(sandbox, '.tauri');
const cacheDir = path.join(sandbox, 'cache');
const archive = await createArchiveFixture();
const utils = Buffer.from('nsis-tauri-utils-dll');
const assets = fixtureAssets({ archive, utils });
await ensureNsisToolset({
toolsDir,
cacheDir,
assets,
fetchImpl: fixtureFetch({ archive, utils }).fetchImpl,
logger: silentLogger,
});
let fetchCalls = 0;
const reused = await ensureNsisToolset({
toolsDir,
cacheDir,
assets,
fetchImpl: async () => {
fetchCalls += 1;
throw new Error('工具链已就绪时不应访问网络');
},
logger: silentLogger,
});
assert.equal(reused.reused, true);
assert.deepEqual(reused.downloaded, []);
assert.equal(fetchCalls, 0);
assert.equal(reused.nsisDir, path.join(toolsDir, NSIS_TOOLSET_DIR_NAME));
});
test('冷启动时下载、校验、解压并落缓存,重跑走缓存', async () => {
const sandbox = createSandbox();
const toolsDir = path.join(sandbox, '.tauri');
const cacheDir = path.join(sandbox, 'cache');
const archive = await createArchiveFixture();
const utils = Buffer.from('nsis-tauri-utils-dll');
const assets = fixtureAssets({ archive, utils });
const { fetchImpl, calls } = fixtureFetch({ archive, utils });
const result = await ensureNsisToolset({
toolsDir,
cacheDir,
assets,
fetchImpl,
logger: silentLogger,
});
assert.deepEqual(calls.length, 2);
assert.deepEqual(result.downloaded, [
NSIS_ARCHIVE_ASSET_NAME,
NSIS_TAURI_UTILS_ASSET_NAME,
]);
assert.equal(
verifyNsisToolset(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), {
utilsSha1: sha1Of(utils),
}).ok,
true,
);
assert.equal(
fs.readFileSync(
path.join(
toolsDir,
NSIS_TOOLSET_DIR_NAME,
'Plugins/x86-unicode/additional/nsis_tauri_utils.dll',
),
'utf8',
),
'nsis-tauri-utils-dll',
);
// 第二次构建:清空工作区工具目录后仍应零下载恢复(模拟 Jenkins git clean -fdx)。
fs.rmSync(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), {
recursive: true,
force: true,
});
const offline = await ensureNsisToolset({
toolsDir,
cacheDir,
assets,
fetchImpl: async () => {
throw new Error('命中缓存时不应访问网络');
},
logger: silentLogger,
});
assert.equal(
verifyNsisToolset(offline.nsisDir, { utilsSha1: sha1Of(utils) }).ok,
true,
);
});
test('下载失败按次数重试,最终成功', async () => {
const sandbox = createSandbox();
const archive = await createArchiveFixture();
const utils = Buffer.from('dll');
const { fetchImpl, calls } = fixtureFetch({ archive, utils, failures: 2 });
const result = await ensureNsisToolset({
toolsDir: path.join(sandbox, '.tauri'),
cacheDir: path.join(sandbox, 'cache'),
assets: fixtureAssets({ archive, utils }),
fetchImpl,
attempts: 3,
retryDelayMs: 1,
logger: silentLogger,
});
assert.equal(result.downloaded.length, 2);
assert.equal(calls.length, 4);
});
test('哈希不匹配时报错并给出可操作提示', async () => {
const sandbox = createSandbox();
const { fetchImpl } = fixtureFetch({
archive: Buffer.from('corrupted'),
utils: Buffer.from('corrupted'),
});
await assert.rejects(
ensureNsisToolset({
toolsDir: path.join(sandbox, '.tauri'),
cacheDir: path.join(sandbox, 'cache'),
assets: [
{
assetName: NSIS_ARCHIVE_ASSET_NAME,
url: 'https://github.com/a/b/releases/download/1/n.zip',
sha1: 'deadbeef',
},
{
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
url: 'https://github.com/a/b/releases/download/1/n.dll',
sha1: 'deadbeef',
},
],
fetchImpl,
attempts: 2,
retryDelayMs: 1,
logger: silentLogger,
}),
/SHA1 不匹配/u,
);
assert.equal(
fs.existsSync(path.join(sandbox, 'cache', NSIS_ARCHIVE_ASSET_NAME)),
false,
);
});
test('归档越界路径与缺失必需文件都会失败关闭', async () => {
const sandbox = createSandbox();
assert.throws(
() =>
resolveArchiveEntryTarget(path.join(sandbox, 'extract'), '../escape.txt'),
/越界路径/u,
);
assert.equal(
resolveArchiveEntryTarget(path.join(sandbox, 'extract'), 'nsis-3.11/a/b'),
path.resolve(sandbox, 'extract', 'nsis-3.11/a/b'),
);
const emptyArchive = new JSZip()
.file(`${NSIS_ARCHIVE_TOP_LEVEL_DIR}/makensis.exe`, 'only-one')
.generateAsync({ type: 'nodebuffer' });
const emptyPath = path.join(sandbox, 'incomplete.zip');
fs.writeFileSync(emptyPath, await emptyArchive);
const toolsDir = path.join(sandbox, 'incomplete-tools');
await extractNsisArchive(emptyPath, toolsDir);
const status = verifyNsisToolset(
path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR),
);
assert.equal(status.ok, false);
assert.ok(status.missing.includes('Bin/makensis.exe'));
});
test('非 Windows 目标或 --no-bundle 不预置工具链', async () => {
let called = 0;
const deps = {
ensure: async () => {
called += 1;
},
};
assert.equal(
await prepareNsisToolsetForRelease(
{ target: 'x86_64-pc-windows-msvc' },
{ bundling: false, ...deps },
),
null,
);
assert.equal(
await prepareNsisToolsetForRelease(
{ target: 'aarch64-apple-darwin' },
deps,
),
null,
);
assert.equal(called, 0);
});
@@ -178,8 +178,12 @@ test('macOS release entry and smoke script derive product names from config and
assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名');
assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名');
assert.ok(
entry.includes('_${version}_universal.dmg'),
'首装包名必须保留清单侧唯一匹配所需的后缀',
entry.includes("const macTarget = 'aarch64-apple-darwin'"),
'macOS 发布入口必须固定单架构目标',
);
assert.ok(
entry.includes('_${version}_aarch64.dmg'),
'首装包名必须保留清单侧单架构分支唯一匹配所需的后缀(Tauri 口径 aarch64',
);
const smoke = fs.readFileSync(
@@ -6,13 +6,6 @@ import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
export const nodeRuntimeSchema = 'agc-node-runtime.v1';
// 单架构目标写扁平目录;universal 在此目录下按架构分目录,见 stageNodeRuntimeForTarget。
const defaultRuntimeDestination = path.join(
appRoot,
'src-tauri',
'resources',
'node-runtime',
);
export function readInstalledNodeLicense(
version,
@@ -72,117 +65,20 @@ export function targetRuntime(target) {
'aarch64-apple-darwin': ['darwin', 'arm64'],
'x86_64-apple-darwin': ['darwin', 'x64'],
};
const value = targets[target];
// universal 不是单份运行时能表达的目标:它必须按架构展开成两份,见 targetRuntimes。
if (!value)
throw new Error(
target === 'universal-apple-darwin'
? 'Node 运行时不能直接按 universal-apple-darwin 制作:双架构请用 stageNodeRuntimeForTarget 按架构展开'
: `Node 运行时不支持发布目标:${target}`,
);
return { platform: value[0], arch: value[1] };
}
/**
* 发布目标需要的随包运行时列表:universal 需要两份单架构运行时,
* 其余目标仍然是一份(保持既有扁平布局与行为)。
*/
export function targetRuntimes(target) {
// 随包 Node 是**单架构**官方发行版:一份运行时只服务它自己的架构。
// macOS 发布当前固定为 aarch64-apple-darwin 单架构包(Intel 未支持),
// universal 目标没有正确的运行时来源,必须失败关闭——绝不能退化成
// 「按宿主架构暂存一份 arm64」:那样通用包自检(按 process.arch)能过,
// 但 Intel 机器上这份运行时不可执行,用户拿到的是坏包。
if (target === 'universal-apple-darwin')
return ['aarch64-apple-darwin', 'x86_64-apple-darwin'];
return [target];
}
/**
* 按架构选择 staging 输入。
*
* 契约(见实施计划):发布包只从**本机已安装且与目标平台/架构一致**的工具链取材,
* 不得使用项目内或相对 PATH 的伪造运行时。宿主架构直接复用当前 Node;
* 其它架构必须由构建节点显式提供,缺失即失败关闭——不静默跳过、不回退系统 Node。
*/
export function runtimeSourceForTarget(
archTarget,
{
env = process.env,
nodePath,
npmCli,
licensePath,
hostNodePath = process.execPath,
hostNpmCli = process.env.npm_execpath,
hostLicensePath = env.AGC_NODE_LICENSE_PATH,
hostPlatform = process.platform,
hostArch = process.arch,
} = {},
) {
const native = targetRuntime(archTarget);
if (native.platform === hostPlatform && native.arch === hostArch) {
return {
nodePath: nodePath ?? hostNodePath,
npmCli: npmCli ?? hostNpmCli,
licensePath: licensePath ?? hostLicensePath,
};
}
const key = `${native.platform}_${native.arch}`.toUpperCase();
const configured = env[`AGC_NODE_RUNTIME_${key}_PATH`]?.trim();
if (!configured) {
throw new Error(
`缺少 ${native.platform}/${native.arch} 的 Node 运行时:该架构不是构建宿主,` +
`请先在本机安装同架构 Node,再用 AGC_NODE_RUNTIME_${key}_PATH 指向其 bin/node`,
'Node 运行时不支持 universal-apple-darwin:随包 Node 只有单架构发行版,' +
'通用包需按架构各带一份(另行下载另一架构官方发行版)之后才能构建;' +
'当前 macOS 发布固定为 aarch64-apple-darwin 单架构',
);
}
return {
nodePath: configured,
// 明令不使用宿主 npm`npm_execpath`):另一架构的 npm 必须来自它自己那份发行版
// 安装目录,否则两份切片的 npm 来源不同源,架构与来源对不上。null 表示"按 node 目录查找"。
npmCli: null,
licensePath: env[`AGC_NODE_RUNTIME_${key}_LICENSE_PATH`]?.trim(),
};
}
/**
* 按发布目标 stage 运行时资源。
*
* 单架构目标沿用既有扁平目录(`node-runtime/`),universal 目标写进
* `node-runtime/<platform>-<arch>/`——与捆绑 Codex 的分架构目录同一形态,
* 由 Rust 侧按当前运行架构选择;构建期资源映射仍是整目录映射,无需按架构分叉。
*
* 先解析并校验**全部**来源,再逐个落盘:非宿主架构的运行时缺失、平台/架构不符、
* 两套版本不一致,都在写出任何运行时目录之前失败,不留下半套资源冒充发布内容。
*/
export function stageNodeRuntimeForTarget(
target,
{ destination = defaultRuntimeDestination, ...options } = {},
) {
const targets = targetRuntimes(target);
const plans = targets.map((archTarget) => {
const native = targetRuntime(archTarget);
return {
destination:
targets.length === 1
? destination
: path.join(destination, `${native.platform}-${native.arch}`),
inspected: inspectRuntimeSource(archTarget, {
...options,
...runtimeSourceForTarget(archTarget, options),
}),
};
});
// 两个切片必须是同一套 Node:版本不一致意味着其中一份被换过,
// 用户在不同架构上会拿到行为不同的工具链。
const identities = new Set(
plans.map(
({ inspected }) =>
`${inspected.info.version}|${inspected.npmPackage.version}`,
),
);
if (identities.size !== 1) {
throw new Error(
`多架构 Node 运行时版本不一致:${[...identities].join(' / ')}`,
);
}
return plans.map(({ inspected, destination }) =>
writeRuntimeBundle(inspected, { destination }),
);
const value = targets[target];
if (!value) throw new Error(`Node 运行时不支持发布目标:${target}`);
return { platform: value[0], arch: value[1] };
}
function inside(root, file) {
@@ -327,19 +223,13 @@ export function assertPortableMacNode(output) {
}
}
/**
* 只读校验一份运行时来源,返回 staging 需要的全部事实。
*
* 与写盘分离的原因:多架构发布必须能在写出任何文件之前发现「另一份来源缺失或
* 与目标不符」。校验口径保持原样——平台/架构必须等于目标、macOS 二进制只能链接
* 系统动态库、npm 的身份与实际版本必须一致、许可必须来自发行版本体。
*/
export function inspectRuntimeSource(
export function stageNodeRuntime(
target,
{
nodePath = process.execPath,
npmCli = process.env.npm_execpath,
licensePath = process.env.AGC_NODE_LICENSE_PATH,
destination = path.join(appRoot, 'src-tauri', 'resources', 'node-runtime'),
execute = execFileSync,
installedLicense = readInstalledNodeLicense,
} = {},
@@ -374,7 +264,6 @@ export function inspectRuntimeSource(
);
}
const nodeDirectory = path.dirname(node);
// `npmCli: null` 表示显式拒绝沿用宿主 npm,只按这份 node 自己的安装目录查找。
const npmCandidates = [
npmCli,
path.join(nodeDirectory, 'node_modules/npm/bin/npm-cli.js'),
@@ -431,14 +320,6 @@ export function inspectRuntimeSource(
throw new Error('Node LICENSE 不包含发行许可');
if (!fs.statSync(path.join(npmRoot, 'LICENSE')).isFile())
throw new Error('npm 缺少 LICENSE');
return { native, node, npmRoot, npmPackage, info, license, licenseName };
}
/** 把已校验的来源写成一份发布资源,返回清单。 */
function writeRuntimeBundle(
{ native, node, npmRoot, npmPackage, info, license, licenseName },
{ destination },
) {
// 临时同级目录完成后才替换资源;不污染 Node 安装或项目工作区。
const requestedDestination = path.resolve(destination);
if (requestedDestination === path.dirname(requestedDestination))
@@ -524,26 +405,3 @@ function writeRuntimeBundle(
cleanupStaging(staging, parent, stagingPrefix, stagingIdentity);
}
}
export function stageNodeRuntime(
target,
{
nodePath = process.execPath,
npmCli = process.env.npm_execpath,
licensePath = process.env.AGC_NODE_LICENSE_PATH,
destination = defaultRuntimeDestination,
execute = execFileSync,
installedLicense = readInstalledNodeLicense,
} = {},
) {
return writeRuntimeBundle(
inspectRuntimeSource(target, {
nodePath,
npmCli,
licensePath,
execute,
installedLicense,
}),
{ destination },
);
}
@@ -9,9 +9,7 @@ import {
assertPortableMacNode,
readInstalledNodeLicense,
stageNodeRuntime,
stageNodeRuntimeForTarget,
targetRuntime,
targetRuntimes,
} from './stage-node-runtime.mjs';
function fixture(run) {
@@ -283,10 +281,10 @@ test('native target and macOS dynamic dependency policy reject nonportable Node'
platform: 'darwin',
arch: 'arm64',
});
// universal 不是单份运行时目标:必须报出「按架构展开」而不是笼统的「不支持」
// universal 必须失败关闭:只带宿主架构那一份运行时,Intel 上不可执行
assert.throws(
() => targetRuntime('universal-apple-darwin'),
/stageNodeRuntimeForTarget/u,
/universal-apple-darwin/u,
);
assertPortableMacNode(
'/node:\n\t/usr/lib/libSystem.B.dylib (compatibility version 1)\n',
@@ -299,164 +297,3 @@ test('native target and macOS dynamic dependency policy reject nonportable Node'
/非系统动态库/u,
);
});
// 双架构夹具:每个架构一份来源目录,execute 按被查询的二进制回报对应架构。
function universalFixture(run) {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), 'agc-node-universal-test-'),
);
const sourceFor = (arch, nodeVersion) => {
const dir = path.join(root, `source-${arch}`);
const npm = path.join(dir, 'node_modules/npm');
fs.mkdirSync(path.join(npm, 'bin'), { recursive: true });
fs.writeFileSync(path.join(dir, 'node'), `node-${arch}`);
fs.writeFileSync(
path.join(dir, 'LICENSE'),
'Node.js\nPermission is hereby granted',
);
fs.writeFileSync(path.join(npm, 'LICENSE'), 'npm distribution license');
fs.writeFileSync(
path.join(npm, 'package.json'),
JSON.stringify({ name: 'npm', version: '11.0.0' }),
);
for (const name of ['npm', 'npx'])
fs.writeFileSync(path.join(npm, `bin/${name}-cli.js`), `// ${arch}`);
return {
nodePath: path.join(dir, 'node'),
npmCli: path.join(npm, 'bin/npm-cli.js'),
licensePath: path.join(dir, 'LICENSE'),
nodeVersion,
};
};
try {
return run(root, {
arm64: sourceFor('arm64', 'v22.23.2'),
x64: sourceFor('x64', 'v22.23.2'),
});
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
function optionsFor(root, sources, overrides = {}) {
const archOf = (file) => (file.includes('source-x64') ? 'x64' : 'arm64');
return {
destination: path.join(root, 'resources/node-runtime'),
hostPlatform: 'darwin',
hostArch: 'arm64',
hostNodePath: sources.arm64.nodePath,
hostNpmCli: sources.arm64.npmCli,
env: {
AGC_NODE_RUNTIME_DARWIN_X64_PATH: sources.x64.nodePath,
AGC_NODE_RUNTIME_DARWIN_X64_LICENSE_PATH: sources.x64.licensePath,
},
installedLicense() {
throw new Error('no installed fixture license');
},
execute(file, args) {
if (file === '/usr/bin/otool')
return `\t/usr/lib/libSystem.B.dylib\n\t/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation\n`;
if (args[0] === '-p') {
const arch = archOf(file);
return JSON.stringify({
platform: 'darwin',
arch,
version: sources[arch].nodeVersion,
});
}
return '11.0.0';
},
...overrides,
};
}
test('universal stages one runtime per architecture with architecture-correct manifests', () =>
universalFixture((root, sources) => {
assert.deepEqual(targetRuntimes('universal-apple-darwin'), [
'aarch64-apple-darwin',
'x86_64-apple-darwin',
]);
// 模拟经 `npm run` 触发的构建:npm 把宿主的 npm-cli.js 放进环境变量。
// 另一架构的切片必须无视它,只能使用自己发行版目录里的 npm。
const previousNpmExecPath = process.env.npm_execpath;
process.env.npm_execpath = sources.arm64.npmCli;
let manifests;
try {
manifests = stageNodeRuntimeForTarget(
'universal-apple-darwin',
optionsFor(root, sources),
);
} finally {
if (previousNpmExecPath === undefined) delete process.env.npm_execpath;
else process.env.npm_execpath = previousNpmExecPath;
}
assert.deepEqual(
manifests.map((manifest) => `${manifest.platform}-${manifest.arch}`),
['darwin-arm64', 'darwin-x64'],
);
for (const manifest of manifests) {
const directory = path.join(
root,
'resources/node-runtime',
`${manifest.platform}-${manifest.arch}`,
);
assert.equal(
JSON.parse(
fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'),
).arch,
manifest.arch,
);
assert.ok(fs.existsSync(path.join(directory, 'node')));
assert.ok(
fs.existsSync(path.join(directory, 'node_modules/npm/LICENSE')),
);
// 每份切片必须自带对应架构发行版的 npm,不能借用宿主那一份。
assert.equal(
fs.readFileSync(
path.join(directory, 'node_modules/npm/bin/npm-cli.js'),
'utf8',
),
`// ${manifest.arch}`,
);
}
// 单架构目标仍写扁平目录(与既有发布一致),不产生分架构子目录。
const flat = stageNodeRuntimeForTarget(
'aarch64-apple-darwin',
optionsFor(root, sources, { destination: path.join(root, 'flat') }),
);
assert.equal(flat.length, 1);
assert.ok(fs.existsSync(path.join(root, 'flat/manifest.json')));
assert.ok(!fs.existsSync(path.join(root, 'flat/darwin-arm64')));
}));
test('universal fails closed when the non-host architecture runtime is absent', () =>
universalFixture((root, sources) => {
const options = optionsFor(root, sources, { env: {} });
assert.throws(
() => stageNodeRuntimeForTarget('universal-apple-darwin', options),
/AGC_NODE_RUNTIME_DARWIN_X64_PATH/u,
);
// 缺失时不得留下半成品目录。
assert.ok(!fs.existsSync(path.join(root, 'resources')));
}));
test('universal rejects mismatched Node versions between the two architectures', () =>
universalFixture((root, sources) => {
const options = optionsFor(root, sources);
options.execute = (file, args) => {
if (file === '/usr/bin/otool') return '\t/usr/lib/libSystem.B.dylib\n';
if (args[0] === '-p')
return JSON.stringify({
platform: 'darwin',
arch: file.includes('source-x64') ? 'x64' : 'arm64',
version: file.includes('source-x64') ? 'v22.23.2' : 'v24.0.0',
});
return '11.0.0';
};
assert.throws(
() => stageNodeRuntimeForTarget('universal-apple-darwin', options),
/版本不一致/u,
);
// 版本核对在写出任何架构之前完成,失败时不留下半套运行时。
assert.ok(!fs.existsSync(path.join(root, 'resources')));
}));
@@ -1006,16 +1006,58 @@ pub(crate) async fn external_editor_json_request(
let response = crate::http_client::with_agc_main_site_marker(request)
.send()
.await
.map_err(|error| format!("{action}失败:{error}"))?;
.map_err(|error| {
format!(
"{action}失败:{}",
describe_external_request_failure(&error)
)
})?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format_external_http_error(action, status, &body));
}
response
.json::<serde_json::Value>()
.await
.map_err(|error| format!("解析{action}响应失败:{error}"))
response.json::<serde_json::Value>().await.map_err(|error| {
format!(
"解析{action}响应失败:{}",
describe_external_request_failure(&error)
)
})
}
/// 把 reqwest 失败翻译成可现场定责的文案。
///
/// `reqwest::Error` 的 `Display` 只输出 kind:客户端预算内没读完正文(总超时)、
/// 正文被提前截断和正文不是合法 JSON 都会显示成同一句 `error decoding response body`
/// 现场无法区分是平台慢、链接慢还是响应被截断。这里补上 kind 语义与底层因链;
/// 因链只取错误文本,不拼接 URL,避免把绝对地址写进日志。
fn describe_external_request_failure(error: &reqwest::Error) -> String {
let kind = if error.is_timeout() {
"请求超时(连接、响应头或响应正文未在客户端预算内完成)"
} else if error.is_decode() {
"响应正文未完整返回或不是合法 JSON"
} else if error.is_body() {
"响应正文读取失败"
} else if error.is_connect() {
"连接失败"
} else if error.is_request() {
"请求发送失败"
} else {
"请求失败"
};
let mut causes: Vec<String> = Vec::new();
let mut source = std::error::Error::source(error);
while let Some(current) = source {
let text = current.to_string();
if !text.is_empty() && !causes.contains(&text) {
causes.push(text);
}
source = current.source();
}
if causes.is_empty() {
return kind.to_string();
}
format!("{kind}{}", causes.join(""))
}
/// Keep provider validation details useful to the operator without copying an
@@ -1443,10 +1485,14 @@ pub(crate) async fn prepare_external_canvas_generation_context(
let project_id = if let Some(project_id) = partial.remote_project_id.clone() {
project_id
} else {
// 这一步只需要按 projectId 确认绑定项目是否仍然存在,固定用摘要视图:
// 缺省 full 会把账号下每个项目的画布与全量资源都带回来,项目增长后会耗尽本次请求的
// 客户端预算,现场表现为「解析读取外部画布项目响应失败:error decoding response body」。
// 站内 `/api/editor/projects` 与 `/api/external/v1/editor/projects` 都支持该视图。
let projects_payload = external_editor_json_request(
client
.get(format!(
"{}{}",
"{}{}?view=summary",
access.api_base_url(),
access.api_route("/api/external/v1/editor/projects")
))
@@ -8535,6 +8581,28 @@ mod canvas_generation_tests {
);
}
/// reqwest 的 `Display` 只给 kind,超时 / 正文截断 / 非法 JSON 全都是同一句
/// `error decoding response body`。这个用例钉住「至少把 kind 语义换成人话」,
/// 避免再退回无法定责的原始文案。
#[tokio::test]
async fn external_request_failure_replaces_the_opaque_reqwest_kind() {
let error = reqwest::Client::new()
.get("http://127.0.0.1:1/healthz")
.timeout(Duration::from_secs(2))
.send()
.await
.expect_err("closed port must fail");
let described = describe_external_request_failure(&error);
// 该端口在真实环境里可能被直接拒绝、也可能被中间层吞掉直到超时,两种都必须能定责。
assert!(
described.contains("连接失败") || described.contains("请求超时"),
"{described}"
);
assert!(described.contains(""), "必须补上底层因链:{described}");
assert!(!described.contains("error sending request"), "{described}");
assert!(!described.contains("http://127.0.0.1:1"), "{described}");
}
fn read_test_http_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_nonblocking(false)
@@ -8843,7 +8911,8 @@ mod canvas_generation_tests {
}),
);
false
} else if request.starts_with("GET /api/external/v1/editor/projects ") {
} else if request.starts_with("GET /api/external/v1/editor/projects?view=summary ")
{
write_test_json_response(
&mut stream,
"200 OK",
@@ -9213,7 +9282,7 @@ mod canvas_generation_tests {
.expect("write concurrent generation png body");
return;
}
if request.starts_with("GET /api/external/v1/editor/projects ") {
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
// 项目绑定已由 `install_test_external_project_binding` 预置,远端只需回认同一组身份。
write_test_json_response(
stream,
@@ -9687,7 +9756,7 @@ mod canvas_generation_tests {
"Bearer token-b" => "b",
unexpected => panic!("unexpected authorization {unexpected}"),
};
let response = if request.starts_with("GET /api/editor/projects ") {
let response = if request.starts_with("GET /api/editor/projects?view=summary ") {
let projects = if project_created.get(account).copied().unwrap_or(false) {
vec![serde_json::json!({
"projectId": format!("remote-project-{account}"),
@@ -9853,7 +9922,7 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture concurrent binding request");
let response = if request.starts_with("GET /api/editor/projects ") {
let response = if request.starts_with("GET /api/editor/projects?view=summary ") {
let (state_lock, ready) = &*state;
let mut state = state_lock.lock().expect("lock project fixture state");
if !state.project_created {
@@ -9988,7 +10057,7 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture project partial request");
if request.starts_with("GET /api/external/v1/editor/projects ") {
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
write_test_json_response(
&mut stream,
"200 OK",
@@ -10122,7 +10191,7 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture frozen binding request");
if request.starts_with("GET /api/external/v1/editor/projects ") {
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
write_test_json_response(
&mut stream,
"200 OK",
@@ -10247,23 +10316,24 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture folder partial request");
let response = if request.starts_with("GET /api/external/v1/editor/projects ") {
serde_json::json!({"data": {"projects": []}})
} else if request.starts_with("POST /api/external/v1/editor/projects ") {
serde_json::json!({"data": {"project": {
"projectId": "folder-partial-project"
}}})
} else if request.starts_with("GET /api/external/v1/editor/assets/library ") {
serde_json::json!({"data": {"library": {"folders": []}}})
} else if request.starts_with("POST /api/external/v1/editor/assets/folders ") {
fs::create_dir_all(&blocked_binding_path)
.expect("block final binding write after folder creation");
serde_json::json!({"data": {"folder": {
"folderId": "folder-partial-folder"
}}})
} else {
panic!("unexpected folder partial request: {request}");
};
let response =
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
serde_json::json!({"data": {"projects": []}})
} else if request.starts_with("POST /api/external/v1/editor/projects ") {
serde_json::json!({"data": {"project": {
"projectId": "folder-partial-project"
}}})
} else if request.starts_with("GET /api/external/v1/editor/assets/library ") {
serde_json::json!({"data": {"library": {"folders": []}}})
} else if request.starts_with("POST /api/external/v1/editor/assets/folders ") {
fs::create_dir_all(&blocked_binding_path)
.expect("block final binding write after folder creation");
serde_json::json!({"data": {"folder": {
"folderId": "folder-partial-folder"
}}})
} else {
panic!("unexpected folder partial request: {request}");
};
write_test_json_response(&mut stream, "200 OK", &response);
}
});

Some files were not shown because too many files have changed in this diff Show More