diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index b9a4e644f..ced19bec9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -807,6 +807,45 @@ async fn ensure_template_installed( }) } +/// 清单来源:远端读取或本机缓存兜底。快照里的 `source` 原样回传,前端据此提示「本机缓存」。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TemplateIndexSource { + Network, + Cache, +} + +impl TemplateIndexSource { + fn as_str(self) -> &'static str { + match self { + Self::Network => "network", + Self::Cache => "cache", + } + } +} + +/// 远端清单不可用时回退本机缓存。 +/// +/// 两条路径都要过同一份 schema 校验:远端已经答话但正文非法时直接失败关闭, +/// 不用缓存掩盖;缓存自己损坏时也不能被当成可用清单。 +fn resolve_template_index( + remote: Result, + cached: Option, +) -> Result<(String, TemplateIndexSource), String> { + match remote { + Ok(body) => { + parse_game_template_library_index(&body)?; + Ok((body, TemplateIndexSource::Network)) + } + Err(error) => match cached { + Some(cached) => { + parse_game_template_library_index(&cached)?; + Ok((cached, TemplateIndexSource::Cache)) + } + None => Err(error), + }, + } +} + #[tauri::command] pub(crate) async fn fetch_game_template_library( app: tauri::AppHandle, @@ -820,26 +859,21 @@ pub(crate) async fn fetch_game_template_library( TEMPLATE_LIBRARY_INDEX_KEY ); let client = build_template_library_client(); - let (body, source) = + let remote = match fetch_limited_bytes(&client, &index_url, TEMPLATE_LIBRARY_MAX_INDEX_BYTES).await { + // 响应回来了但正文不是合法 UTF-8 属于「远端答非所问」,不能用缓存掩盖。 Ok(bytes) => { - let body = - String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?; - parse_game_template_library_index(&body)?; - with_validated_platform_session_identity(&identity, || { - write_cached_index(&cache_root, &body); - Ok(()) - })?; - (body, "network") + Ok(String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?) } - Err(error) => match read_cached_index(&cache_root) { - Some(cached) => { - parse_game_template_library_index(&cached)?; - (cached, "cache") - } - None => return Err(error), - }, + Err(error) => Err(error), }; + let (body, source) = resolve_template_index(remote, read_cached_index(&cache_root))?; + if source == TemplateIndexSource::Network { + with_validated_platform_session_identity(&identity, || { + write_cached_index(&cache_root, &body); + Ok(()) + })?; + } validate_platform_session_identity(&identity)?; let (header, templates) = parse_game_template_library_index(&body)?; let installed = collect_installed_records(&cache_root); @@ -859,7 +893,7 @@ pub(crate) async fn fetch_game_template_library( library_version: header.library_version, updated_at: header.updated_at, fetched_at_millis: now_millis(), - source: source.to_string(), + source: source.as_str().to_string(), templates: entries, }) } @@ -1181,6 +1215,50 @@ mod tests { )) } + #[test] + fn template_index_prefers_the_network_body_and_labels_its_source() { + let body = sample_index_body(); + let (resolved, source) = + resolve_template_index(Ok(body.clone()), Some("{not json".to_string())) + .expect("合法远端正文优先于缓存"); + assert_eq!(source.as_str(), "network"); + assert_eq!(resolved, body); + } + + #[test] + fn template_index_falls_back_to_the_cache_body_when_the_remote_fetch_fails() { + let cached = sample_index_body(); + let (resolved, source) = + resolve_template_index(Err("网络不可用".to_string()), Some(cached.clone())) + .expect("远端不可用时回退本机缓存"); + assert_eq!(source.as_str(), "cache"); + assert_eq!(resolved, cached); + } + + #[test] + fn template_index_keeps_the_remote_error_when_no_cache_exists() { + let error = resolve_template_index(Err("远端超时".to_string()), None) + .expect_err("没有缓存时必须暴露远端错误"); + assert_eq!(error, "远端超时"); + } + + #[test] + fn template_index_rejects_an_invalid_remote_body_without_masking_it_with_cache() { + let cached = sample_index_body(); + let invalid = cached.replace(TEMPLATE_LIBRARY_SCHEMA_VERSION, "agc-template-library.v2"); + let error = resolve_template_index(Ok(invalid), Some(cached)) + .expect_err("远端已答话但正文非法时不得用缓存掩盖"); + assert!(error.contains("版本不受支持"), "{error}"); + } + + #[test] + fn template_index_rejects_a_corrupt_cache_instead_of_serving_it() { + let error = + resolve_template_index(Err("网络不可用".to_string()), Some("{not json".to_string())) + .expect_err("损坏的缓存必须失败关闭"); + assert!(error.contains("不是有效 JSON"), "{error}"); + } + #[test] fn rejects_index_with_unsupported_schema_or_duplicate_templates() { let unsupported = @@ -1323,6 +1401,8 @@ mod tests { .unwrap() .join("escape.txt") .exists()); + // 越界归档失败后不能留下安装记录:目录残留不算「已下载」。 + assert!(collect_installed_records(destination.path()).is_empty()); } #[test] @@ -1371,6 +1451,13 @@ mod tests { let error = install_template_archive(cache_root.path(), &summary, &archive) .expect_err("digest mismatch rejected"); assert!(error.contains("完整性校验失败"), "{error}"); + + // 大小或摘要不一致必须在任何落盘之前失败:不留安装目录,也不产生「已下载」判据。 + let directory = + installed_template_dir(cache_root.path(), &summary.id, &summary.template_version) + .expect("install dir"); + assert!(!directory.exists(), "拒绝的模板不得留下安装目录"); + assert!(collect_installed_records(cache_root.path()).is_empty()); } #[test] @@ -1415,6 +1502,38 @@ mod tests { assert!(error.contains("模板尚未安装"), "{error}"); } + #[test] + fn failed_project_creation_removes_the_partial_project_directory() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let projects_root = unique_projects_root(); + // 安装目录里只有安装记录、没有任何可复制文件:复制阶段必须失败关闭, + // 且已经创建的项目目录要被清掉,不能把半成品留在自动工作区里。 + let directory = installed_template_dir(cache_root.path(), "empty-template", "1.0.0") + .expect("install dir"); + ensure_game_creator_private_directory_tree(&directory, "模板安装目录") + .expect("create install dir"); + write_game_creator_private_file( + &directory.join(TEMPLATE_INSTALLED_MARKER_FILE), + b"{\"templateId\":\"empty-template\"}", + "模板安装记录", + ) + .expect("write install marker"); + + let error = + create_project_from_installed_template_at(&projects_root, &directory, None, false) + .expect_err("模板没有可复制文件时必须失败关闭"); + assert!(error.contains("没有可复制的文件"), "{error}"); + let leftovers = fs::read_dir(&projects_root) + .map(|entries| { + entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .collect::>() + }) + .unwrap_or_default(); + assert!(leftovers.is_empty(), "失败不得留下项目目录:{leftovers:?}"); + } + fn template_source_files(root: &Path) -> Vec<(String, Vec)> { fn collect(root: &Path, directory: &Path, files: &mut Vec<(String, Vec)>) { for entry in fs::read_dir(directory).expect("read template directory") { diff --git a/docs/project-memory/plans/【实施计划】AGC模板库客户端接入-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC模板库客户端接入-2026-09-17.md deleted file mode 100644 index 1b7315703..000000000 --- a/docs/project-memory/plans/【实施计划】AGC模板库客户端接入-2026-09-17.md +++ /dev/null @@ -1,59 +0,0 @@ -# AGC 模板库客户端接入实施计划 - -Version: 1.0 -Status: active -Date: 2026-09-17 -Milestone Spec: `docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md` - -## 步骤 - -1. **OSS 库布局与契约** - - 在 `agc-dev` 落地 `templates/` 前缀:`index.json`、`v1//{template.json,template.zip,cover.*}`。 - - 清单补齐 `tags`、`coverKey/coverWidth/coverHeight/coverSha256`,正文改为 zip(zip 根 == 项目根)。 - - 模板源落在 `apps/ai-game-creator-shell/template-library/v1//{meta.json,project/**,cover.*}`;zip 由 `scripts/agc-template-library-publish.mjs` 现场打包(不落仓库)。 - - 交付:发布脚本(校验 + 打包 + 上传 + 回读校验,支持 `--dry-run` / `--prune`)、`templates/README.md`,以及 5 个模板(3 个空白 + 2 个起步工程)。 - - 验收:匿名 `GET templates/index.json` 可读,每个 `zipKey` 回读 SHA-256 与清单一致。 - -2. **Rust 模板库模块** - - 新增 `src-tauri/src/template_library.rs`:清单解析与校验、受信任 base、缓存/安装目录、zip 安全解压、安装记录、由模板建项目。 - - 注册命令 `fetch_game_template_library`、`download_game_template`、`create_automatic_local_game_project_from_template`。 - - 交付:模块内 8 项单测(schema/重复模板、键前缀、base 校验、解压逃逸、摘要与大小、安装记录、建项目与失败清理)。 - - 验收:`cargo test --bin genarrative-ai-game-creator-shell template_library` 全绿。 - -3. **前端状态链路** - - `src/features/template-library/templateLibraryModel.ts`(类型与搜索/筛选纯函数)与 `useTemplateLibrary.ts`(拉取、下载、建项目、就地更新已下载状态)。 - - `useHomeProjectCreation` 增加 `enterCreatedTemplateProject`,复用既有进项目通道。 - - 交付:9 项模型单测。 - - 验收:`npx vitest run src/features/template-library` 全绿。 - -4. **界面接入** - - 新增 `src/view/template-library/index.tsx` 全屏页;`LauncherView` 增加 `template-library`;左侧导航加模板库入口。 - - 首页「灵感推荐」替换为 `TemplateRecommendations`;删除 `InspirationGallery.tsx` 与 `assets/inspiration/`。 - - `tauri.conf.json` 的 `img-src` 放行受信任 OSS 主机以加载封面。 - - 验收:模板库页可搜索、筛选、下载、显示已下载并成功建项目;首页推荐位可跳转。 - -5. **文档与共享记忆** - - 主规范 `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`,并在 `docs/README.md` 建索引。 - - 本里程碑与实施计划;`decision-log.md` 记录库路径、清单 schema、缓存目录与 CSP 约定。 - - 验收:`node scripts/check-doc-index.mjs` 通过。 - -## 验证命令 - -```bash -cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library -cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library -- --ignored -cd apps/ai-game-creator-shell && npx tsc -p tsconfig.json --noEmit -npx vitest run apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx -node scripts/agc-template-library-publish.mjs --source --dry-run -npm run check:encoding -node scripts/check-doc-index.mjs -git diff --check -``` - -## 风险与回退 - -- **封面走 WebView 直连**:仅放行受信任 OSS 主机;若日后改用后端签名,清单的 `coverKey` 不变。 -- **模板包体积**:下载上限 512 MiB、解压文件数 4096、单文件 256 MiB;超限直接拒绝,不落盘。 -- **清单漂移**:客户端只信「受信任主机 + 对象键」,清单中的地址字段不参与请求。 -- **回退**:清空 `templates/` 前缀即回到空模板库;客户端保留错误与空态展示,不阻断其它功能。 diff --git a/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md index f6483d226..6272c2d1b 100644 --- a/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md +++ b/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md @@ -1,7 +1,7 @@ # AGC 模板库客户端接入 Version: 1.0 -Status: active +Status: accepted(2026-09-21 复核:7 条验收项均有定向、前端与线上真连证据) Date: 2026-09-17 Parent Spec: `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md` @@ -38,3 +38,17 @@ AGC 客户端能读取公共 OSS 上的游戏模板库,并把「浏览 → 筛 - 现有自动工作区建项链路(`create_automatic_local_game_project_at` / `init_local_game_project_at`)。 - 现有 AGC 更新通道使用的受信任 OSS 主机与 CSP 白名单口径。 - 仓库 OSS 凭据(本机 `.env.secrets.local` 的 `ALIYUN_OSS_*`)与 `scripts/agc-template-library-publish.mjs`。 + +## 验收证据(2026-09-21) + +| 验收项 | 证据 | +| --- | --- | +| 1 读清单 + 合并已安装状态 + 远端不可用回退缓存并标 `source=cache` | `template_library::tests::template_index_*` 5 项(网络优先并标 `network`、远端失败回退缓存并标 `cache`、无缓存暴露远端错误、远端正文非法不用缓存掩盖、缓存损坏失败关闭);`fetches_the_live_template_library_index` 真连通过 | +| 2 下载拒绝字节数 / SHA-256 / 越界键 / 非 `templates/` 前缀,且不落半成品 | `rejects_archive_when_size_or_digest_do_not_match_the_index`(含新增断言:拒绝后无安装目录、无已下载判据)、`rejects_object_keys_outside_the_templates_prefix`、`rejects_template_base_url_outside_trusted_oss`;`downloads_and_installs_a_live_template` 真连通过 | +| 3 解压拒绝绝对路径 / `..` / 盘符 / 符号链接;安装完成才写 `installed.json` | `rejects_archive_entries_that_escape_the_destination`(含新增断言:失败后不产生安装记录)、`installs_template_archive_and_reports_it_as_installed`、`parses_content_addressed_objects_without_changing_the_template_contract` | +| 4 建项具备模板文件 + `.agent` + 标准目录,失败不留项目目录 | `creates_project_from_installed_template_without_leaking_install_marker`、`installs_official_cocos_templates_and_creates_native_projects`、新增 `failed_project_creation_removes_the_partial_project_directory`、`refuses_to_create_project_when_template_is_not_installed`;`downloads_and_creates_live_cocos_templates` 真连通过 | +| 5 页面筛选 / 卡片 / 已下载徽标 / 更新入口 / 浮层提示 / 版本落后先重下 | `templateLibraryView.test.tsx` 12 项、`templateLibraryModel.test.ts` 9 项、`templateLibraryGrid.test.ts` 6 项、`useTemplateLibrary.test.tsx` 11 项 | +| 6 首页推荐位与左侧导航入口 | `appSurface.test.ts` 首页模板用例 2 项:灰度外隐藏入口与推荐位、点击推荐位进入模板库且不建项目 | +| 7 定向用例 + 类型 / 编码 / 文档检查 + 可选真连 | Rust 24 项 + 线上 3 项、前端 38 项 + `appSurface` 2 项、AGC `typecheck`、`check:encoding`、`check:doc-index`、`git diff --check` 全部通过;匿名 `GET templates/index.json` 返回 200(`schemaVersion=agc-template-library.v1`、9 个模板) | + +仍未验证:Creator 内场景运行;真机 iOS / Android 观感。持久结论已并入主规范 [`【技术方案】AGC模板库与模板建项-2026-09-17.md`](../../technical/【技术方案】AGC模板库与模板建项-2026-09-17.md),本里程碑的实施计划已按工作流删除。 diff --git a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md index 684cd4df2..5cee7f513 100644 --- a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md +++ b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md @@ -83,7 +83,7 @@ templates/ | 命令 | 行为 | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `fetch_game_template_library` | 读 `templates/index.json`(≤4 MiB),校验后缓存到 `/templates/index.json`;网络失败时回退本机缓存并在 `source` 标 `cache` | +| `fetch_game_template_library` | 读 `templates/index.json`(≤4 MiB),校验后缓存到 `/templates/index.json`;网络失败时回退本机缓存并在 `source` 标 `cache`。远端已经答话但正文不是合法 UTF-8 或不符合 schema、以及缓存自己损坏时,一律失败关闭,不用缓存掩盖远端错误 | | `download_game_template` | 取清单里对应条目,流式下载 zip(≤512 MiB),校验字节数与 SHA-256,解压到 `/templates/installed///`,最后写 `installed.json` 作为安装完成的唯一标记 | | `create_automatic_local_game_project_from_template` | 需要时先安装模板,然后在自动工作区根目录下按既有自动工作区规则建目录:先复制模板文件;Cocos 项目更新自身身份后走既有 Cocos 导入,其余沿现有 `init_local_game_project_at` 初始化。根目录默认是 `/projects/`,用户可选 `projectsRoot` 覆盖(必须来自本机目录选择器并通过私有路径门禁),见 [`【实施计划】AGC项目创建目录可选-2026-09-17.md`](../project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md) | @@ -93,6 +93,7 @@ templates/ - 安装目录名由标识符白名单拼出,不拼接远端字符串;重装时只清理该模板自己的安装目录。 - 模板文件与安装记录统一走 `write_game_creator_private_file` / `ensure_game_creator_private_directory_tree`,保持项目目录的私有 DACL 口径。 - 建项目失败时删除刚创建的项目目录,不留半成品。 +- 大小 / 摘要校验在落盘之前完成,被拒绝的模板包不产生任何安装目录;`installed.json` 是「已下载」的唯一判据,目录残留(例如解压中途失败)不构成已安装,下次安装会先清理该模板自己的安装目录。 ### 前端 @@ -114,6 +115,8 @@ Cocos 回归分别覆盖仓库模板和线上真实 ZIP 的安装、连续建项 `2026-09-19` 发布一致性验收:Node 发布回归 22 项通过,覆盖两个发布者竞争、正文/清单写入失败、迟到清单 PUT、锁归属、版本控制拒绝、V1 签名及真实 CLI 的无写入 dry-run。Rust 定向回归 15 项通过,新增内容地址的清单解析与 URL 保留校验;3 项线上用例本轮未重复执行,此前同日线上下载及原生建项已通过。只读 dry-run 保留线上九个模板并仅计划更新四个 Cocos 条目。格式、编码、文档索引、定向 ESLint 与 diff 检查通过。全部并发/故障写入证据来自离线替身,未执行真实 OSS 锁写入或发布,也未验证 Creator 内场景运行。 +`2026-09-21` 客户端接入复核:Rust 定向 24 项通过(新增清单来源判定 5 项:合法远端正文优先并标 `network`、远端失败回退缓存并标 `cache`、无缓存时暴露远端错误、远端正文非法时不用缓存掩盖、缓存损坏时失败关闭;新增建项目失败清理 1 项;并在大小/摘要不一致、越界归档两条用例上补「拒绝后不留安装目录、不产生已下载判据」断言)。3 项线上用例(读线上清单、下载安装线上模板、下载并原生建项 Cocos 模板)本轮全部真实执行通过;匿名 `GET https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/index.json` 返回 200、`schemaVersion=agc-template-library.v1`、9 个模板。前端 38 项通过(模型 9、网格 6、页面 12、控制器 11),`appSurface` 首页模板用例 2 项通过(灰度外隐藏入口与推荐位、点击进入模板库不建项),AGC `typecheck` 通过。仍未验证:Creator 内场景运行,以及真机 iOS / Android 观感。 + ## 本地压测假数据注入(feature 控制) 模板库的数据源在 Rust 侧(清单校验、安装状态、下载与建项目都在这里),TS 只消费快照做渲染,所以假数据注入也放在 Rust 侧,走与真实完全一致的链路。