Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/export.rs
T
suzmii 3aa3e33ceb
Project CI / AI game creator shell Rust crates (pull_request) Failing after 56s
Project CI / Backend tests (pull_request) Failing after 23s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 5m7s
Project CI / Repository checks (pull_request) Failing after 21s
Project CI / Frontend 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 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
实现统一项目发行版本
以 projectVersion 作为 AGC 唯一用户版本,支持同版本重复提交和版本回退。

保留 publicationRevision 并发校验与旧客户端自动递增兼容。
2026-10-03 16:41:04 +08:00

969 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::io::{Cursor, Read};
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectExportPackageFileDigest {
pub(crate) path: String,
pub(crate) size_bytes: u64,
pub(crate) sha256: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectExportPackagePayload {
pub(crate) package_relative_path: String,
pub(crate) package_bytes: Vec<u8>,
pub(crate) package_sha256: String,
pub(crate) package_size_bytes: u64,
pub(crate) files: Vec<LocalProjectExportPackageFileDigest>,
}
pub(crate) fn export_local_project_package_at(
root: &Path,
) -> Result<LocalProjectExportPackageResult, String> {
validate_project_root(root)?;
super::verification::validate_project_game_entry(root)?;
ensure_project_export_readme(root)?;
let files = collect_project_export_package_files(root)?;
let total_bytes = files.iter().map(|(_, _, size)| *size).sum::<u64>();
if files.len() > MAX_PROJECT_EXPORT_PACKAGE_FILES {
return Err(format!(
"试玩包文件数量超过上限:{} > {}",
files.len(),
MAX_PROJECT_EXPORT_PACKAGE_FILES
));
}
if total_bytes > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
return Err(format!(
"试玩包文件总大小超过上限:{}B > {}B",
total_bytes, MAX_PROJECT_EXPORT_PACKAGE_BYTES
));
}
let package_relative_path = next_project_export_package_relative_path(root)?;
let package_path = resolve_local_project_path(root, &package_relative_path)?;
if let Some(parent) = package_path.parent() {
ensure_game_creator_private_directory_tree(parent, "导出目录")?;
prepare_game_creator_private_path_for_read(parent, true, "导出目录")?;
}
prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let file = options
.open(&package_path)
.map_err(|error| format!("创建试玩包失败:{}: {error}", package_path.display()))?;
if let Err(error) = harden_new_game_creator_private_path(&package_path, false, "试玩包") {
drop(file);
let _ = fs::remove_file(&package_path);
return Err(error);
}
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (relative_path, absolute_path, _) in &files {
writer
.start_file(relative_path, options)
.map_err(|error| format!("写入试玩包条目失败:{relative_path}: {error}"))?;
prepare_game_creator_private_path_for_read(absolute_path, false, "导出文件")?;
let bytes = fs::read(absolute_path)
.map_err(|error| format!("读取导出文件失败:{}: {error}", absolute_path.display()))?;
writer
.write_all(&bytes)
.map_err(|error| format!("写入试玩包文件失败:{relative_path}: {error}"))?;
}
writer
.finish()
.map_err(|error| format!("完成试玩包失败:{}: {error}", package_path.display()))?;
prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?;
let updated_at = unix_timestamp();
let log_path = root.join(".agent/logs/command.log");
let output = format!(
"导出试玩包:{},{} 个文件,{}B",
package_relative_path,
files.len(),
total_bytes
);
let line = format!("{updated_at} project.export_package: {output}\n");
append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?;
record_command_run(
root,
GameCreationAppCommandRunState {
command_id: "project.export_package".to_string(),
status: GameCreationAppCommandRunStatus::Completed,
output,
log_path: log_path.to_string_lossy().into_owned(),
updated_at,
},
)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "project.export_package",
"packagePath": package_relative_path,
"fileCount": files.len(),
"totalBytes": total_bytes,
}),
)?;
Ok(LocalProjectExportPackageResult {
package_path: package_path.to_string_lossy().into_owned(),
package_relative_path,
file_count: files.len(),
total_bytes,
})
}
/// Read a previously exported package for the explicit AGC publish flow.
///
/// The caller receives the package bytes and a deterministic file manifest, but
/// never receives a filesystem path that it could accidentally send to the API.
/// 发布前构建的超时上限:与 `project.verify` 的上限保持一致(构建属于常规步骤,
/// 给足时间但必须有界),避免发布路径越过校验器允许的区间。
pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300;
fn project_has_runnable_prototype(manifest: &GameCreationAppManifest) -> bool {
let has_completed_prototype_task = manifest.tasks.iter().any(|task| {
task.id == "code-prototype" && task.status == GameCreationAppTaskStatus::Completed
});
let has_running_preview = manifest.preview.as_ref().is_some_and(|preview| {
preview.status == GameCreationAppPreviewStatus::Running
&& preview
.url
.as_deref()
.is_some_and(|url| !url.trim().is_empty())
});
has_completed_prototype_task || has_running_preview
}
/// 找到声明了 `scripts.build` 的 npm 工作目录(项目根或 `game/` 子工程)。
///
/// 只读 `package.json`,不执行任何东西;真正的执行交给 `project.verify` 的受控
/// npm 运行器(脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义、沙箱与超时都在那里)。
pub(crate) fn resolve_publish_build_cwd(root: &Path) -> Result<Option<&'static str>, String> {
for cwd in [".", "game"] {
let package_root = if cwd == "." {
root.to_path_buf()
} else {
resolve_local_project_path(root, cwd)?
};
let package_path = package_root.join("package.json");
let metadata = match fs::symlink_metadata(&package_path) {
Ok(metadata) => metadata,
Err(_) => continue,
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
continue;
}
let Ok(content) = fs::read_to_string(&package_path) else {
continue;
};
let Ok(package) = serde_json::from_str::<serde_json::Value>(&content) else {
continue;
};
let declared = package
.get("scripts")
.and_then(|scripts| scripts.get("build"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if declared.is_some() {
return Ok(Some(cwd));
}
}
Ok(None)
}
/// 读取声明的 build 脚本原文:`project.verify` 用它做 expectedCommand 反漂移校验。
pub(crate) fn read_publish_build_command(
root: &Path,
cwd_relative: &str,
) -> Result<String, String> {
let package_root = if cwd_relative == "." {
root.to_path_buf()
} else {
resolve_local_project_path(root, cwd_relative)?
};
let package_path = package_root.join("package.json");
let content = fs::read_to_string(&package_path).map_err(|error| {
format!(
"读取 package.json 失败:{}: {error}",
package_path.display()
)
})?;
let package: serde_json::Value = serde_json::from_str(&content)
.map_err(|error| format!("解析 package.json 失败:{error}"))?;
package
.get("scripts")
.and_then(|scripts| scripts.get("build"))
.and_then(serde_json::Value::as_str)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| "package.json 未定义 build 脚本".to_string())
}
/// 构建失败的日志尾部:命令输出有界,直接回传最后一段给作者判断。
fn publish_build_failure_tail(output: &str) -> String {
const MAX_CHARS: usize = 2_000;
let trimmed = output.trim();
let chars = trimmed.chars().count();
if chars <= MAX_CHARS {
return trimmed.to_string();
}
let tail = trimmed.chars().skip(chars - MAX_CHARS).collect::<String>();
format!("…{tail}")
}
/// 发布前构建计划:在哪个目录构建、构建脚本原文、以及是否需要先装依赖。
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct PublishBuildPlan {
pub(crate) cwd_relative: &'static str,
pub(crate) command: String,
/// `game/` 子工程缺 `node_modules` 时为 true:构建前必须先跑 `project.bootstrap`。
pub(crate) needs_dependency_install: bool,
}
/// 解析发布前构建计划;项目没有任何可构建的 npm 工程时返回可操作错误。
pub(crate) fn resolve_publish_build_plan(root: &Path) -> Result<PublishBuildPlan, String> {
let Some(cwd_relative) = resolve_publish_build_cwd(root)? else {
return Err(
"项目还没有可玩入口,且项目根 / game 目录的 package.json 都没有 build 脚本:请让 Agent 生成可玩产物,或补上 build 脚本后重试"
.to_string(),
);
};
let command = read_publish_build_command(root, cwd_relative)?;
let needs_dependency_install =
cwd_relative == "game" && !root.join("game").join("node_modules").is_dir();
Ok(PublishBuildPlan {
cwd_relative,
command,
needs_dependency_install,
})
}
/// 为发布导出试玩包:项目还没有可玩入口时,先跑项目自己的 `npm run build`。
///
/// 作者只需要点一次「发布」:已有可玩产物(`game/index.html` 或 `dist/index.html`)直接打包;
/// 只有源码时用 `project.verify` 的受控 npm 运行器执行 build,再校验入口并打包。构建失败
/// 返回带日志尾部的可操作错误,不回传本地路径。
pub(crate) async fn export_local_project_package_for_publish_at(
root: &Path,
) -> Result<LocalProjectExportPackageResult, String> {
if validate_project_game_entry(root).is_ok() {
ensure_publish_project_stack(root)?;
return export_local_project_package_at(root);
}
let manifest = read_manifest_for_project(root)?;
if !project_has_runnable_prototype(&manifest) {
return Err(
"首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。".to_string(),
);
}
let plan = resolve_publish_build_plan(root)?;
// 首版发行包合同只覆盖 Phaser 4 + Vite 的 npm 工程:在跑安装/构建之前先说清楚,
// 避免作者等完一次 npm install 才拿到一句泛化错误。
ensure_publish_project_stack_at(root, plan.cwd_relative)?;
// `game/` 子工程构建前必须先有依赖:缺 node_modules 时由发布流程自己补一次安装,
// 否则作者要点两次(先 bootstrap 再发布)。
if plan.needs_dependency_install {
let bootstrap =
crate::project::run_project_bootstrap_at(root, PUBLISH_BUILD_TIMEOUT_SECONDS).await?;
if bootstrap.status != "completed" {
return Err(format!(
"安装 game 依赖失败(npm install 未通过):\n{}",
publish_build_failure_tail(&bootstrap.output)
));
}
}
let built = crate::project::verification::run_project_verification_with_commit_at(
root,
"build",
&plan.command,
PUBLISH_BUILD_TIMEOUT_SECONDS,
plan.cwd_relative,
|| Ok(()),
)
.await?;
if built.status != "completed" {
return Err(format!(
"构建可玩版本失败(npm run build 未通过):\n{}",
publish_build_failure_tail(&built.output)
));
}
validate_project_game_entry(root)
.map_err(|error| format!("构建完成但项目仍没有可玩入口:{error}"))?;
export_local_project_package_at(root)
}
/// 解析依赖声明里的版本号主版本:跳过 `^`、`~`、`>=` 等前缀,取第一段连续数字。
///
/// `*`、`latest`、`workspace:*` 这类没有数字的声明返回 `None`,由调用方决定是否回退到
/// `package-lock.json` 的解析结果。
fn declared_version_major(spec: &str) -> Option<u64> {
let digits = spec
.trim()
.chars()
.skip_while(|character| !character.is_ascii_digit())
.take_while(char::is_ascii_digit)
.collect::<String>();
digits.parse::<u64>().ok()
}
/// 读取依赖声明原文;兼容 `"phaser": "4.2.1"` 与 `"phaser": { "version": "4.2.1" }` 两种写法。
fn declared_dependency_spec(package: &serde_json::Value, name: &str) -> Option<String> {
const SECTIONS: [&str; 3] = ["dependencies", "devDependencies", "peerDependencies"];
for section in SECTIONS {
let Some(entry) = package.get(section).and_then(|deps| deps.get(name)) else {
continue;
};
let spec = match entry {
serde_json::Value::String(spec) => spec.trim().to_string(),
serde_json::Value::Object(object) => object
.get("version")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.unwrap_or_default()
.to_string(),
_ => String::new(),
};
if !spec.is_empty() {
return Some(spec);
}
}
None
}
/// `package-lock.json` 的已解析版本:v2/v3 看 `packages["node_modules/<name>"]`,v1 看 `dependencies`。
fn locked_dependency_version(package_root: &Path, name: &str) -> Option<String> {
let lock_path = package_root.join("package-lock.json");
let content = fs::read_to_string(&lock_path).ok()?;
let lock: serde_json::Value = serde_json::from_str(&content).ok()?;
let from_packages = lock
.get("packages")
.and_then(|packages| packages.get(format!("node_modules/{name}")))
.and_then(|entry| entry.get("version"))
.and_then(serde_json::Value::as_str);
from_packages
.or_else(|| {
lock.get("dependencies")
.and_then(|dependencies| dependencies.get(name))
.and_then(|entry| entry.get("version"))
.and_then(serde_json::Value::as_str)
})
.map(|version| version.trim().to_string())
.filter(|version| !version.is_empty())
}
/// 读取某个 npm 工作区的 `package.json`;不存在或不是普通文件时返回 `None`。
fn read_publish_workspace_package(
root: &Path,
cwd_relative: &str,
) -> Result<Option<(PathBuf, serde_json::Value)>, String> {
let package_root = if cwd_relative == "." {
root.to_path_buf()
} else {
resolve_local_project_path(root, cwd_relative)?
};
let package_path = package_root.join("package.json");
let metadata = match fs::symlink_metadata(&package_path) {
Ok(metadata) => metadata,
Err(_) => return Ok(None),
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(format!("{cwd_relative}/package.json 必须是普通文件"));
}
let content = fs::read_to_string(&package_path)
.map_err(|error| format!("读取 {cwd_relative}/package.json 失败:{error}"))?;
let package: serde_json::Value = serde_json::from_str(&content)
.map_err(|error| format!("解析 {cwd_relative}/package.json 失败:{error}"))?;
Ok(Some((package_root, package)))
}
fn workspace_has_vite_config(package_root: &Path) -> bool {
[
"vite.config.js",
"vite.config.mjs",
"vite.config.cjs",
"vite.config.ts",
"vite.config.mts",
"vite.config.cts",
]
.iter()
.any(|name| package_root.join(name).is_file())
}
/// 发布到游戏广场首版只接受 Phaser 4 + Vite 的 npm 工程。
///
/// 客户端发布必须命中 npm 构建工作区;无 `package.json` 的单文件 HTML 项目不属于首版 Phaser 4 发布合同。
/// 判定顺序以**将被构建的那个工作区**为准,它没声明 phaser 时再看项目里的另一个工作区
/// (根工程 / `game` 子工程),避免多工作区项目被误判;校验失败必须给出可操作错误——
/// 缺什么依赖、当前声明是什么版本、要怎么做。错误文案只带项目内相对路径。
fn ensure_publish_project_stack_at(
root: &Path,
build_cwd_relative: &'static str,
) -> Result<(), String> {
let candidates: [&'static str; 2] = if build_cwd_relative == "." {
[".", "game"]
} else {
[build_cwd_relative, "."]
};
let mut phaser: Option<(String, String, PathBuf)> = None;
let mut vite_ready = false;
for cwd_relative in candidates {
let Some((package_root, package)) = read_publish_workspace_package(root, cwd_relative)?
else {
continue;
};
if phaser.is_none() {
if let Some(spec) = declared_dependency_spec(&package, "phaser") {
phaser = Some((spec, cwd_relative.to_string(), package_root.clone()));
}
}
if declared_dependency_spec(&package, "vite").is_some()
|| workspace_has_vite_config(&package_root)
{
vite_ready = true;
}
}
let Some((spec, cwd_relative, package_root)) = phaser else {
return Err(format!(
"发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{build_cwd_relative}/package.json 未声明 phaser 依赖。请先安装 Phaser 4(例如 npm install phaser@4.2.1)后重新发布。"
));
};
let major = declared_version_major(&spec).or_else(|| {
locked_dependency_version(&package_root, "phaser")
.as_deref()
.and_then(declared_version_major)
});
if major.is_some_and(|major| major != 4) {
return Err(format!(
"发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{cwd_relative}/package.json 声明的 phaser 版本是 {spec}。请升级到 Phaser 4(例如 npm install phaser@4.2.1)后重新发布。"
));
}
if !vite_ready {
return Err(format!(
"发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{cwd_relative}/package.json 未声明 vite 依赖,也没有 vite.config.*。请改用 Vite 构建(例如 npm create vite)后重新发布。"
));
}
Ok(())
}
/// 发布前校验工程栈:客户端发布必须使用 Phaser 4 + Vite npm 工程。
pub(crate) fn ensure_publish_project_stack(root: &Path) -> Result<(), String> {
let Some(cwd_relative) = resolve_publish_build_cwd(root)? else {
return Err(
"发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:项目没有可识别的 npm build 工作区。请先使用 Phaser 4 + Vite 工程并提供 npm run build 后重新发布。".to_string(),
);
};
ensure_publish_project_stack_at(root, cwd_relative)
}
/// 发布前的客户端预检:包体不得超过平台上限。
///
/// 服务端仍会独立校验;这里先判一次是为了给作者可操作错误(含实际体积与上限),
/// 而不是让他把包传上去再吃一个 413。上限单一来源在 `shared-contracts`。
fn ensure_within_platform_package_limit(package_bytes: u64) -> Result<(), String> {
let limit = shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES;
if package_bytes > limit {
return Err(format!(
"发行包 {:.1} MiB 超过平台上限 {} MiB;请精简资源后重新导出再发布。",
package_bytes as f64 / (1024.0 * 1024.0),
limit / (1024 * 1024)
));
}
Ok(())
}
pub(crate) fn read_local_project_export_package_at(
root: &Path,
package_relative_path: &str,
) -> Result<LocalProjectExportPackagePayload, String> {
validate_project_root(root)?;
let normalized = normalize_export_package_entry_path(package_relative_path)?;
if !normalized.starts_with("exports/playtest-package-")
|| !normalized.ends_with(".zip")
|| normalized.contains('/') && normalized.split('/').count() != 2
{
return Err("发行包路径必须是 exports/playtest-package-*.zip".to_string());
}
let package_path = resolve_local_project_path(root, &normalized)?;
prepare_game_creator_private_path_for_read(&package_path, false, "发行包")?;
let metadata = checked_export_package_metadata(&package_path, &normalized)?;
if !metadata.is_file() {
return Err("发行包必须是普通文件".to_string());
}
if metadata.len() == 0 || metadata.len() > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
return Err("发行包大小超出本地发布上限".to_string());
}
ensure_within_platform_package_limit(metadata.len())?;
let package_bytes =
fs::read(&package_path).map_err(|error| format!("读取发行包失败:{error}"))?;
if package_bytes.len() as u64 != metadata.len() {
return Err("发行包在读取期间发生变化,请重新导出".to_string());
}
let mut archive = zip::ZipArchive::new(Cursor::new(&package_bytes))
.map_err(|error| format!("读取发行包 ZIP 失败:{error}"))?;
let mut entries = Vec::with_capacity(archive.len());
let mut seen = BTreeSet::new();
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| format!("读取发行包条目失败:{error}"))?;
if entry.is_dir() {
continue;
}
let source_path = normalize_export_package_entry_path(entry.name())?;
// 本地试玩包以 game/index.html 为入口,而平台发行合同要求根
// index.html。把 game/ 前缀剥离到内存 ZIP,避免上传本地路径或修改
// 工作区里的原始导出文件;根目录的 README/assets 等公共条目原样保留。
let path = source_path
.strip_prefix("game/")
.unwrap_or(source_path.as_str())
.to_string();
let path = normalize_export_package_entry_path(&path)?;
if !seen.insert(path.clone()) {
return Err(format!("发行包包含重复条目:{path}"));
}
let expected_size = entry.size();
let mut content = Vec::with_capacity(expected_size.min(16 * 1024 * 1024) as usize);
entry
.read_to_end(&mut content)
.map_err(|error| format!("读取发行包文件失败:{path}: {error}"))?;
if content.len() as u64 != expected_size {
return Err(format!("发行包条目长度不一致:{path}"));
}
let content = normalize_release_asset_references(&path, content);
entries.push((path, content));
}
entries.sort_by(|left, right| left.0.cmp(&right.0));
if entries.is_empty() {
return Err("发行包没有可上传文件".to_string());
}
let mut normalized_writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (path, content) in &entries {
normalized_writer
.start_file(path, options)
.map_err(|error| format!("写入发行包条目失败:{path}: {error}"))?;
normalized_writer
.write_all(content)
.map_err(|error| format!("写入发行包文件失败:{path}: {error}"))?;
}
let normalized_cursor = normalized_writer
.finish()
.map_err(|error| format!("完成发行包失败:{error}"))?;
let package_bytes = normalized_cursor.into_inner();
if package_bytes.is_empty() || package_bytes.len() as u64 > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
return Err("归一化发行包大小超出本地发布上限".to_string());
}
let package_sha256 = format!("{:x}", Sha256::digest(&package_bytes));
let files = entries
.into_iter()
.map(|(path, content)| LocalProjectExportPackageFileDigest {
size_bytes: content.len() as u64,
sha256: format!("{:x}", Sha256::digest(&content)),
path,
})
.collect::<Vec<_>>();
if !files.iter().any(|file| file.path == "index.html") {
return Err("归一化发行包缺少根 index.html".to_string());
}
Ok(LocalProjectExportPackagePayload {
package_relative_path: normalized,
package_size_bytes: package_bytes.len() as u64,
package_bytes,
package_sha256,
files,
})
}
fn normalize_release_asset_references(path: &str, content: Vec<u8>) -> Vec<u8> {
let is_text = matches!(
path.rsplit('.')
.next()
.unwrap_or_default()
.to_ascii_lowercase()
.as_str(),
"css" | "html" | "htm" | "js" | "json" | "mjs"
);
if !is_text {
return content;
}
let mut source = match String::from_utf8(content) {
Ok(source) => source,
Err(error) => return error.into_bytes(),
};
for root in ["assets", "game", "ui"] {
source = source.replace(&format!("\"/{root}/"), &format!("\"{root}/"));
source = source.replace(&format!("'/{root}/"), &format!("'{root}/"));
source = source.replace(&format!("`/{root}/"), &format!("`{root}/"));
source = source.replace(&format!("url(/{root}/"), &format!("url({root}/"));
}
source.into_bytes()
}
#[cfg(test)]
mod tests {
use super::normalize_release_asset_references;
#[test]
fn normalize_release_asset_references_removes_root_prefix_only_in_text() {
let source = br#"<img src="/assets/hero.png"><script>fetch('/ui/config.json')</script><style>url(/game/icon.svg)</style>"#;
let normalized = normalize_release_asset_references("index.html", source.to_vec());
let normalized = String::from_utf8(normalized).expect("normalized text");
assert!(normalized.contains("src=\"assets/hero.png\""));
assert!(normalized.contains("fetch('ui/config.json')"));
assert!(normalized.contains("url(game/icon.svg)"));
assert_eq!(
normalize_release_asset_references("image.png", b"/assets/image.png".to_vec()),
b"/assets/image.png"
);
}
}
pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result<String, String> {
let seed = unix_millis();
for suffix in 0..1000 {
let file_name = if suffix == 0 {
format!("playtest-package-{seed}.zip")
} else {
format!("playtest-package-{seed}-{suffix}.zip")
};
let relative_path = format!("exports/{file_name}");
let package_path = resolve_local_project_path(root, &relative_path)?;
if !package_path.exists() {
return Ok(relative_path);
}
}
Err("无法生成唯一试玩包文件名".to_string())
}
pub(crate) fn list_local_project_export_packages_at(
root: &Path,
) -> Result<LocalProjectExportPackagesResult, String> {
validate_project_root(root)?;
let export_dir = resolve_local_project_path(root, "exports")?;
if !export_dir.exists() {
return Ok(LocalProjectExportPackagesResult {
project_path: root.to_string_lossy().into_owned(),
packages: Vec::new(),
});
}
let export_dir_metadata = checked_export_package_metadata(&export_dir, "exports")?;
if !export_dir_metadata.is_dir() {
return Err("exports 必须是目录".to_string());
}
let mut packages = Vec::new();
for entry in fs::read_dir(&export_dir)
.map_err(|error| format!("读取试玩包目录失败:{}: {error}", export_dir.display()))?
{
let entry = entry
.map_err(|error| format!("读取试玩包文件失败:{}: {error}", export_dir.display()))?;
let file_type = entry.file_type().map_err(|error| {
format!(
"读取试玩包文件类型失败:{}: {error}",
entry.path().display()
)
})?;
if file_type.is_symlink() || !file_type.is_file() {
continue;
}
let file_name = match entry.file_name().to_str() {
Some(value) => value.to_string(),
None => continue,
};
if !file_name.starts_with("playtest-package-") || !file_name.ends_with(".zip") {
continue;
}
let package_relative_path =
normalize_export_package_entry_path(&format!("exports/{file_name}"))?;
let metadata = entry.metadata().map_err(|error| {
format!("读取试玩包元数据失败:{}: {error}", entry.path().display())
})?;
let modified_at = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0);
packages.push(LocalProjectExportPackageSummary {
package_path: entry.path().to_string_lossy().into_owned(),
package_relative_path,
total_bytes: metadata.len(),
modified_at,
});
}
packages.sort_by(|left, right| {
(right.modified_at, &right.package_relative_path)
.cmp(&(left.modified_at, &left.package_relative_path))
});
Ok(LocalProjectExportPackagesResult {
project_path: root.to_string_lossy().into_owned(),
packages,
})
}
/// 发布导出前确保 `exports/README.md` 存在。
///
/// 缺失时按项目 manifest 生成最小发布说明;已有文件原样保留,避免覆盖作者或 Agent 的正式文案。
/// README 本身仍沿用试玩包的普通文件 / 符号链接安全口径。
fn ensure_project_export_readme(root: &Path) -> Result<PathBuf, String> {
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
match fs::symlink_metadata(&readme_path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err("试玩包不能包含符号链接:exports/README.md".to_string());
}
if !metadata.is_file() {
return Err("exports/README.md 必须是普通文件".to_string());
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let manifest = read_manifest_for_project(root)?;
let content = render_project_export_readme(&manifest);
write_game_creator_private_file(&readme_path, content.as_bytes(), "试玩包发布说明")?;
}
Err(error) => {
return Err(format!(
"读取 exports/README.md 元数据失败:{}: {error}",
readme_path.display()
));
}
}
Ok(readme_path)
}
fn render_project_export_readme(manifest: &GameCreationAppManifest) -> String {
let title = manifest.name.trim();
let title = if title.is_empty() {
"试玩游戏"
} else {
title
};
let summary = manifest
.goal
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("这是一个可直接在浏览器中试玩的游戏原型。");
format!(
"# {title} 试玩说明\n\n## 简介\n\n{summary}\n\n## 试玩方式\n\n- 解压试玩包后打开根目录的 `index.html`。\n- 使用游戏内提示开始试玩。\n\n## 反馈\n\n请记录问题发生步骤、预期结果和实际结果,便于快速定位。\n"
)
}
pub(crate) fn collect_project_export_package_files(
root: &Path,
) -> Result<Vec<(String, PathBuf, u64)>, String> {
let mut files = Vec::new();
let game_root = crate::preview::project_game_root(root);
let built = game_root == root.join("dist") || game_root == root.join("game/dist");
if built {
let relative = relative_project_path(root, &game_root)?;
collect_project_export_package_dir_files(root, &relative, &mut files)?;
for (name, _, _) in &mut files {
*name = format!(
"game/{}",
name.strip_prefix(&format!("{relative}/"))
.ok_or("构建产物路径非法")?
);
}
// AGC 上传的素材登记在项目根 `assets/`,部分 Phaser 工程直接以
// `assets/uploads/...` 作为运行 URL,没有通过 Vite public 目录复制到 dist。
// 构建产物优先;只有 dist 没有同路径时才补入根目录素材,避免重复 ZIP 条目。
let assets_root = resolve_local_project_path(root, "assets")?;
if assets_root.exists() {
let mut root_assets = Vec::new();
collect_project_export_package_dir_files(root, "assets", &mut root_assets)?;
for entry in root_assets {
let dist_path = format!("game/{}", entry.0);
if !files.iter().any(|(path, _, _)| path == &dist_path) {
files.push(entry);
}
}
}
} else {
collect_project_export_package_dir_files(root, "game", &mut files)?;
}
if !built && resolve_local_project_path(root, "assets")?.exists() {
collect_project_export_package_dir_files(root, "assets", &mut files)?;
}
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?;
if !readme_metadata.is_file() {
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
}
let readme_size = readme_metadata.len();
files.push(("exports/README.md".to_string(), readme_path, readme_size));
files.sort_by(|left, right| left.0.cmp(&right.0));
Ok(files)
}
pub(crate) fn collect_project_export_package_dir_files(
root: &Path,
relative_dir: &str,
files: &mut Vec<(String, PathBuf, u64)>,
) -> Result<(), String> {
let dir = resolve_local_project_path(root, relative_dir)?;
if !dir.exists() {
return Ok(());
}
let dir_metadata = checked_export_package_metadata(&dir, relative_dir)?;
if !dir_metadata.is_dir() {
return Err(format!("{relative_dir} 必须是目录"));
}
let mut dirs = vec![dir];
while let Some(current_dir) = dirs.pop() {
for entry in fs::read_dir(&current_dir)
.map_err(|error| format!("读取导出目录失败:{}: {error}", current_dir.display()))?
{
let entry = entry
.map_err(|error| format!("读取导出文件失败:{}: {error}", current_dir.display()))?;
let file_type = entry.file_type().map_err(|error| {
format!("读取导出文件类型失败:{}: {error}", entry.path().display())
})?;
let relative_path =
normalize_export_package_entry_path(&relative_project_path(root, &entry.path())?)?;
if file_type.is_symlink() {
return Err(format!("试玩包不能包含符号链接:{relative_path}"));
}
if file_type.is_dir() {
dirs.push(entry.path());
} else if file_type.is_file() {
let size = entry
.metadata()
.map_err(|error| {
format!(
"读取导出文件元数据失败:{}: {error}",
entry.path().display()
)
})?
.len();
files.push((relative_path, entry.path(), size));
}
}
}
Ok(())
}
pub(crate) fn checked_export_package_metadata(
path: &Path,
relative_path: &str,
) -> Result<fs::Metadata, String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("读取导出文件类型失败:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() {
return Err(format!(
"试玩包不能包含符号链接:{}",
normalize_export_package_entry_path(relative_path)?
));
}
Ok(metadata)
}
pub(crate) fn normalize_export_package_entry_path(relative_path: &str) -> Result<String, String> {
if relative_path.chars().any(char::is_control) {
return Err("试玩包条目路径不能包含控制字符".to_string());
}
normalize_relative_path(relative_path)
}
#[cfg(test)]
mod npm_export_tests {
use super::*;
#[test]
fn declared_version_major_reads_semver_prefixes_and_ranges() {
assert_eq!(declared_version_major("4.2.1"), Some(4));
assert_eq!(declared_version_major("^4.2.1"), Some(4));
assert_eq!(declared_version_major("~4.2.1"), Some(4));
assert_eq!(declared_version_major(">=4 <5"), Some(4));
assert_eq!(declared_version_major("3.90.0"), Some(3));
assert_eq!(declared_version_major("latest"), None);
assert_eq!(declared_version_major("*"), None);
assert_eq!(declared_version_major("workspace:*"), None);
}
#[test]
fn declared_dependency_spec_accepts_string_and_object_forms() {
let package = serde_json::json!({
"dependencies": { "phaser": "^4.2.1" },
"devDependencies": { "vite": { "version": "^6.2.0" } }
});
assert_eq!(
declared_dependency_spec(&package, "phaser").as_deref(),
Some("^4.2.1")
);
assert_eq!(
declared_dependency_spec(&package, "vite").as_deref(),
Some("^6.2.0")
);
assert_eq!(declared_dependency_spec(&package, "three"), None);
}
#[test]
fn publish_precheck_rejects_packages_over_the_platform_limit() {
let limit = shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES;
// 边界:正好等于上限放行,差一个字节也放行(服务端同样按 `>` 判定)。
ensure_within_platform_package_limit(limit).unwrap();
ensure_within_platform_package_limit(limit - 1).unwrap();
// 超限:错误文案必须给出实际体积与上限,作者才能据此精简。
let error = ensure_within_platform_package_limit(limit + 1).expect_err("over limit");
assert!(error.contains("超过平台上限"), "{error}");
assert!(error.contains("200 MiB"), "{error}");
assert!(error.contains("MiB"), "{error}");
// 本地导出上限更宽松(512 MiB):超平台的包必须在读包阶段就被拦下,
// 不能等到上传后由服务端 413。
assert!(limit < MAX_PROJECT_EXPORT_PACKAGE_BYTES);
}
#[test]
fn npm_package_contains_only_dist_and_publish_readme() {
let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp");
fs::create_dir_all(&base).unwrap();
let root = tempfile::tempdir_in(base).unwrap();
for directory in ["dist/assets", "assets", "exports", "node_modules", "game"] {
fs::create_dir_all(root.path().join(directory)).unwrap();
}
for file in [
"package.json",
"dist/index.html",
"dist/assets/main.js",
"assets/hero.png",
"exports/README.md",
"node_modules/private.js",
"game/source.js",
] {
fs::write(root.path().join(file), "test").unwrap();
}
let files = collect_project_export_package_files(root.path()).unwrap();
let names = files
.iter()
.map(|(name, _, _)| name.as_str())
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"exports/README.md",
"game/assets/main.js",
"game/index.html"
]
);
}
}