Files
Genarrative/apps/ai-game-creator-shell/src-tauri/build.rs
T
kdletters 90a414ebf3
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m54s
Project CI / Backend tests (push) Successful in 4m3s
Project CI / Frontend tests (push) Successful in 1m59s
Project CI / Native shell tests (push) Successful in 6m11s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
清理随包 Codex 资源里的旧布局残留
- src-tauri/build.rs:stage_codex_target 拷贝前调用新增的 prune_stale_codex_components,删掉不在本轮布局也不在 manifest.json/NOTICE.md 白名单里的文件并收掉空目录
- 复验:重建后 resources/codex/win-x64 根目录那份 0.147.0 codex.exe 与临时备份被清除,六个声明组件、manifest.json 与 NOTICE.md 保留;同一二进制下 loopback 夹具仍跑到 requests=3、markers.ready=true
- 文档:DirectProject 里程碑本轮核对细化第二层问题(不是 codex 版本漂移、不是 stdin;指向 Drop for ExecutionBinding 的收尾竞态,需插桩),并说明两条运行时验收本质要真实客户端窗口;pitfalls 记录该环境陷阱与修复
2026-09-28 22:55:41 +08:00

603 lines
24 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.
#[path = "build_support/codex_bundle.rs"]
mod codex_bundle;
#[path = "build_support/codex_package_metadata.rs"]
mod codex_package_metadata;
#[path = "build_support/frontend_dist_guard.rs"]
mod frontend_dist_guard;
#[path = "build_support/godot_bundle.rs"]
mod godot_bundle;
#[path = "build_support/runtime_prompt_bundle.rs"]
mod runtime_prompt_bundle;
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::env;
use std::fs;
use std::path::PathBuf;
use std::io::{BufReader, Read};
fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> {
let file = fs::File::open(path)?;
let mut reader = BufReader::new(file);
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = reader.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET");
println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
if target.contains("apple-darwin") {
// Tauri 的 universal 两次 Cargo 编译共用 resource staging,
// 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。
let staging = manifest_dir.join("resources/codex/mac-native");
if staging.exists() {
fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败");
}
for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] {
stage_codex_target(manifest_dir, target);
}
} else {
stage_codex_target(manifest_dir, &target);
}
}
fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
let Some(layout) = codex_bundle::for_target(target) else {
assert!(
!target.contains("windows") && !target.contains("apple-darwin"),
"不支持的 Codex 随包目标:{target}"
);
return;
};
{
let app_root = manifest_dir
.parent()
.expect("AI 游戏创作 Tauri manifest 必须位于应用目录下");
let repo_root = app_root
.parent()
.and_then(|apps_dir| apps_dir.parent())
.expect("AI 游戏创作应用必须位于仓库 apps 目录下");
let package = format!("codex-{}", layout.platform);
let source_candidates = [app_root, repo_root]
.into_iter()
.flat_map(|root| {
[
root.join(format!("node_modules/@openai/{package}/vendor/{target}")),
root.join(format!(
"node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}"
)),
]
})
.collect::<Vec<_>>();
let source = source_candidates
.iter()
.find(|path| {
layout
.files
.iter()
.all(|relative| path.join(relative).is_file())
})
.cloned()
.unwrap_or_else(|| {
panic!(
"内置 Codex CLI 缺失;请先在仓库根目录执行 npm ci(已检查:{})",
source_candidates
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(";")
)
});
let metadata: serde_json::Value = serde_json::from_slice(
&fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
)
.expect("Codex 原生包元数据无效");
codex_package_metadata::validate_package_metadata(&metadata, target, layout)
.unwrap_or_else(|error| panic!("{error}"));
let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
let notice = target_dir.join("NOTICE.md");
if target.contains("apple-darwin") {
let source_notice =
manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md");
stage_plugin_file(&source_notice, &notice);
println!("cargo:rerun-if-changed={}", source_notice.display());
}
if !notice.is_file() {
panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display());
}
fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败");
// 这份目录是随包资源(Windows:`resources/codex/win-x64/**` → `coding-agent/win-x64/**`),
// 只能包含本轮布局声明的组件。上一版布局留下的旧二进制(例如包根目录那份 0.147.0
// `codex.exe`)会长期留在原地:既误导本地核对与夹具,也让「随包内容」与清单不一致。
prune_stale_codex_components(&target_dir, layout.files);
let mut file_hashes = serde_json::Map::new();
for relative in layout.files {
let source_path = source.join(relative);
let target_path = target_dir.join(relative);
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败");
}
let source_sha256 = sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败");
let target_matches_source = target_path.is_file()
&& sha256_file(&target_path)
.map(|target_sha256| target_sha256 == source_sha256)
.unwrap_or(false);
let source_permissions = fs::metadata(&source_path)
.expect("读取组件权限失败")
.permissions();
if !target_matches_source {
fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败");
fs::set_permissions(&target_path, source_permissions.clone())
.expect("保留内置 Codex CLI 组件权限失败");
} else if fs::metadata(&target_path)
.expect("读取内置 Codex CLI 资源失败")
.permissions()
!= source_permissions
{
// 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。
// 权限已一致时不再写元数据:Windows 上这次写入会更新 change time,
// 让 tauri dev 的文件监听把每次构建都当成 staging 变更而无限重建。
fs::set_permissions(&target_path, source_permissions)
.expect("保留内置 Codex CLI 组件权限失败");
}
file_hashes.insert(
relative.to_string(),
serde_json::Value::String(source_sha256),
);
}
let manifest = serde_json::json!({
"schemaVersion": codex_bundle::SCHEMA,
"platform": layout.platform,
"version": codex_bundle::CLI_VERSION,
"files": file_hashes,
});
let manifest_path = target_dir.join("manifest.json");
let manifest_payload = format!(
"{}\n",
serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败")
);
if fs::read_to_string(&manifest_path)
.map(|current| current != manifest_payload)
.unwrap_or(true)
{
fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败");
}
for relative in layout.files {
println!("cargo:rerun-if-changed={}", source.join(relative).display());
}
println!("cargo:rerun-if-changed={}", notice.display());
}
}
/// 删除 `target_dir` 下不在本轮布局内的残留文件;空目录一并收掉。
fn prune_stale_codex_components(target_dir: &std::path::Path, files: &[&str]) {
const ALWAYS_KEEP: &[&str] = &["manifest.json", "NOTICE.md"];
fn walk(root: &std::path::Path, directory: &std::path::Path, files: &[&str], keep: &[&str]) {
let Ok(entries) = fs::read_dir(directory) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(kind) = entry.file_type() else {
continue;
};
if kind.is_dir() {
walk(root, &path, files, keep);
if fs::read_dir(&path)
.map(|mut remaining| remaining.next().is_none())
.unwrap_or(false)
{
let _ = fs::remove_dir(&path);
}
continue;
}
let Ok(relative) = path.strip_prefix(root) else {
continue;
};
let relative = relative.to_string_lossy().replace('\\', "/");
if files.contains(&relative.as_str()) || keep.contains(&relative.as_str()) {
continue;
}
eprintln!("cargo:warning=清理内置 Codex 组件残留:{relative}");
let _ = fs::remove_file(&path);
}
}
walk(target_dir, target_dir, files, ALWAYS_KEEP);
}
fn seed_task_group_id(
group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup,
) -> &'static str {
use shared_contracts::game_creation_app::GameCreationAppAgentGroup;
match group {
GameCreationAppAgentGroup::Design => "design",
GameCreationAppAgentGroup::Art => "art",
GameCreationAppAgentGroup::Code => "code",
GameCreationAppAgentGroup::Balance => "balance",
GameCreationAppAgentGroup::Audio => "audio",
GameCreationAppAgentGroup::Publishing => "publishing",
}
}
fn validate_seed_task_catalog(compiled: &runtime_prompt_bundle::CompiledPromptBundle) {
let actual = compiled
.specialist_nodes
.iter()
.map(|node| {
(
node.task_id.clone(),
node.group_id.clone(),
node.role.clone(),
)
})
.collect::<BTreeSet<_>>();
let expected = shared_contracts::game_creation_app::new_game_creation_app_seed_tasks()
.into_iter()
.map(|task| {
(
task.id,
seed_task_group_id(&task.group).to_string(),
task.role,
)
})
.collect::<BTreeSet<_>>();
if actual != expected {
panic!(
"Prompt Bundle agentCatalog 与正式 seed DAG 的 taskId/group/role 不一致\nactual={actual:#?}\nexpected={expected:#?}"
);
}
}
fn main() {
let manifest_dir = PathBuf::from(
env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"),
);
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
stage_bundled_codex_cli(&manifest_dir);
prepare_unity_editor_helper(&manifest_dir);
prepare_godot_editor_extension(&manifest_dir);
stage_plugin_workspace(&manifest_dir);
stage_cocos_editor_payload(&manifest_dir);
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
.unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}"));
validate_seed_task_catalog(&compiled);
for dependency in &compiled.dependencies {
println!("cargo:rerun-if-changed={}", dependency.display());
}
let output_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR must be available"))
.join("agent_runtime_prompt_bundle.rs");
fs::write(&output_path, compiled.rust_source)
.unwrap_or_else(|error| panic!("写入 Prompt Bundle 生成代码失败:{error}"));
if !tauri_build::is_dev() {
let frontend_dist = manifest_dir
.parent()
.expect("AI 游戏创作 Tauri manifest 必须位于应用目录下")
.join("dist");
println!("cargo:rerun-if-changed={}", frontend_dist.display());
frontend_dist_guard::validate_frontend_dist(&frontend_dist)
.unwrap_or_else(|error| panic!("生产 frontendDist 检查失败:{error}"));
}
tauri_build::build()
}
#[cfg(windows)]
fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) {
if std::env::var_os("CARGO_FEATURE_COCOS_EDITOR_INJECTION").is_none() {
return;
}
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR"));
let profile_dir = out_dir
.ancestors()
.find(|path| path.file_name().is_some_and(|name| name == "build"))
.and_then(|build_dir| build_dir.parent())
.expect("AGC Cargo profile directory not found");
let candidates = [
profile_dir.join("deps/cocos_editor_bridge.dll"),
profile_dir.join("cocos_editor_bridge.dll"),
];
let source = candidates
.iter()
.find(|path| path.is_file())
.unwrap_or_else(|| {
panic!(
"Cocos bridge native payload 未构建:{}",
candidates
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(";")
)
});
for destination in [
// 插件工作区里的 payload 是开发态与打包态的唯一真源。
manifest_dir
.join("../../../plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"),
// 随包资源目录与 tauri.windows.conf.json 的 `resources/plugins` 映射保持一致。
manifest_dir
.join("resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"),
] {
std::fs::create_dir_all(destination.parent().expect("payload resource parent"))
.expect("创建 Cocos bridge payload 目录失败");
std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败");
}
println!("cargo:rerun-if-changed={}", source.display());
}
#[cfg(not(windows))]
fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {}
/// Unity helper 是插件的随包运行文件。内容指纹避免每次 Cargo 检查都重新发布 .NET。
fn prepare_unity_editor_helper(manifest_dir: &std::path::Path) {
println!("cargo:rerun-if-env-changed=CARGO_FEATURE_UNITY_EDITOR_EXECUTE");
let target = env::var("TARGET").expect("Cargo TARGET");
if env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none()
|| target != "x86_64-pc-windows-msvc"
{
return;
}
let root = manifest_dir.join("../../../plugins/agc-unity-editor/dotnet");
let mut sources = Vec::new();
collect_unity_helper_sources(&root, &mut sources);
sources.sort();
let mut fingerprint = Sha256::new();
for source in &sources {
println!("cargo:rerun-if-changed={}", source.display());
fingerprint.update(
source
.strip_prefix(&root)
.expect("helper source")
.to_string_lossy()
.as_bytes(),
);
fingerprint.update([0]);
fingerprint.update(fs::read(source).expect("读取 Unity helper 源文件失败"));
}
let fingerprint = format!("{:x}", fingerprint.finalize());
let publish = root.join("publish/win-x64");
let executable = publish.join("Agc.Unity.Attach.exe");
let stamp = publish.join(".agc-source.sha256");
println!("cargo:rerun-if-changed={}", executable.display());
if unity_helper_publish_complete(&publish)
&& fs::read_to_string(&stamp).ok().as_deref() == Some(&fingerprint)
{
return;
}
assert!(
cfg!(windows),
"构建 Unity 插件 helper 需要 Windows .NET 10 与 x64 C++ 工具链"
);
let status = std::process::Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(root.join("build.ps1"))
.current_dir(&root)
.status()
.expect("无法启动 Unity helper 构建脚本");
assert!(
status.success() && unity_helper_publish_complete(&publish),
"Unity helper 构建失败或缺少运行文件/许可"
);
fs::write(stamp, fingerprint).expect("写入 Unity helper 构建指纹失败");
}
fn unity_helper_publish_complete(publish: &std::path::Path) -> bool {
[
"Agc.Unity.Attach.exe",
"NOTICE",
"THIRD-PARTY-NOTICES.txt",
"licenses/DotCraft-Apache-2.0.txt",
"licenses/Roslyn-MIT.txt",
"licenses/upstream.json",
"licenses/dotnet-LICENSE.TXT",
"licenses/dotnet-THIRD-PARTY-NOTICES.TXT",
"licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf",
"licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf",
]
.iter()
.all(|name| {
fs::symlink_metadata(publish.join(name)).is_ok_and(|metadata| {
metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0
})
})
}
fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec<PathBuf>) {
for entry in fs::read_dir(root)
.expect("Unity helper 源码目录缺失")
.flatten()
{
let kind = entry.file_type().expect("读取 Unity helper 源文件类型失败");
assert!(!kind.is_symlink(), "Unity helper 源码不允许符号链接");
let name = entry.file_name();
if kind.is_dir() {
if !matches!(
name.to_str(),
Some("bin" | "obj" | "publish" | "native-build")
) {
collect_unity_helper_sources(&entry.path(), sources);
}
} else if kind.is_file() {
sources.push(entry.path());
}
}
}
fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) {
println!("cargo:rerun-if-env-changed=CARGO_FEATURE_GODOT_EDITOR_EXECUTE");
if env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_none()
|| env::var("TARGET").expect("Cargo TARGET") != "x86_64-pc-windows-msvc"
{
return;
}
let root = manifest_dir.join("../../../plugins/agc-godot-editor/native/gdextension");
for source in godot_bundle::source_files(&root).unwrap_or_else(|error| panic!("{error}")) {
println!("cargo:rerun-if-changed={}", source.display());
}
assert!(
cfg!(windows),
"构建 Godot 原生扩展需要 Windows x64 C 编译器"
);
let status = std::process::Command::new("powershell.exe")
// Cargo 可能从 PowerShell 7 启动,Windows PowerShell 应使用自身模块目录。
.env_remove("PSModulePath")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(root.join("build.ps1"))
.current_dir(&root)
.status()
.expect("无法启动 Godot 原生扩展构建脚本");
assert!(status.success(), "Godot 原生扩展构建失败");
godot_bundle::validate(&root).unwrap_or_else(|error| panic!("{error}"));
}
/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。
///
/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
/// Cargo target 目录或 node_modules。
fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET");
if !target.contains("windows") && !target.contains("apple-darwin") {
return;
}
let repo_root = manifest_dir
.parent()
.and_then(|app_root| app_root.parent())
.and_then(|apps_dir| apps_dir.parent())
.expect("AGC 应用必须位于仓库 apps 目录下")
.to_path_buf();
let workspace = repo_root.join("plugins");
let destination_root = manifest_dir.join("resources/plugins");
// staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。
if destination_root.exists() {
std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败");
}
std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败");
let entries = match std::fs::read_dir(&workspace) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let plugin_root = entry.path();
assert!(
!entry
.file_type()
.expect("读取插件目录类型失败")
.is_symlink(),
"插件工作区不允许符号链接"
);
if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() {
continue;
}
let name = entry.file_name();
let destination = destination_root.join(&name);
copy_plugin_file(
&plugin_root.join("plugin.json"),
&destination.join("plugin.json"),
);
for relative in [
std::path::PathBuf::from("src"),
std::path::PathBuf::from("panels"),
std::path::PathBuf::from("skills"),
std::path::PathBuf::from("native/payload"),
std::path::PathBuf::from("dotnet/publish/win-x64"),
] {
if (relative == std::path::Path::new("native/payload") && !target.contains("windows"))
|| (relative == std::path::Path::new("dotnet/publish/win-x64")
&& (target != "x86_64-pc-windows-msvc"
|| env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none()))
{
continue;
}
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
}
if name == "agc-godot-editor" {
godot_bundle::stage(
&plugin_root.join("native/gdextension"),
&destination.join("native/gdextension"),
&target,
env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_some(),
)
.unwrap_or_else(|error| panic!("{error}"));
}
println!("cargo:rerun-if-changed={}", plugin_root.display());
}
}
fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
let bytes = std::fs::read(source)
.unwrap_or_else(|error| panic!("读取随包资源失败 {}:{error}", source.display()));
if std::fs::read(destination).is_ok_and(|existing| existing == bytes) {
return;
}
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent).expect("创建插件资源目录失败");
}
std::fs::write(destination, bytes).expect("复制插件资源失败");
}
fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
let entries = match std::fs::read_dir(source) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let target = destination.join(entry.file_name());
let path = entry.path();
assert!(
!entry
.file_type()
.expect("读取插件文件类型失败")
.is_symlink(),
"插件资源不允许符号链接"
);
if path.is_dir() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") {
continue;
}
std::fs::create_dir_all(&target).expect("创建插件资源目录失败");
copy_plugin_tree(&path, &target);
} else {
// 测试文件不随包分发。
let name = entry.file_name();
let name = name.to_string_lossy();
if name.contains(".test.") {
continue;
}
if name.starts_with('.') {
continue;
}
stage_plugin_file(&path, &target);
}
}
}
fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
if !source.is_file() {
return;
}
std::fs::create_dir_all(destination.parent().expect("插件资源父目录"))
.expect("创建插件资源目录失败");
std::fs::copy(source, destination).expect("复制插件资源失败");
}