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 并发校验与旧客户端自动递增兼容。
969 lines
40 KiB
Rust
969 lines
40 KiB
Rust
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(¤t_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"
|
||
]
|
||
);
|
||
}
|
||
}
|