#[path = "build_support/codex_bundle.rs"] mod codex_bundle; #[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 { 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 = layout.npm_package; 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::>(); 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::>() .join(";") ) }); let metadata: serde_json::Value = serde_json::from_slice( &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"), ) .expect("Codex 原生包元数据无效"); codex_bundle::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, ¬ice); 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 资源目录失败"); 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()); } } 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::>(); 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::>(); 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::>() .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) { 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("复制插件资源失败"); }