#[path = "build_support/codex_bundle.rs"] mod codex_bundle; #[path = "build_support/frontend_dist_guard.rs"] mod frontend_dist_guard; #[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}"); 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); if !target_matches_source { fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败"); } // 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。 fs::set_permissions( &target_path, fs::metadata(&source_path) .expect("读取组件权限失败") .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); 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) {} /// 把 `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("native/payload"), ] { if relative == std::path::Path::new("native/payload") && !target.contains("windows") { continue; } copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); } 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("复制插件资源失败"); }