19ed1776f1
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
1782 lines
72 KiB
Rust
1782 lines
72 KiB
Rust
//! AGC 游戏模板库:读取公共 OSS 模板清单、下载模板 zip、安装到本机并据此建项目。
|
||
//!
|
||
//! 边界:模板库只做「远端清单 → 本机安装 → 复制进新项目」这条路,不生成模板内容,
|
||
//! 也不改写已存在项目。远端对象只允许来自受信任 OSS 主机下的 `templates/` 前缀;
|
||
//! 清单、zip 与封面一律先校验再落盘,zip 解压只接受普通文件与目录。
|
||
|
||
use super::*;
|
||
use crate::platform_session::{
|
||
current_platform_session, validate_platform_session_identity,
|
||
with_validated_platform_session_identity, PlatformSessionIdentity, PlatformSessionSnapshot,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
const TEMPLATE_LIBRARY_SCHEMA_VERSION: &str = "agc-template-library.v1";
|
||
const TEMPLATE_LIBRARY_INDEX_KEY: &str = "templates/index.json";
|
||
const TEMPLATE_LIBRARY_OBJECT_PREFIX: &str = "templates/";
|
||
const TEMPLATE_LIBRARY_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs.com";
|
||
const DEFAULT_TEMPLATE_LIBRARY_BASE_URL: &str =
|
||
"https://agc-dev.oss-rg-china-mainland.aliyuncs.com";
|
||
const TEMPLATE_LIBRARY_BASE_URL_ENV: &str = "AGC_TEMPLATE_LIBRARY_BASE_URL";
|
||
const TEMPLATE_CACHE_DIRECTORY_NAME: &str = "templates";
|
||
const TEMPLATE_INSTALLED_DIRECTORY_NAME: &str = "installed";
|
||
const TEMPLATE_INSTALLED_MARKER_FILE: &str = "installed.json";
|
||
const TEMPLATE_INDEX_CACHE_FILE: &str = "index.json";
|
||
const TEMPLATE_LIBRARY_MAX_INDEX_BYTES: u64 = 4 * 1024 * 1024;
|
||
const TEMPLATE_ARCHIVE_MAX_BYTES: u64 = 512 * 1024 * 1024;
|
||
const TEMPLATE_ARCHIVE_MAX_FILES: usize = 4_096;
|
||
const TEMPLATE_ARCHIVE_MAX_FILE_BYTES: u64 = 256 * 1024 * 1024;
|
||
const TEMPLATE_ID_MAX_CHARS: usize = 64;
|
||
const TEMPLATE_VERSION_MAX_CHARS: usize = 32;
|
||
const TEMPLATE_ACCESS_ERROR: &str = "template-library-unavailable: 模板库暂未向当前账号开放";
|
||
|
||
async fn template_library_access_for_session(
|
||
session: &PlatformSessionSnapshot,
|
||
) -> Result<bool, String> {
|
||
let client = reqwest::Client::builder()
|
||
.connect_timeout(Duration::from_secs(10))
|
||
.timeout(Duration::from_secs(15))
|
||
.redirect(reqwest::redirect::Policy::none())
|
||
.build()
|
||
.map_err(|_| "template-library-unavailable: 无法检查模板库权限".to_string())?;
|
||
let response = client
|
||
.get(format!(
|
||
"{}/api/runtime/frontend-config",
|
||
session.api_base_url.trim_end_matches('/')
|
||
))
|
||
.bearer_auth(&session.access_token)
|
||
.send()
|
||
.await
|
||
.map_err(|_| "template-library-unavailable: 检查模板库权限失败,请重试".to_string())?;
|
||
validate_platform_session_identity(&session.identity())?;
|
||
if !response.status().is_success() {
|
||
return Err(format!(
|
||
"template-library-unavailable: 检查模板库权限返回 HTTP {}",
|
||
response.status().as_u16()
|
||
));
|
||
}
|
||
const MAX_BYTES: usize = 64 * 1024;
|
||
if response
|
||
.content_length()
|
||
.is_some_and(|length| length > MAX_BYTES as u64)
|
||
{
|
||
return Err("template-library-unavailable: 模板库权限响应无效".to_string());
|
||
}
|
||
let mut bytes = Vec::new();
|
||
let mut stream = response.bytes_stream();
|
||
while let Some(chunk) = stream.next().await {
|
||
let chunk =
|
||
chunk.map_err(|_| "template-library-unavailable: 读取模板库权限失败".to_string())?;
|
||
if bytes.len() + chunk.len() > MAX_BYTES {
|
||
return Err("template-library-unavailable: 模板库权限响应无效".to_string());
|
||
}
|
||
bytes.extend_from_slice(&chunk);
|
||
}
|
||
validate_platform_session_identity(&session.identity())?;
|
||
let payload: serde_json::Value = serde_json::from_slice(&bytes)
|
||
.map_err(|_| "template-library-unavailable: 模板库权限响应无效".to_string())?;
|
||
Ok(payload
|
||
.get("agcTemplateLibraryEnabled")
|
||
.and_then(|value| value.as_bool())
|
||
== Some(true))
|
||
}
|
||
|
||
async fn require_template_library_access() -> Result<PlatformSessionIdentity, String> {
|
||
let session = current_platform_session().ok_or_else(|| TEMPLATE_ACCESS_ERROR.to_string())?;
|
||
if !template_library_access_for_session(&session).await? {
|
||
return Err(TEMPLATE_ACCESS_ERROR.to_string());
|
||
}
|
||
Ok(session.identity())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn get_game_template_library_access() -> Result<bool, String> {
|
||
let Some(session) = current_platform_session() else {
|
||
return Ok(false);
|
||
};
|
||
template_library_access_for_session(&session).await
|
||
}
|
||
|
||
/// 远端清单里的单个模板条目(`templates/index.json` 中的 `templates[]`)。
|
||
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct GameTemplateSummary {
|
||
pub(crate) id: String,
|
||
pub(crate) title: String,
|
||
#[serde(default)]
|
||
pub(crate) summary: String,
|
||
#[serde(default)]
|
||
pub(crate) tags: Vec<String>,
|
||
#[serde(default)]
|
||
pub(crate) runtime: String,
|
||
#[serde(default)]
|
||
pub(crate) engine: String,
|
||
#[serde(default)]
|
||
pub(crate) engine_version: String,
|
||
pub(crate) template_version: String,
|
||
#[serde(default)]
|
||
pub(crate) updated_at: String,
|
||
#[serde(default)]
|
||
pub(crate) entry: String,
|
||
pub(crate) zip_key: String,
|
||
pub(crate) zip_size_bytes: u64,
|
||
pub(crate) zip_sha256: String,
|
||
pub(crate) cover_key: String,
|
||
#[serde(default)]
|
||
pub(crate) cover_width: u32,
|
||
#[serde(default)]
|
||
pub(crate) cover_height: u32,
|
||
#[serde(default)]
|
||
pub(crate) cover_sha256: String,
|
||
#[serde(default)]
|
||
pub(crate) metadata_key: String,
|
||
}
|
||
|
||
/// `templates/index.json` 的库头信息。
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct GameTemplateLibraryHeader {
|
||
pub(crate) schema_version: String,
|
||
#[serde(default)]
|
||
pub(crate) library: String,
|
||
#[serde(default)]
|
||
pub(crate) library_version: u32,
|
||
#[serde(default)]
|
||
pub(crate) updated_at: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameTemplateLibraryIndex {
|
||
#[serde(flatten)]
|
||
header: GameTemplateLibraryHeader,
|
||
#[serde(default)]
|
||
templates: Vec<GameTemplateSummary>,
|
||
}
|
||
|
||
/// 返回给前端的模板条目:清单字段 + 远端地址 + 本机安装状态。
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct GameTemplateEntry {
|
||
pub(crate) id: String,
|
||
pub(crate) title: String,
|
||
pub(crate) summary: String,
|
||
pub(crate) tags: Vec<String>,
|
||
pub(crate) runtime: String,
|
||
pub(crate) engine: String,
|
||
pub(crate) engine_version: String,
|
||
pub(crate) template_version: String,
|
||
pub(crate) updated_at: String,
|
||
pub(crate) entry: String,
|
||
pub(crate) zip_url: String,
|
||
pub(crate) zip_size_bytes: u64,
|
||
pub(crate) zip_sha256: String,
|
||
pub(crate) cover_url: String,
|
||
pub(crate) cover_width: u32,
|
||
pub(crate) cover_height: u32,
|
||
pub(crate) installed: bool,
|
||
pub(crate) installed_version: Option<String>,
|
||
pub(crate) installed_at_millis: Option<u64>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct GameTemplateLibrarySnapshot {
|
||
pub(crate) schema_version: String,
|
||
pub(crate) library: String,
|
||
pub(crate) library_version: u32,
|
||
pub(crate) updated_at: String,
|
||
pub(crate) fetched_at_millis: u64,
|
||
/// `network` 或 `cache`:命中本机缓存时前端应提示清单可能不是最新。
|
||
pub(crate) source: String,
|
||
pub(crate) templates: Vec<GameTemplateEntry>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct InstalledGameTemplateRecord {
|
||
template_id: String,
|
||
template_version: String,
|
||
installed_at_millis: u64,
|
||
zip_sha256: String,
|
||
file_count: usize,
|
||
project_dir: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct InstalledGameTemplate {
|
||
pub(crate) template_id: String,
|
||
pub(crate) template_version: String,
|
||
pub(crate) installed_at_millis: u64,
|
||
pub(crate) zip_sha256: String,
|
||
pub(crate) file_count: usize,
|
||
pub(crate) project_dir: String,
|
||
}
|
||
|
||
fn now_millis() -> u64 {
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|duration| duration.as_millis() as u64)
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn template_library_base_url() -> String {
|
||
std::env::var(TEMPLATE_LIBRARY_BASE_URL_ENV)
|
||
.ok()
|
||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or_else(|| DEFAULT_TEMPLATE_LIBRARY_BASE_URL.to_string())
|
||
}
|
||
|
||
/// 只接受受信任 OSS 主机下的 HTTPS 地址;返回去掉尾斜杠的 base。
|
||
pub(crate) fn validated_template_library_base_url(raw: &str) -> Result<String, String> {
|
||
let trimmed = raw.trim().trim_end_matches('/');
|
||
let parsed = url::Url::parse(trimmed).map_err(|_| "模板库地址无效".to_string())?;
|
||
if parsed.scheme() != "https" || parsed.host_str() != Some(TEMPLATE_LIBRARY_OSS_HOST) {
|
||
return Err("模板库地址必须来自受信任的 OSS".to_string());
|
||
}
|
||
if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() {
|
||
return Err("模板库地址只能包含主机名".to_string());
|
||
}
|
||
Ok(trimmed.to_string())
|
||
}
|
||
|
||
fn template_library_base() -> Result<String, String> {
|
||
validated_template_library_base_url(&template_library_base_url())
|
||
}
|
||
|
||
fn sha256_hex(bytes: &[u8]) -> String {
|
||
let mut hasher = sha2::Sha256::new();
|
||
hasher.update(bytes);
|
||
format!("{:x}", hasher.finalize())
|
||
}
|
||
|
||
fn is_valid_sha256(value: &str) -> bool {
|
||
let trimmed = value.trim();
|
||
trimmed.len() == 64 && trimmed.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||
}
|
||
|
||
fn validate_template_identifier(value: &str, max_chars: usize, label: &str) -> Result<(), String> {
|
||
let trimmed = value.trim();
|
||
if trimmed.is_empty() || trimmed.chars().count() > max_chars {
|
||
return Err(format!("{label}无效"));
|
||
}
|
||
if !trimmed.chars().all(|value| {
|
||
value.is_ascii_lowercase() || value.is_ascii_digit() || matches!(value, '-' | '_' | '.')
|
||
}) {
|
||
return Err(format!("{label}无效"));
|
||
}
|
||
if trimmed.contains("..") {
|
||
return Err(format!("{label}无效"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 远端对象键必须落在 `templates/` 前缀下,且不含绝对路径、上跳或反斜杠。
|
||
pub(crate) fn validate_template_object_key(key: &str) -> Result<(), String> {
|
||
let trimmed = key.trim();
|
||
if trimmed.is_empty() || !trimmed.starts_with(TEMPLATE_LIBRARY_OBJECT_PREFIX) {
|
||
return Err("模板对象键必须位于 templates/ 前缀下".to_string());
|
||
}
|
||
if trimmed.starts_with('/') || trimmed.contains('\\') || trimmed.contains("..") {
|
||
return Err("模板对象键无效".to_string());
|
||
}
|
||
if trimmed
|
||
.chars()
|
||
.any(|value| value.is_control() || value == ' ' || value == '?')
|
||
{
|
||
return Err("模板对象键无效".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn template_object_url(key: &str) -> Result<String, String> {
|
||
validate_template_object_key(key)?;
|
||
Ok(format!("{}/{}", template_library_base()?, key.trim()))
|
||
}
|
||
|
||
fn validate_template_summary(entry: &GameTemplateSummary) -> Result<(), String> {
|
||
validate_template_identifier(&entry.id, TEMPLATE_ID_MAX_CHARS, "模板 ID")?;
|
||
validate_template_identifier(
|
||
&entry.template_version,
|
||
TEMPLATE_VERSION_MAX_CHARS,
|
||
"模板版本",
|
||
)?;
|
||
if entry.title.trim().is_empty() || entry.title.chars().count() > 120 {
|
||
return Err(format!("模板 {} 的标题无效", entry.id));
|
||
}
|
||
if entry.tags.len() > 32 {
|
||
return Err(format!("模板 {} 的标签过多", entry.id));
|
||
}
|
||
validate_template_object_key(&entry.zip_key)?;
|
||
validate_template_object_key(&entry.cover_key)?;
|
||
if !entry.metadata_key.trim().is_empty() {
|
||
validate_template_object_key(&entry.metadata_key)?;
|
||
}
|
||
if entry.zip_size_bytes == 0 || entry.zip_size_bytes > TEMPLATE_ARCHIVE_MAX_BYTES {
|
||
return Err(format!("模板 {} 的包大小无效", entry.id));
|
||
}
|
||
if !is_valid_sha256(&entry.zip_sha256) {
|
||
return Err(format!("模板 {} 的包摘要无效", entry.id));
|
||
}
|
||
if !entry.cover_sha256.trim().is_empty() && !is_valid_sha256(&entry.cover_sha256) {
|
||
return Err(format!("模板 {} 的封面摘要无效", entry.id));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 解析并校验远端清单;任何一条不合法都让整次读取失败,避免前端拿到半可信数据。
|
||
pub(crate) fn parse_game_template_library_index(
|
||
body: &str,
|
||
) -> Result<(GameTemplateLibraryHeader, Vec<GameTemplateSummary>), String> {
|
||
let index: GameTemplateLibraryIndex =
|
||
serde_json::from_str(body).map_err(|error| format!("模板库清单不是有效 JSON:{error}"))?;
|
||
if index.header.schema_version != TEMPLATE_LIBRARY_SCHEMA_VERSION {
|
||
return Err("模板库清单版本不受支持".to_string());
|
||
}
|
||
let mut seen = std::collections::BTreeSet::new();
|
||
for entry in &index.templates {
|
||
validate_template_summary(entry)?;
|
||
if !seen.insert(entry.id.clone()) {
|
||
return Err(format!("模板库清单存在重复模板:{}", entry.id));
|
||
}
|
||
}
|
||
Ok((index.header, index.templates))
|
||
}
|
||
|
||
fn template_cache_root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||
app.path()
|
||
.app_data_dir()
|
||
.map(|root| root.join(TEMPLATE_CACHE_DIRECTORY_NAME))
|
||
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
|
||
}
|
||
|
||
fn installed_templates_root(cache_root: &Path) -> PathBuf {
|
||
cache_root.join(TEMPLATE_INSTALLED_DIRECTORY_NAME)
|
||
}
|
||
|
||
fn installed_template_dir(
|
||
cache_root: &Path,
|
||
template_id: &str,
|
||
template_version: &str,
|
||
) -> Result<PathBuf, String> {
|
||
validate_template_identifier(template_id, TEMPLATE_ID_MAX_CHARS, "模板 ID")?;
|
||
validate_template_identifier(template_version, TEMPLATE_VERSION_MAX_CHARS, "模板版本")?;
|
||
Ok(installed_templates_root(cache_root)
|
||
.join(template_id.trim())
|
||
.join(template_version.trim()))
|
||
}
|
||
|
||
fn read_installed_record(directory: &Path) -> Option<InstalledGameTemplateRecord> {
|
||
let body = fs::read_to_string(directory.join(TEMPLATE_INSTALLED_MARKER_FILE)).ok()?;
|
||
serde_json::from_str(&body).ok()
|
||
}
|
||
|
||
/// 扫描本机已安装模板,返回 `(模板 ID, 安装记录)`;损坏或缺少标记的目录视为未安装。
|
||
fn collect_installed_records(cache_root: &Path) -> Vec<InstalledGameTemplateRecord> {
|
||
let root = installed_templates_root(cache_root);
|
||
let Ok(template_entries) = fs::read_dir(&root) else {
|
||
return Vec::new();
|
||
};
|
||
let mut records = Vec::new();
|
||
for template_entry in template_entries.flatten() {
|
||
let Ok(version_entries) = fs::read_dir(template_entry.path()) else {
|
||
continue;
|
||
};
|
||
for version_entry in version_entries.flatten() {
|
||
if let Some(record) = read_installed_record(&version_entry.path()) {
|
||
records.push(record);
|
||
}
|
||
}
|
||
}
|
||
records
|
||
}
|
||
|
||
fn installed_record_for(
|
||
records: &[InstalledGameTemplateRecord],
|
||
template_id: &str,
|
||
) -> Option<InstalledGameTemplateRecord> {
|
||
records
|
||
.iter()
|
||
.filter(|record| record.template_id == template_id)
|
||
.max_by_key(|record| record.installed_at_millis)
|
||
.cloned()
|
||
}
|
||
|
||
fn to_entry(
|
||
summary: &GameTemplateSummary,
|
||
installed: Option<&InstalledGameTemplateRecord>,
|
||
) -> Result<GameTemplateEntry, String> {
|
||
Ok(GameTemplateEntry {
|
||
id: summary.id.clone(),
|
||
title: summary.title.clone(),
|
||
summary: summary.summary.clone(),
|
||
tags: summary.tags.clone(),
|
||
runtime: summary.runtime.clone(),
|
||
engine: summary.engine.clone(),
|
||
engine_version: summary.engine_version.clone(),
|
||
template_version: summary.template_version.clone(),
|
||
updated_at: summary.updated_at.clone(),
|
||
entry: summary.entry.clone(),
|
||
zip_url: template_object_url(&summary.zip_key)?,
|
||
zip_size_bytes: summary.zip_size_bytes,
|
||
zip_sha256: summary.zip_sha256.to_ascii_lowercase(),
|
||
cover_url: template_object_url(&summary.cover_key)?,
|
||
cover_width: summary.cover_width,
|
||
cover_height: summary.cover_height,
|
||
installed: installed.is_some(),
|
||
installed_version: installed.map(|record| record.template_version.clone()),
|
||
installed_at_millis: installed.map(|record| record.installed_at_millis),
|
||
})
|
||
}
|
||
|
||
/// 本地假数据注入:只在 `template-library-fixtures` feature(或测试构建)下编译。
|
||
///
|
||
/// 开启时把真实清单循环补齐成假数据(条数见 `fixtures::synthetic_template_count`);
|
||
/// 未开启时是恒等透传,正式构建里没有任何注入分支。
|
||
fn apply_template_library_fixtures(entries: Vec<GameTemplateEntry>) -> Vec<GameTemplateEntry> {
|
||
#[cfg(feature = "template-library-fixtures")]
|
||
{
|
||
return fixtures::pad_synthetic_templates(entries, fixtures::synthetic_template_count());
|
||
}
|
||
#[cfg(not(feature = "template-library-fixtures"))]
|
||
{
|
||
entries
|
||
}
|
||
}
|
||
|
||
/// 本地假数据注入实现:只在 `template-library-fixtures` feature(或测试构建)下编译。
|
||
///
|
||
/// 位置刻意放在 Rust 侧:模板库的清单校验、安装状态与建项目都在这一侧,TS 只消费快照做渲染;
|
||
/// 在这里注入才能压到与真实一致的整条链路。正式构建不含该模块,因此不存在误触发路径。
|
||
#[cfg(any(test, feature = "template-library-fixtures"))]
|
||
pub(crate) mod fixtures {
|
||
use super::*;
|
||
|
||
pub(crate) const SYNTHETIC_COUNT_ENV: &str = "AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT";
|
||
pub(crate) const DEFAULT_SYNTHETIC_COUNT: usize = 1_000;
|
||
const MAX_SYNTHETIC_COUNT: usize = 20_000;
|
||
|
||
/// 假数据条数:环境变量优先,缺省 1000;0 表示不注入。
|
||
pub(crate) fn synthetic_template_count() -> usize {
|
||
parse_synthetic_count(std::env::var(SYNTHETIC_COUNT_ENV).ok().as_deref())
|
||
}
|
||
|
||
fn parse_synthetic_count(raw: Option<&str>) -> usize {
|
||
raw.and_then(|value| value.trim().parse::<usize>().ok())
|
||
.unwrap_or(DEFAULT_SYNTHETIC_COUNT)
|
||
.min(MAX_SYNTHETIC_COUNT)
|
||
}
|
||
|
||
/// 把真实清单循环复制成指定条数:id/标题/封面地址唯一,安装态按 1/3 混合。
|
||
pub(crate) fn pad_synthetic_templates(
|
||
entries: Vec<GameTemplateEntry>,
|
||
target: usize,
|
||
) -> Vec<GameTemplateEntry> {
|
||
if target <= entries.len() || entries.is_empty() {
|
||
return entries;
|
||
}
|
||
let base = entries.clone();
|
||
let mut padded = entries;
|
||
let mut index = padded.len();
|
||
while padded.len() < target {
|
||
let source = &base[index % base.len()];
|
||
let installed = index % 3 == 0;
|
||
let mut next = source.clone();
|
||
next.id = format!("{}-{:04}", source.id, index);
|
||
next.title = format!("{} · 假数据 {index:04}", source.title);
|
||
let mut tags = source.tags.clone();
|
||
tags.push(format!("批次-{:02}", index % 20));
|
||
next.tags = tags;
|
||
next.cover_url = format!("{}?synthetic={index}", source.cover_url);
|
||
next.installed = installed;
|
||
next.installed_version = installed.then(|| source.template_version.clone());
|
||
next.installed_at_millis = installed.then(|| now_millis());
|
||
padded.push(next);
|
||
index += 1;
|
||
}
|
||
padded
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn base_entry(id: &str, tag: &str) -> GameTemplateEntry {
|
||
GameTemplateEntry {
|
||
id: id.to_string(),
|
||
title: format!("模板 {id}"),
|
||
summary: "假数据基础条目".to_string(),
|
||
tags: vec![tag.to_string()],
|
||
runtime: "html".to_string(),
|
||
engine: "phaser".to_string(),
|
||
engine_version: "4.2.1".to_string(),
|
||
template_version: "0.1.0".to_string(),
|
||
updated_at: "2026-09-17T00:00:00Z".to_string(),
|
||
entry: "game/index.html".to_string(),
|
||
zip_url: format!("https://oss.example/templates/v1/{id}/template.zip"),
|
||
zip_size_bytes: 1024,
|
||
zip_sha256: "a".repeat(64),
|
||
cover_url: format!("https://oss.example/templates/v1/{id}/cover.svg"),
|
||
cover_width: 960,
|
||
cover_height: 540,
|
||
installed: false,
|
||
installed_version: None,
|
||
installed_at_millis: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parses_synthetic_count_from_env_value() {
|
||
assert_eq!(parse_synthetic_count(None), DEFAULT_SYNTHETIC_COUNT);
|
||
assert_eq!(parse_synthetic_count(Some(" 250 ")), 250);
|
||
assert_eq!(parse_synthetic_count(Some("0")), 0);
|
||
assert_eq!(
|
||
parse_synthetic_count(Some("not-a-number")),
|
||
DEFAULT_SYNTHETIC_COUNT
|
||
);
|
||
assert_eq!(parse_synthetic_count(Some("999999")), MAX_SYNTHETIC_COUNT);
|
||
}
|
||
|
||
#[test]
|
||
fn pads_entries_with_unique_identity_and_mixed_install_state() {
|
||
let base = vec![
|
||
base_entry("blank-web", "空白"),
|
||
base_entry("blank-2d", "2d"),
|
||
];
|
||
let padded = pad_synthetic_templates(base.clone(), 9);
|
||
assert_eq!(padded.len(), 9);
|
||
// 真实条目保持原样排在最前。
|
||
assert_eq!(padded[0].id, "blank-web");
|
||
assert_eq!(padded[1].id, "blank-2d");
|
||
let ids = padded
|
||
.iter()
|
||
.map(|entry| entry.id.clone())
|
||
.collect::<std::collections::BTreeSet<_>>();
|
||
assert_eq!(ids.len(), 9, "假数据 id 必须唯一");
|
||
let covers = padded
|
||
.iter()
|
||
.map(|entry| entry.cover_url.clone())
|
||
.collect::<std::collections::BTreeSet<_>>();
|
||
assert_eq!(covers.len(), 9, "假数据封面地址必须唯一");
|
||
assert!(padded.iter().any(|entry| entry.installed));
|
||
assert!(padded.iter().any(|entry| !entry.installed));
|
||
assert!(padded
|
||
.iter()
|
||
.skip(2)
|
||
.all(|entry| entry.tags.iter().any(|tag| tag.starts_with("批次-"))));
|
||
}
|
||
|
||
#[test]
|
||
fn padding_is_a_no_op_without_room_to_fill() {
|
||
let base = vec![base_entry("blank-web", "空白")];
|
||
assert_eq!(pad_synthetic_templates(base.clone(), 1).len(), 1);
|
||
assert_eq!(pad_synthetic_templates(base.clone(), 0).len(), 1);
|
||
assert!(pad_synthetic_templates(Vec::new(), 10).is_empty());
|
||
}
|
||
}
|
||
}
|
||
|
||
fn build_template_library_client() -> reqwest::Client {
|
||
reqwest::Client::builder()
|
||
.connect_timeout(Duration::from_secs(10))
|
||
.timeout(Duration::from_secs(120))
|
||
.build()
|
||
.unwrap_or_else(|_| reqwest::Client::new())
|
||
}
|
||
|
||
async fn fetch_limited_bytes(
|
||
client: &reqwest::Client,
|
||
url: &str,
|
||
max_bytes: u64,
|
||
) -> Result<Vec<u8>, String> {
|
||
let response = client
|
||
.get(url)
|
||
.send()
|
||
.await
|
||
.map_err(|error| format!("请求模板库失败:{error}"))?;
|
||
if !response.status().is_success() {
|
||
return Err(format!("模板库返回 HTTP {}", response.status().as_u16()));
|
||
}
|
||
if response
|
||
.content_length()
|
||
.is_some_and(|length| length > max_bytes)
|
||
{
|
||
return Err("模板库对象超过大小限制".to_string());
|
||
}
|
||
let mut body = Vec::new();
|
||
let mut stream = response.bytes_stream();
|
||
while let Some(chunk) = stream.next().await {
|
||
let chunk = chunk.map_err(|error| format!("读取模板库对象失败:{error}"))?;
|
||
if body.len() as u64 + chunk.len() as u64 > max_bytes {
|
||
return Err("模板库对象超过大小限制".to_string());
|
||
}
|
||
body.extend_from_slice(&chunk);
|
||
}
|
||
Ok(body)
|
||
}
|
||
|
||
fn write_cached_index(cache_root: &Path, body: &str) {
|
||
let _ = ensure_game_creator_private_directory_tree(cache_root, "模板库缓存目录");
|
||
let _ = write_game_creator_private_file(
|
||
&cache_root.join(TEMPLATE_INDEX_CACHE_FILE),
|
||
body.as_bytes(),
|
||
"模板库清单缓存",
|
||
);
|
||
}
|
||
|
||
fn read_cached_index(cache_root: &Path) -> Option<String> {
|
||
fs::read_to_string(cache_root.join(TEMPLATE_INDEX_CACHE_FILE)).ok()
|
||
}
|
||
|
||
/// 单个 zip 条目路径:只允许相对普通路径,禁止绝对路径、上跳、反斜杠与盘符。
|
||
pub(crate) fn safe_archive_relative_path(raw: &str) -> Result<PathBuf, String> {
|
||
let trimmed = raw.trim();
|
||
if trimmed.is_empty() {
|
||
return Err("模板包条目名为空".to_string());
|
||
}
|
||
let normalized = trimmed.replace('\\', "/");
|
||
if normalized.starts_with('/') || normalized.contains(':') {
|
||
return Err(format!("模板包条目路径无效:{raw}"));
|
||
}
|
||
let mut path = PathBuf::new();
|
||
for segment in normalized.split('/') {
|
||
if segment.is_empty() || segment == "." {
|
||
continue;
|
||
}
|
||
if segment == ".." {
|
||
return Err(format!("模板包条目路径无效:{raw}"));
|
||
}
|
||
path.push(segment);
|
||
}
|
||
if path.as_os_str().is_empty() {
|
||
return Err(format!("模板包条目路径无效:{raw}"));
|
||
}
|
||
Ok(path)
|
||
}
|
||
|
||
fn extract_template_archive(bytes: &[u8], destination: &Path) -> Result<usize, String> {
|
||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes))
|
||
.map_err(|error| format!("模板包不是有效 zip:{error}"))?;
|
||
if archive.len() > TEMPLATE_ARCHIVE_MAX_FILES {
|
||
return Err("模板包文件数量超过上限".to_string());
|
||
}
|
||
let mut written = 0_usize;
|
||
for index in 0..archive.len() {
|
||
let mut entry = archive
|
||
.by_index(index)
|
||
.map_err(|error| format!("读取模板包条目失败:{error}"))?;
|
||
if entry
|
||
.unix_mode()
|
||
.is_some_and(|mode| mode & 0o170000 == 0o120000)
|
||
{
|
||
return Err("模板包不允许包含符号链接".to_string());
|
||
}
|
||
let relative = safe_archive_relative_path(entry.name())?;
|
||
let target = destination.join(&relative);
|
||
if entry.is_dir() {
|
||
ensure_game_creator_private_directory_tree(&target, "模板目录")?;
|
||
continue;
|
||
}
|
||
if entry.size() > TEMPLATE_ARCHIVE_MAX_FILE_BYTES {
|
||
return Err(format!("模板包文件超过大小上限:{}", entry.name()));
|
||
}
|
||
let mut buffer = Vec::with_capacity(entry.size() as usize);
|
||
entry
|
||
.read_to_end(&mut buffer)
|
||
.map_err(|error| format!("读取模板包文件失败:{error}"))?;
|
||
if let Some(parent) = target.parent() {
|
||
ensure_game_creator_private_directory_tree(parent, "模板目录")?;
|
||
}
|
||
write_game_creator_private_file(&target, &buffer, "模板文件")?;
|
||
written += 1;
|
||
}
|
||
if written == 0 {
|
||
return Err("模板包没有可写入的文件".to_string());
|
||
}
|
||
Ok(written)
|
||
}
|
||
|
||
fn install_template_archive(
|
||
cache_root: &Path,
|
||
summary: &GameTemplateSummary,
|
||
bytes: &[u8],
|
||
) -> Result<InstalledGameTemplateRecord, String> {
|
||
if bytes.len() as u64 != summary.zip_size_bytes {
|
||
return Err("模板包大小校验失败".to_string());
|
||
}
|
||
let actual_sha256 = sha256_hex(bytes);
|
||
if actual_sha256 != summary.zip_sha256.trim().to_ascii_lowercase() {
|
||
return Err("模板包完整性校验失败".to_string());
|
||
}
|
||
let directory = installed_template_dir(cache_root, &summary.id, &summary.template_version)?;
|
||
if directory.exists() {
|
||
// 只清理本模板自己的安装目录;路径由标识符白名单拼出,不含远端输入。
|
||
let _ = fs::remove_dir_all(&directory);
|
||
}
|
||
ensure_game_creator_private_directory_tree(&directory, "模板安装目录")?;
|
||
let file_count = extract_template_archive(bytes, &directory)?;
|
||
let record = InstalledGameTemplateRecord {
|
||
template_id: summary.id.clone(),
|
||
template_version: summary.template_version.clone(),
|
||
installed_at_millis: now_millis(),
|
||
zip_sha256: actual_sha256,
|
||
file_count,
|
||
project_dir: directory.to_string_lossy().into_owned(),
|
||
};
|
||
let body = serde_json::to_string_pretty(&record)
|
||
.map_err(|error| format!("写入模板安装记录失败:{error}"))?;
|
||
write_game_creator_private_file(
|
||
&directory.join(TEMPLATE_INSTALLED_MARKER_FILE),
|
||
body.as_bytes(),
|
||
"模板安装记录",
|
||
)?;
|
||
Ok(record)
|
||
}
|
||
|
||
fn copy_template_project(source_dir: &Path, target_root: &Path) -> Result<usize, String> {
|
||
let mut copied = 0_usize;
|
||
let mut stack = vec![source_dir.to_path_buf()];
|
||
while let Some(directory) = stack.pop() {
|
||
let entries =
|
||
fs::read_dir(&directory).map_err(|error| format!("读取模板目录失败:{error}"))?;
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if entry.file_name() == TEMPLATE_INSTALLED_MARKER_FILE {
|
||
continue;
|
||
}
|
||
let metadata = entry
|
||
.metadata()
|
||
.map_err(|error| format!("读取模板条目失败:{error}"))?;
|
||
if metadata.is_dir() {
|
||
stack.push(path);
|
||
continue;
|
||
}
|
||
if !metadata.is_file() {
|
||
return Err(format!("模板包含不支持的条目:{}", path.display()));
|
||
}
|
||
let relative = path
|
||
.strip_prefix(source_dir)
|
||
.map_err(|error| format!("模板条目路径无效:{error}"))?;
|
||
let target = target_root.join(relative);
|
||
if let Some(parent) = target.parent() {
|
||
ensure_game_creator_private_directory_tree(parent, "项目模板目录")?;
|
||
}
|
||
let bytes = fs::read(&path).map_err(|error| format!("读取模板文件失败:{error}"))?;
|
||
write_game_creator_private_file(&target, &bytes, "项目模板文件")?;
|
||
copied += 1;
|
||
}
|
||
}
|
||
if copied == 0 {
|
||
return Err("模板没有可复制的文件".to_string());
|
||
}
|
||
Ok(copied)
|
||
}
|
||
|
||
fn find_template_summary(
|
||
cache_root: &Path,
|
||
template_id: &str,
|
||
template_version: &str,
|
||
) -> Result<GameTemplateSummary, String> {
|
||
let body = read_cached_index(cache_root).ok_or_else(|| "本机没有模板库清单缓存".to_string())?;
|
||
let (_, templates) = parse_game_template_library_index(&body)?;
|
||
templates
|
||
.into_iter()
|
||
.find(|entry| entry.id == template_id && entry.template_version == template_version)
|
||
.ok_or_else(|| "模板库清单里没有该模板版本".to_string())
|
||
}
|
||
|
||
async fn ensure_template_installed(
|
||
cache_root: &Path,
|
||
template_id: &str,
|
||
template_version: &str,
|
||
identity: &PlatformSessionIdentity,
|
||
) -> Result<InstalledGameTemplateRecord, String> {
|
||
validate_platform_session_identity(identity)?;
|
||
let installed_directory = installed_template_dir(cache_root, template_id, template_version)?;
|
||
if let Some(record) = read_installed_record(&installed_directory) {
|
||
return Ok(record);
|
||
}
|
||
let summary = find_template_summary(cache_root, template_id, template_version)?;
|
||
let client = build_template_library_client();
|
||
let url = template_object_url(&summary.zip_key)?;
|
||
let bytes = fetch_limited_bytes(&client, &url, TEMPLATE_ARCHIVE_MAX_BYTES).await?;
|
||
with_validated_platform_session_identity(identity, || {
|
||
install_template_archive(cache_root, &summary, &bytes)
|
||
})
|
||
}
|
||
|
||
/// 清单来源:远端读取或本机缓存兜底。快照里的 `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<String, String>,
|
||
cached: Option<String>,
|
||
) -> 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,
|
||
) -> Result<GameTemplateLibrarySnapshot, String> {
|
||
let identity = require_template_library_access().await?;
|
||
let cache_root = template_cache_root(&app)?;
|
||
ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?;
|
||
let index_url = format!(
|
||
"{}/{}",
|
||
template_library_base()?,
|
||
TEMPLATE_LIBRARY_INDEX_KEY
|
||
);
|
||
let client = build_template_library_client();
|
||
let remote =
|
||
match fetch_limited_bytes(&client, &index_url, TEMPLATE_LIBRARY_MAX_INDEX_BYTES).await {
|
||
// 响应回来了但正文不是合法 UTF-8 属于「远端答非所问」,不能用缓存掩盖。
|
||
Ok(bytes) => {
|
||
Ok(String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?)
|
||
}
|
||
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);
|
||
let entries = templates
|
||
.iter()
|
||
.map(|summary| {
|
||
to_entry(
|
||
summary,
|
||
installed_record_for(&installed, &summary.id).as_ref(),
|
||
)
|
||
})
|
||
.collect::<Result<Vec<_>, _>>()?;
|
||
let entries = apply_template_library_fixtures(entries);
|
||
Ok(GameTemplateLibrarySnapshot {
|
||
schema_version: header.schema_version,
|
||
library: header.library,
|
||
library_version: header.library_version,
|
||
updated_at: header.updated_at,
|
||
fetched_at_millis: now_millis(),
|
||
source: source.as_str().to_string(),
|
||
templates: entries,
|
||
})
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn download_game_template(
|
||
app: tauri::AppHandle,
|
||
template_id: String,
|
||
template_version: String,
|
||
) -> Result<InstalledGameTemplate, String> {
|
||
let identity = require_template_library_access().await?;
|
||
let cache_root = template_cache_root(&app)?;
|
||
ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?;
|
||
let record = ensure_template_installed(
|
||
&cache_root,
|
||
template_id.trim(),
|
||
template_version.trim(),
|
||
&identity,
|
||
)
|
||
.await?;
|
||
validate_platform_session_identity(&identity)?;
|
||
Ok(InstalledGameTemplate {
|
||
template_id: record.template_id,
|
||
template_version: record.template_version,
|
||
installed_at_millis: record.installed_at_millis,
|
||
zip_sha256: record.zip_sha256,
|
||
file_count: record.file_count,
|
||
project_dir: record.project_dir,
|
||
})
|
||
}
|
||
|
||
/// 用已安装模板在自动工作区根目录下建项目:先把模板文件铺进新目录,再走标准项目初始化。
|
||
pub(crate) fn create_project_from_installed_template_at(
|
||
projects_root: &Path,
|
||
installed_project_dir: &Path,
|
||
requested_name: Option<&str>,
|
||
planning: bool,
|
||
) -> Result<InitLocalProjectResult, String> {
|
||
let requested_name = requested_name
|
||
.map(normalize_game_creation_project_name)
|
||
.transpose()?;
|
||
if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() {
|
||
return Err("自动工作区根目录必须是绝对路径".to_string());
|
||
}
|
||
if !installed_project_dir.is_dir() {
|
||
return Err("模板尚未安装到本机".to_string());
|
||
}
|
||
ensure_game_creator_private_directory_tree(projects_root, "自动工作区根目录")?;
|
||
prepare_game_creator_private_path_for_read(projects_root, true, "自动工作区根目录")?;
|
||
let metadata = fs::symlink_metadata(projects_root).map_err(|error| {
|
||
format!(
|
||
"读取自动工作区根目录失败:{}: {error}",
|
||
projects_root.display()
|
||
)
|
||
})?;
|
||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||
return Err("自动工作区根目录必须是普通文件夹".to_string());
|
||
}
|
||
|
||
for _ in 0..16 {
|
||
let workspace_id = uuid::Uuid::new_v4().simple().to_string();
|
||
let short_id = &workspace_id[..8];
|
||
let project_name = requested_name.clone().unwrap_or_else(|| {
|
||
let prefix = if planning {
|
||
"策划项目"
|
||
} else {
|
||
"GameAgent 项目"
|
||
};
|
||
format!("{prefix} {short_id}")
|
||
});
|
||
let project_root = projects_root.join(format!("gameagent-{short_id}"));
|
||
match fs::create_dir(&project_root) {
|
||
Ok(()) => {
|
||
let result = (|| {
|
||
harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?;
|
||
enforce_project_permission_policy(&project_root, "project.create")?;
|
||
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
|
||
copy_template_project(installed_project_dir, &project_root)?;
|
||
if discover_local_cocos_project_root(&project_root)?.is_some() {
|
||
let package_path = project_root.join("package.json");
|
||
let mut package: serde_json::Value = serde_json::from_slice(
|
||
&fs::read(&package_path)
|
||
.map_err(|error| format!("读取 Cocos 项目配置失败:{error}"))?,
|
||
)
|
||
.map_err(|error| format!("解析 Cocos 项目配置失败:{error}"))?;
|
||
package["name"] = serde_json::json!(project_name);
|
||
package["uuid"] = serde_json::json!(uuid::Uuid::new_v4().to_string());
|
||
let bytes = serde_json::to_vec_pretty(&package)
|
||
.map_err(|error| format!("序列化 Cocos 项目配置失败:{error}"))?;
|
||
write_game_creator_private_file(&package_path, &bytes, "Cocos 项目配置")?;
|
||
return import_local_cocos_project_at(
|
||
&project_root,
|
||
&format!("gameagent-{workspace_id}"),
|
||
&project_name,
|
||
);
|
||
}
|
||
init_local_game_project_at(
|
||
&project_root,
|
||
&format!("gameagent-{workspace_id}"),
|
||
&project_name,
|
||
)
|
||
})();
|
||
if result.is_err() {
|
||
let _ = fs::remove_dir_all(&project_root);
|
||
}
|
||
return result;
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"创建自动工作区失败:{}: {error}",
|
||
project_root.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Err("自动工作区命名冲突,请重试".to_string())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn create_automatic_local_game_project_from_template(
|
||
app: tauri::AppHandle,
|
||
template_id: String,
|
||
template_version: String,
|
||
name: Option<String>,
|
||
planning: Option<bool>,
|
||
projects_root: Option<String>,
|
||
) -> Result<InitLocalProjectResult, String> {
|
||
let identity = require_template_library_access().await?;
|
||
let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?;
|
||
let cache_root = template_cache_root(&app)?;
|
||
ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?;
|
||
let record = ensure_template_installed(
|
||
&cache_root,
|
||
template_id.trim(),
|
||
template_version.trim(),
|
||
&identity,
|
||
)
|
||
.await?;
|
||
with_validated_platform_session_identity(&identity, || {
|
||
create_project_from_installed_template_at(
|
||
&projects_root,
|
||
Path::new(&record.project_dir),
|
||
name.as_deref(),
|
||
planning.unwrap_or(false),
|
||
)
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn access_server(
|
||
status: u16,
|
||
body: &str,
|
||
change_identity: bool,
|
||
) -> (String, std::thread::JoinHandle<String>) {
|
||
use std::io::{Read, Write};
|
||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||
let body = body.to_string();
|
||
let server = std::thread::spawn(move || {
|
||
let (mut socket, _) = listener.accept().unwrap();
|
||
socket
|
||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||
.unwrap();
|
||
let mut request = Vec::new();
|
||
let mut buffer = [0; 1024];
|
||
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
|
||
let size = socket.read(&mut buffer).unwrap();
|
||
assert!(size > 0);
|
||
request.extend_from_slice(&buffer[..size]);
|
||
}
|
||
if change_identity {
|
||
crate::platform_session::clear_platform_session(2, 2);
|
||
}
|
||
write!(socket, "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
|
||
String::from_utf8(request).unwrap()
|
||
});
|
||
(url, server)
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn template_access_requires_current_account_and_explicit_server_grant() {
|
||
for (status, body, allowed) in [
|
||
(200, r#"{"agcTemplateLibraryEnabled":true}"#, true),
|
||
(200, r#"{"agcTemplateLibraryEnabled":false}"#, false),
|
||
(200, r#"{"imageEditorAgentSidebarEnabled":true}"#, false),
|
||
(200, r#"{"agcTemplateLibraryEnabled":"true"}"#, false),
|
||
(503, r#"{"agcTemplateLibraryEnabled":true}"#, false),
|
||
(200, "invalid JSON", false),
|
||
] {
|
||
let (origin, server) = access_server(status, body, false);
|
||
let _session = crate::platform_session::install_test_platform_session(
|
||
"template-user",
|
||
"template-test-token",
|
||
&origin,
|
||
);
|
||
assert_eq!(require_template_library_access().await.is_ok(), allowed);
|
||
let request = server.join().unwrap().to_lowercase();
|
||
assert!(request.starts_with("get /api/runtime/frontend-config "));
|
||
assert!(request.contains("authorization: bearer template-test-token"));
|
||
}
|
||
let _session = crate::platform_session::clear_test_platform_session();
|
||
assert!(!get_game_template_library_access().await.unwrap());
|
||
assert!(require_template_library_access().await.is_err());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn template_access_preserves_identity_during_token_rotation() {
|
||
let (origin, server) = access_server(200, r#"{"agcTemplateLibraryEnabled":true}"#, false);
|
||
let _session = crate::platform_session::install_test_platform_session(
|
||
"template-user",
|
||
"old-token",
|
||
&origin,
|
||
);
|
||
let frozen = current_platform_session().unwrap();
|
||
crate::platform_session::install_platform_session(
|
||
"template-user",
|
||
"new-token",
|
||
&origin,
|
||
1,
|
||
2,
|
||
)
|
||
.unwrap();
|
||
assert!(template_library_access_for_session(&frozen).await.unwrap());
|
||
server.join().unwrap();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn template_access_rejects_old_account_response_and_cached_install() {
|
||
let (origin, server) = access_server(200, r#"{"agcTemplateLibraryEnabled":true}"#, true);
|
||
let _session = crate::platform_session::install_test_platform_session(
|
||
"template-user",
|
||
"template-test-token",
|
||
&origin,
|
||
);
|
||
let identity = current_platform_session().unwrap().identity();
|
||
let error = require_template_library_access().await.unwrap_err();
|
||
assert!(error.contains("authentication-required"));
|
||
server.join().unwrap();
|
||
let error = ensure_template_installed(
|
||
Path::new("unused-cache"),
|
||
"demo-template",
|
||
"0.1.0",
|
||
&identity,
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(error.contains("authentication-required"));
|
||
assert!(
|
||
with_validated_platform_session_identity::<()>(&identity, || panic!(
|
||
"旧会话不得写入项目"
|
||
))
|
||
.is_err()
|
||
);
|
||
}
|
||
|
||
fn sample_index_body() -> String {
|
||
serde_json::json!({
|
||
"schemaVersion": TEMPLATE_LIBRARY_SCHEMA_VERSION,
|
||
"library": "agc-game-templates",
|
||
"libraryVersion": 1,
|
||
"updatedAt": "2026-09-17T00:00:00Z",
|
||
"templates": [
|
||
{
|
||
"id": "demo-template",
|
||
"title": "演示模板",
|
||
"summary": "用于测试",
|
||
"tags": ["2d", "demo"],
|
||
"runtime": "html",
|
||
"engine": "phaser",
|
||
"engineVersion": "4.2.1",
|
||
"templateVersion": "1.0.0",
|
||
"updatedAt": "2026-09-17T00:00:00Z",
|
||
"entry": "game/index.html",
|
||
"zipKey": "templates/v1/demo-template/template.zip",
|
||
"zipSizeBytes": 128,
|
||
"zipSha256": "0".repeat(64),
|
||
"coverKey": "templates/v1/demo-template/cover.png",
|
||
"coverWidth": 960,
|
||
"coverHeight": 540,
|
||
"coverSha256": "1".repeat(64),
|
||
"metadataKey": "templates/v1/demo-template/template.json"
|
||
}
|
||
]
|
||
})
|
||
.to_string()
|
||
}
|
||
|
||
fn sample_summary() -> GameTemplateSummary {
|
||
parse_game_template_library_index(&sample_index_body())
|
||
.expect("parse sample index")
|
||
.1
|
||
.remove(0)
|
||
}
|
||
|
||
fn build_archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||
let options = zip::write::SimpleFileOptions::default();
|
||
for (name, bytes) in entries {
|
||
writer.start_file(*name, options).expect("start file");
|
||
writer.write_all(bytes).expect("write file");
|
||
}
|
||
writer.finish().expect("finish archive").into_inner()
|
||
}
|
||
|
||
/// 自动工作区根目录要走私有 DACL 校验,和 `tests::unique_project_path` 一样
|
||
/// 在临时目录下取一个尚未存在的唯一路径,而不是用 tempdir 预先建好的目录。
|
||
fn unique_projects_root() -> PathBuf {
|
||
let millis = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.expect("system clock")
|
||
.as_millis();
|
||
std::env::temp_dir().join(format!(
|
||
"genarrative-agc-template-library-test-{}-{millis}",
|
||
std::process::id()
|
||
))
|
||
}
|
||
|
||
#[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 =
|
||
sample_index_body().replace(TEMPLATE_LIBRARY_SCHEMA_VERSION, "agc-template-library.v2");
|
||
assert!(parse_game_template_library_index(&unsupported).is_err());
|
||
|
||
let entry = serde_json::to_string(&sample_summary()).expect("serialize summary");
|
||
let duplicated = serde_json::json!({
|
||
"schemaVersion": TEMPLATE_LIBRARY_SCHEMA_VERSION,
|
||
"templates": [
|
||
serde_json::from_str::<serde_json::Value>(&entry).expect("value"),
|
||
serde_json::from_str::<serde_json::Value>(&entry).expect("value"),
|
||
]
|
||
})
|
||
.to_string();
|
||
let error = parse_game_template_library_index(&duplicated).expect_err("duplicate rejected");
|
||
assert!(error.contains("重复模板"), "{error}");
|
||
}
|
||
|
||
#[test]
|
||
fn parses_content_addressed_objects_without_changing_the_template_contract() {
|
||
let mut index: serde_json::Value =
|
||
serde_json::from_str(&sample_index_body()).expect("sample index");
|
||
let keys = [
|
||
("zipKey", "template.zip", "a"),
|
||
("coverKey", "cover.png", "b"),
|
||
("metadataKey", "template.json", "c"),
|
||
]
|
||
.map(|(field, file, hash)| {
|
||
(
|
||
field,
|
||
format!(
|
||
"templates/v1/demo-template/sha256/{}/{file}",
|
||
hash.repeat(64)
|
||
),
|
||
)
|
||
});
|
||
for (field, key) in &keys {
|
||
index["templates"][0][field] = serde_json::json!(key);
|
||
}
|
||
let (_, templates) =
|
||
parse_game_template_library_index(&index.to_string()).expect("content addressed index");
|
||
let parsed = serde_json::to_value(&templates[0]).expect("serialize template");
|
||
for (field, key) in &keys {
|
||
assert_eq!(parsed[field], *key);
|
||
assert!(template_object_url(key)
|
||
.expect("trusted URL")
|
||
.ends_with(key));
|
||
}
|
||
assert_eq!(templates[0].template_version, "1.0.0");
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_object_keys_outside_the_templates_prefix() {
|
||
assert!(validate_template_object_key("agc/templates/v1/demo/template.zip").is_err());
|
||
assert!(validate_template_object_key("templates/../secret").is_err());
|
||
assert!(validate_template_object_key("/templates/a.zip").is_err());
|
||
assert!(validate_template_object_key("templates\\a.zip").is_err());
|
||
assert!(validate_template_object_key("templates/v1/demo/template.zip").is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_template_base_url_outside_trusted_oss() {
|
||
assert!(validated_template_library_base_url(
|
||
"https://agc-dev.oss-rg-china-mainland.aliyuncs.com"
|
||
)
|
||
.is_ok());
|
||
assert!(validated_template_library_base_url(
|
||
"http://agc-dev.oss-rg-china-mainland.aliyuncs.com"
|
||
)
|
||
.is_err());
|
||
assert!(validated_template_library_base_url("https://evil.example.com").is_err());
|
||
assert!(validated_template_library_base_url(
|
||
"https://agc-dev.oss-rg-china-mainland.aliyuncs.com/other-prefix"
|
||
)
|
||
.is_err());
|
||
}
|
||
|
||
/// 固定 2026-09-17 实际发布的 `templates/index.json`:客户端解析必须与线上契约一致。
|
||
#[test]
|
||
fn parses_the_published_library_index_fixture() {
|
||
let body = include_str!("../tests/fixtures/agc-template-library-index.json");
|
||
let (header, templates) =
|
||
parse_game_template_library_index(body).expect("parse published index");
|
||
assert_eq!(header.schema_version, TEMPLATE_LIBRARY_SCHEMA_VERSION);
|
||
assert_eq!(header.library, "agc-game-templates");
|
||
assert_eq!(
|
||
templates
|
||
.iter()
|
||
.map(|entry| entry.id.as_str())
|
||
.collect::<Vec<_>>(),
|
||
vec![
|
||
"blank-2d-canvas",
|
||
"blank-3d-scene",
|
||
"blank-web",
|
||
"cocos-empty-2d",
|
||
"cocos-empty-3d",
|
||
"cocos-empty-3d-hq",
|
||
"cocos-hello-world",
|
||
"phaser-2d-starter",
|
||
"threejs-3d-starter",
|
||
]
|
||
);
|
||
let first = templates
|
||
.iter()
|
||
.find(|entry| entry.id == "phaser-2d-starter")
|
||
.expect("phaser template");
|
||
assert_eq!(first.runtime, "html");
|
||
assert_eq!(first.tags.first().map(String::as_str), Some("起步工程"));
|
||
assert!(first.cover_width > 0 && first.cover_height > 0);
|
||
|
||
let entry = to_entry(first, None).expect("build entry");
|
||
let base = template_library_base().expect("trusted base");
|
||
assert_eq!(
|
||
entry.zip_url,
|
||
format!("{base}/templates/v1/phaser-2d-starter/template.zip")
|
||
);
|
||
assert_eq!(
|
||
entry.cover_url,
|
||
format!("{base}/templates/v1/phaser-2d-starter/cover.svg")
|
||
);
|
||
assert!(!entry.installed);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_archive_entries_that_escape_the_destination() {
|
||
assert!(safe_archive_relative_path("game/index.html").is_ok());
|
||
assert!(safe_archive_relative_path("./game/index.html").is_ok());
|
||
assert!(safe_archive_relative_path("../escape.txt").is_err());
|
||
assert!(safe_archive_relative_path("game/../../escape.txt").is_err());
|
||
assert!(safe_archive_relative_path("C:/escape.txt").is_err());
|
||
assert!(safe_archive_relative_path("/escape.txt").is_err());
|
||
|
||
let archive = build_archive(&[("../escape.txt", b"nope")]);
|
||
let destination = tempfile::tempdir().expect("temp dir");
|
||
assert!(extract_template_archive(&archive, destination.path()).is_err());
|
||
assert!(!destination
|
||
.path()
|
||
.parent()
|
||
.unwrap()
|
||
.join("escape.txt")
|
||
.exists());
|
||
// 越界归档失败后不能留下安装记录:目录残留不算「已下载」。
|
||
assert!(collect_installed_records(destination.path()).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn installs_template_archive_and_reports_it_as_installed() {
|
||
let cache_root = tempfile::tempdir().expect("temp dir");
|
||
let archive = build_archive(&[
|
||
("game/index.html", b"<html></html>"),
|
||
("game/game.js", b"console.log('demo');"),
|
||
]);
|
||
let mut summary = sample_summary();
|
||
summary.zip_size_bytes = archive.len() as u64;
|
||
summary.zip_sha256 = sha256_hex(&archive);
|
||
|
||
let record = install_template_archive(cache_root.path(), &summary, &archive)
|
||
.expect("install template");
|
||
assert_eq!(record.file_count, 2);
|
||
let installed = collect_installed_records(cache_root.path());
|
||
assert_eq!(installed.len(), 1);
|
||
assert_eq!(installed[0].template_version, "1.0.0");
|
||
|
||
let entry = to_entry(
|
||
&summary,
|
||
installed_record_for(&installed, &summary.id).as_ref(),
|
||
)
|
||
.expect("build entry");
|
||
assert!(entry.installed);
|
||
assert_eq!(entry.installed_version.as_deref(), Some("1.0.0"));
|
||
assert!(entry.zip_url.starts_with("https://"));
|
||
assert!(entry
|
||
.cover_url
|
||
.ends_with("/templates/v1/demo-template/cover.png"));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_archive_when_size_or_digest_do_not_match_the_index() {
|
||
let cache_root = tempfile::tempdir().expect("temp dir");
|
||
let archive = build_archive(&[("game/index.html", b"<html></html>")]);
|
||
let mut summary = sample_summary();
|
||
summary.zip_size_bytes = archive.len() as u64 + 1;
|
||
let error = install_template_archive(cache_root.path(), &summary, &archive)
|
||
.expect_err("size mismatch rejected");
|
||
assert!(error.contains("大小校验失败"), "{error}");
|
||
|
||
summary.zip_size_bytes = archive.len() as u64;
|
||
summary.zip_sha256 = "f".repeat(64);
|
||
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]
|
||
fn creates_project_from_installed_template_without_leaking_install_marker() {
|
||
let cache_root = tempfile::tempdir().expect("temp dir");
|
||
let projects_root = unique_projects_root();
|
||
let archive = build_archive(&[
|
||
("game/index.html", b"<html><body>template</body></html>"),
|
||
("assets/README.txt", b"template assets"),
|
||
]);
|
||
let mut summary = sample_summary();
|
||
summary.zip_size_bytes = archive.len() as u64;
|
||
summary.zip_sha256 = sha256_hex(&archive);
|
||
let record = install_template_archive(cache_root.path(), &summary, &archive)
|
||
.expect("install template");
|
||
|
||
let result = create_project_from_installed_template_at(
|
||
&projects_root,
|
||
Path::new(&record.project_dir),
|
||
Some("模板项目"),
|
||
false,
|
||
)
|
||
.expect("create project from template");
|
||
assert_eq!(result.manifest.name, "模板项目");
|
||
let project_root = Path::new(&result.project_path);
|
||
let index =
|
||
fs::read_to_string(project_root.join("game/index.html")).expect("template file copied");
|
||
assert!(index.contains("template"));
|
||
assert!(project_root.join("assets/README.txt").is_file());
|
||
assert!(!project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).exists());
|
||
assert!(!project_root.join("game/index.html.orig").exists());
|
||
fs::remove_dir_all(&projects_root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn refuses_to_create_project_when_template_is_not_installed() {
|
||
let projects_root = tempfile::tempdir().expect("temp dir");
|
||
let missing = projects_root.path().join("missing-template");
|
||
let error =
|
||
create_project_from_installed_template_at(projects_root.path(), &missing, None, false)
|
||
.expect_err("missing template rejected");
|
||
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::<Vec<_>>()
|
||
})
|
||
.unwrap_or_default();
|
||
assert!(leftovers.is_empty(), "失败不得留下项目目录:{leftovers:?}");
|
||
}
|
||
|
||
fn template_source_files(root: &Path) -> Vec<(String, Vec<u8>)> {
|
||
fn collect(root: &Path, directory: &Path, files: &mut Vec<(String, Vec<u8>)>) {
|
||
for entry in fs::read_dir(directory).expect("read template directory") {
|
||
let path = entry.expect("template entry").path();
|
||
if path.is_dir() {
|
||
collect(root, &path, files);
|
||
} else {
|
||
files.push((
|
||
path.strip_prefix(root)
|
||
.expect("relative path")
|
||
.to_string_lossy()
|
||
.replace('\\', "/"),
|
||
fs::read(&path).expect("read template file"),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
let mut files = Vec::new();
|
||
collect(root, root, &mut files);
|
||
files.sort_by(|left, right| left.0.cmp(&right.0));
|
||
files
|
||
}
|
||
|
||
fn assert_cocos_template_creates_independent_projects(
|
||
summary: &GameTemplateSummary,
|
||
archive: &[u8],
|
||
) {
|
||
let cache_root = tempfile::tempdir().expect("cache directory");
|
||
let projects_root = unique_projects_root();
|
||
let record = install_template_archive(cache_root.path(), summary, archive)
|
||
.expect("install Cocos template");
|
||
let installed_root = Path::new(&record.project_dir);
|
||
let installed_files = template_source_files(installed_root);
|
||
let original_package: serde_json::Value = serde_json::from_slice(
|
||
&fs::read(installed_root.join("package.json")).expect("installed package"),
|
||
)
|
||
.expect("parse installed package");
|
||
let mut project_uuids = std::collections::HashSet::new();
|
||
for name in ["Cocos 模板项目一", "Cocos 模板项目二"] {
|
||
let created = create_project_from_installed_template_at(
|
||
&projects_root,
|
||
installed_root,
|
||
Some(name),
|
||
false,
|
||
)
|
||
.expect("create Cocos project from template");
|
||
let root = Path::new(&created.project_path);
|
||
assert_eq!(created.manifest.name, name);
|
||
assert_eq!(created.manifest.cocos_project_root.as_deref(), Some("."));
|
||
assert!(created.manifest.godot_project_root.is_none());
|
||
assert!(root.join(".agent/manifest.json").is_file());
|
||
assert!(root.join(".agent/agent.db").is_file());
|
||
for unexpected in ["game", "memory", "exports", TEMPLATE_INSTALLED_MARKER_FILE] {
|
||
assert!(!root.join(unexpected).exists(), "unexpected {unexpected}");
|
||
}
|
||
let package: serde_json::Value = serde_json::from_slice(
|
||
&fs::read(root.join("package.json")).expect("created package"),
|
||
)
|
||
.expect("parse created package");
|
||
assert_eq!(package["name"], name);
|
||
assert_eq!(package["creator"]["version"], "3.8.8");
|
||
let uuid = package["uuid"].as_str().expect("project uuid");
|
||
uuid::Uuid::parse_str(uuid).expect("valid project uuid");
|
||
assert_ne!(package["uuid"], original_package["uuid"]);
|
||
assert!(project_uuids.insert(uuid.to_string()));
|
||
for (relative, bytes) in &installed_files {
|
||
if relative != "package.json" && relative != TEMPLATE_INSTALLED_MARKER_FILE {
|
||
assert_eq!(&fs::read(root.join(relative)).expect("copied file"), bytes);
|
||
}
|
||
}
|
||
}
|
||
assert_eq!(template_source_files(installed_root), installed_files);
|
||
fs::remove_dir_all(&projects_root).expect("remove test projects");
|
||
}
|
||
|
||
#[test]
|
||
fn installs_official_cocos_templates_and_creates_native_projects() {
|
||
let library = Path::new(env!("CARGO_MANIFEST_DIR")).join("../template-library/v1");
|
||
for id in [
|
||
"cocos-empty-2d",
|
||
"cocos-empty-3d",
|
||
"cocos-empty-3d-hq",
|
||
"cocos-hello-world",
|
||
] {
|
||
let source = library.join(id).join("project");
|
||
let source_files = template_source_files(&source);
|
||
let entries = source_files
|
||
.iter()
|
||
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
|
||
.collect::<Vec<_>>();
|
||
let archive = build_archive(&entries);
|
||
let mut summary = sample_summary();
|
||
summary.id = id.to_string();
|
||
summary.zip_size_bytes = archive.len() as u64;
|
||
summary.zip_sha256 = sha256_hex(&archive);
|
||
assert_cocos_template_creates_independent_projects(&summary, &archive);
|
||
assert_eq!(template_source_files(&source), source_files);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "需要网络:下载线上 Cocos 模板并验证原生建项"]
|
||
async fn downloads_and_creates_live_cocos_templates() {
|
||
let base = template_library_base().expect("trusted base");
|
||
let client = build_template_library_client();
|
||
let body = fetch_limited_bytes(
|
||
&client,
|
||
&format!("{base}/{TEMPLATE_LIBRARY_INDEX_KEY}"),
|
||
TEMPLATE_LIBRARY_MAX_INDEX_BYTES,
|
||
)
|
||
.await
|
||
.expect("fetch live index");
|
||
let (_, templates) =
|
||
parse_game_template_library_index(&String::from_utf8(body).expect("utf-8 index"))
|
||
.expect("parse live index");
|
||
for id in [
|
||
"cocos-empty-2d",
|
||
"cocos-empty-3d",
|
||
"cocos-empty-3d-hq",
|
||
"cocos-hello-world",
|
||
] {
|
||
let summary = templates.iter().find(|entry| entry.id == id).expect(id);
|
||
assert_eq!(summary.runtime, "cocos");
|
||
assert_eq!(summary.engine_version, "3.8.8");
|
||
assert_eq!(summary.entry, "package.json");
|
||
let bytes = fetch_limited_bytes(
|
||
&client,
|
||
&template_object_url(&summary.zip_key).expect("trusted zip url"),
|
||
TEMPLATE_ARCHIVE_MAX_BYTES,
|
||
)
|
||
.await
|
||
.expect("download Cocos template");
|
||
assert_cocos_template_creates_independent_projects(summary, &bytes);
|
||
}
|
||
}
|
||
|
||
/// 正式构建(未开 feature)必须恒等透传:注入路径不能出现在默认产物里。
|
||
#[cfg(not(feature = "template-library-fixtures"))]
|
||
#[test]
|
||
fn fixtures_are_inert_without_the_feature() {
|
||
let index_body = include_str!("../tests/fixtures/agc-template-library-index.json");
|
||
let (_, templates) =
|
||
parse_game_template_library_index(index_body).expect("parse fixture index");
|
||
let entries = templates
|
||
.iter()
|
||
.map(|summary| to_entry(summary, None).expect("entry"))
|
||
.collect::<Vec<_>>();
|
||
let original = entries.len();
|
||
let applied = apply_template_library_fixtures(entries);
|
||
assert_eq!(applied.len(), original, "默认构建不应注入假数据");
|
||
}
|
||
|
||
/// 开启 feature 后同一次调用必须补齐到配置条数。
|
||
#[cfg(feature = "template-library-fixtures")]
|
||
#[test]
|
||
fn fixtures_expand_entries_when_the_feature_is_enabled() {
|
||
let index_body = include_str!("../tests/fixtures/agc-template-library-index.json");
|
||
let (_, templates) =
|
||
parse_game_template_library_index(index_body).expect("parse fixture index");
|
||
let entries = templates
|
||
.iter()
|
||
.map(|summary| to_entry(summary, None).expect("entry"))
|
||
.collect::<Vec<_>>();
|
||
let applied = apply_template_library_fixtures(entries);
|
||
assert_eq!(applied.len(), fixtures::synthetic_template_count());
|
||
assert!(applied.len() > templates.len());
|
||
}
|
||
|
||
/// 可选的真连检查:`cargo test --bin genarrative-ai-game-creator-shell template_library -- --ignored`。
|
||
/// 默认跳过,避免离线环境因网络失败误报。
|
||
#[tokio::test]
|
||
#[ignore = "需要网络:校验客户端能真实读到线上模板库清单与对象键"]
|
||
async fn fetches_the_live_template_library_index() {
|
||
let base = template_library_base().expect("trusted base");
|
||
let client = build_template_library_client();
|
||
let body = fetch_limited_bytes(
|
||
&client,
|
||
&format!("{base}/{TEMPLATE_LIBRARY_INDEX_KEY}"),
|
||
TEMPLATE_LIBRARY_MAX_INDEX_BYTES,
|
||
)
|
||
.await
|
||
.expect("fetch live index");
|
||
let body = String::from_utf8(body).expect("utf-8 index");
|
||
let (header, templates) =
|
||
parse_game_template_library_index(&body).expect("parse live index");
|
||
assert_eq!(header.schema_version, TEMPLATE_LIBRARY_SCHEMA_VERSION);
|
||
assert!(!templates.is_empty(), "线上模板库应当至少有一个模板");
|
||
for entry in &templates {
|
||
let zip_url = template_object_url(&entry.zip_key).expect("trusted zip url");
|
||
assert!(zip_url.starts_with(&format!("{base}/")));
|
||
}
|
||
}
|
||
|
||
/// 可选的真连检查:下载线上模板包并安装到临时目录,证明"清单 → 下载 → 摘要校验 → 解压"整条链路可用。
|
||
#[tokio::test]
|
||
#[ignore = "需要网络:下载并安装线上模板包"]
|
||
async fn downloads_and_installs_a_live_template() {
|
||
let cache_root = tempfile::tempdir().expect("temp dir");
|
||
let base = template_library_base().expect("trusted base");
|
||
let client = build_template_library_client();
|
||
let body = fetch_limited_bytes(
|
||
&client,
|
||
&format!("{base}/{TEMPLATE_LIBRARY_INDEX_KEY}"),
|
||
TEMPLATE_LIBRARY_MAX_INDEX_BYTES,
|
||
)
|
||
.await
|
||
.expect("fetch live index");
|
||
let body = String::from_utf8(body).expect("utf-8 index");
|
||
let (_, templates) = parse_game_template_library_index(&body).expect("parse live index");
|
||
let entry = templates
|
||
.iter()
|
||
.find(|template| template.id == "blank-web")
|
||
.expect("线上应存在 blank-web 模板");
|
||
let bytes = fetch_limited_bytes(
|
||
&client,
|
||
&template_object_url(&entry.zip_key).expect("trusted zip url"),
|
||
TEMPLATE_ARCHIVE_MAX_BYTES,
|
||
)
|
||
.await
|
||
.expect("download live template");
|
||
let record = install_template_archive(cache_root.path(), entry, &bytes)
|
||
.expect("install live template");
|
||
assert!(record.file_count >= 5, "模板文件数 {}", record.file_count);
|
||
let project_root = Path::new(&record.project_dir);
|
||
assert!(project_root.join("game/index.html").is_file());
|
||
assert!(project_root.join("game/main.js").is_file());
|
||
assert!(project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).is_file());
|
||
|
||
// 同一条链路继续建项目:线上模板 → 本机安装 → 新项目目录。
|
||
let projects_root = unique_projects_root();
|
||
let created = create_project_from_installed_template_at(
|
||
&projects_root,
|
||
project_root,
|
||
Some("线上模板项目"),
|
||
false,
|
||
)
|
||
.expect("create project from live template");
|
||
assert_eq!(created.manifest.name, "线上模板项目");
|
||
let created_root = Path::new(&created.project_path);
|
||
assert!(created_root.join("game/index.html").is_file());
|
||
assert!(created_root.join("game/main.js").is_file());
|
||
assert!(created_root.join(".agent/manifest.json").is_file());
|
||
fs::remove_dir_all(&projects_root).ok();
|
||
}
|
||
}
|