Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/export.rs
T
kdletters 1498247e9f
Project CI / Repository checks (push) Successful in 2m54s
Project CI / Frontend tests (push) Successful in 3m4s
Project CI / Backend tests (push) Successful in 8m10s
Project CI / Native shell tests (push) Failing after 16m56s
AGC产物迁移npm与Phaser4
新增 npm + Vite + Phaser 4.2.1 游戏工程脚手架

允许 DirectProject 在项目边界内安装依赖并执行构建

让预览、静态检查、投影和试玩导出统一使用 dist 产物

补充 Phaser Skill、技术方案、项目记忆和定向测试
2026-09-08 15:11:36 +08:00

349 lines
13 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::*;
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_package_dir(root, "exports")?;
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
if !readme_path.is_file() {
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
}
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 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,
})
}
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,
})
}
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("构建产物路径非法")?
);
}
} 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 ensure_project_export_package_dir(
root: &Path,
relative_dir: &str,
) -> Result<(), String> {
let dir = resolve_local_project_path(root, relative_dir)?;
let metadata = checked_export_package_metadata(&dir, relative_dir)?;
if !metadata.is_dir() {
return Err(format!("{relative_dir} 必须是目录"));
}
Ok(())
}
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 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"
]
);
}
}