diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index cd69f8180..52a397bce 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -15,35 +15,20 @@ 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())) -} +use codex_bundle::package_layout; 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") { + let resource_directory = manifest_dir.join(package_layout::codex().resource_directory); + if let Some(group) = package_layout::codex_universal_group(&target) { // Tauri 的 universal 两次 Cargo 编译共用 resource staging, // 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。 - let staging = manifest_dir.join("resources/codex/mac-native"); + let staging = resource_directory.join(group.directory); 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); + for member in group.targets { + stage_codex_target(manifest_dir, member); } } else { stage_codex_target(manifest_dir, &target); @@ -66,18 +51,9 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { .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::>(); + let source_candidates = + package_layout::codex_source_candidates(app_root, repo_root, target) + .unwrap_or_else(|error| panic!("{error}")); let source = source_candidates .iter() .find(|path| { @@ -98,18 +74,23 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { ) }); let metadata: serde_json::Value = serde_json::from_slice( - &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"), + &fs::read(source.join(package_layout::codex().package_metadata_file_name)) + .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, ¬ice); - println!("cargo:rerun-if-changed={}", source_notice.display()); + let target_dir = manifest_dir + .join(package_layout::codex().resource_directory) + .join(layout.directory); + let notice = target_dir.join(package_layout::codex().notice_file_name); + if let Some(notice_source) = package_layout::codex_notice_source(target) { + if !notice_source.preserve { + // 受版本控制的第三方声明由此处复制;`preserve` 的声明文件本身已在随包目录内。 + let source_notice = manifest_dir.join(notice_source.source); + stage_plugin_file(&source_notice, ¬ice); + println!("cargo:rerun-if-changed={}", source_notice.display()); + } } if !notice.is_file() { panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display()); @@ -122,9 +103,10 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { 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 source_sha256 = + package_layout::sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败"); let target_matches_source = target_path.is_file() - && sha256_file(&target_path) + && package_layout::sha256_file(&target_path) .map(|target_sha256| target_sha256 == source_sha256) .unwrap_or(false); let source_permissions = fs::metadata(&source_path) @@ -156,7 +138,7 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { "version": codex_bundle::CLI_VERSION, "files": file_hashes, }); - let manifest_path = target_dir.join("manifest.json"); + let manifest_path = target_dir.join(package_layout::codex().manifest_file_name); let manifest_payload = format!( "{}\n", serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败") @@ -217,16 +199,113 @@ fn validate_seed_task_catalog(compiled: &runtime_prompt_bundle::CompiledPromptBu } } +/// 只读校验:确认已经落盘的随包产物与声明一致。本函数不写任何文件。 +fn validate_staged_resources(manifest_dir: &std::path::Path) { + let target = env::var("TARGET").expect("Cargo TARGET"); + for staged_target in package_layout::staged_targets(&target) { + let Some(layout) = codex_bundle::for_target(staged_target) else { + continue; + }; + let target_dir = manifest_dir + .join(package_layout::codex().resource_directory) + .join(layout.directory); + package_layout::validate_staged_codex_bundle(&target_dir, staged_target).unwrap_or_else( + |error| panic!("内置 Codex CLI 随包资源校验失败({staged_target}):{error}"), + ); + } + validate_staged_plugin_workspace(manifest_dir, &target); +} + +/// 只读校验插件随包工作区:插件清单与声明的必需子目录齐备、整树无符号链接、无越界路径。 +/// +/// 插件产物的逐文件摘要校验在准备步骤接管写入后启用——在那之前构建脚本仍会整体重建 +/// `resources/plugins`,任何写在树内的准备步骤清单都会被清掉。 +fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) { + if !package_layout::plugin_staging_applies(target) { + return; + } + let declared = package_layout::plugins(); + let destination_root = manifest_dir.join(declared.destination_directory); + if !destination_root.is_dir() { + panic!("插件随包资源目录缺失:{}", destination_root.display()); + } + let repo_root = manifest_dir + .parent() + .and_then(|app_root| app_root.parent()) + .and_then(|apps_dir| apps_dir.parent()) + .expect("AGC 应用必须位于仓库 apps 目录下"); + if let Ok(entries) = std::fs::read_dir(repo_root.join(declared.source_directory)) { + for entry in entries.flatten() { + let plugin_root = entry.path(); + if !plugin_root.is_dir() || !plugin_root.join(declared.manifest_file_name).is_file() { + continue; + } + let name = entry.file_name(); + let staged = destination_root.join(&name); + if !staged.join(declared.manifest_file_name).is_file() { + panic!( + "随包插件缺少清单:{}", + staged.join(declared.manifest_file_name).display() + ); + } + for subdirectory in declared.subdirectories { + if !package_layout::subdirectory_enabled( + subdirectory, + target, + package_layout::cargo_feature_enabled, + ) { + continue; + } + let relative = package_layout::declared_relative_path(subdirectory.path); + if plugin_root.join(&relative).is_dir() && !staged.join(&relative).is_dir() { + panic!( + "随包插件缺少必需目录:{}(插件 {})", + staged.join(&relative).display(), + name.to_string_lossy() + ); + } + } + for staging in declared.library_staging { + if name != staging.plugin + || !package_layout::library_staging_enabled( + staging, + target, + package_layout::cargo_feature_enabled, + ) + { + continue; + } + let relative = package_layout::declared_relative_path(staging.source_subdirectory); + if !staged.join(&relative).is_dir() { + panic!("随包库目录缺失:{}", staged.join(&relative).display()); + } + } + } + } + package_layout::collect_tree_files(&destination_root) + .unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}")); +} + 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); + // 运行期定位随包目录依赖该编译期常量,与是否跳过 staging 无关(见技术方案 §4.4)。 + println!( + "cargo:rustc-env=AGC_BUILD_TARGET={}", + env::var("TARGET").expect("Cargo TARGET") + ); + // AGC_SKIP_RESOURCE_STAGING=1 只做只读校验(要求随包资源已由准备步骤生成), + // 用于在既有产物上单独验证校验路径。 + if env::var_os("AGC_SKIP_RESOURCE_STAGING").is_none() { + 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); + } + validate_staged_resources(&manifest_dir); let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) .unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}")); validate_seed_task_catalog(&compiled); @@ -435,17 +514,18 @@ fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) { /// 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") { + if !package_layout::plugin_staging_applies(&target) { return; } + let declared = package_layout::plugins(); 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"); + let workspace = repo_root.join(declared.source_directory); + let destination_root = manifest_dir.join(declared.destination_directory); // staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。 if destination_root.exists() { std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败"); @@ -464,39 +544,47 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { .is_symlink(), "插件工作区不允许符号链接" ); - if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() { + if !plugin_root.is_dir() || !plugin_root.join(declared.manifest_file_name).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"), + &plugin_root.join(declared.manifest_file_name), + &destination.join(declared.manifest_file_name), ); - 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())) + for subdirectory in declared.subdirectories { + if !package_layout::subdirectory_enabled( + subdirectory, + &target, + package_layout::cargo_feature_enabled, + ) { + continue; + } + let relative = package_layout::declared_relative_path(subdirectory.path); + copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); + } + for staging in declared.library_staging { + if name != staging.plugin + || !package_layout::library_staging_enabled( + staging, + &target, + package_layout::cargo_feature_enabled, + ) { 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}")); + let relative = package_layout::declared_relative_path(staging.source_subdirectory); + match staging.layout { + "godot-bundle" => godot_bundle::stage( + &plugin_root.join(&relative), + &destination.join(&relative), + &target, + true, + ) + .unwrap_or_else(|error| panic!("{error}")), + other => panic!("未实现的随包库 staging 布局:{other}"), + } } println!("cargo:rerun-if-changed={}", plugin_root.display()); } @@ -531,20 +619,15 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { ); 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") { + if package_layout::skip_directory(&name.to_string_lossy()) { 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('.') { + if package_layout::skip_file_name(&name.to_string_lossy()) { continue; } stage_plugin_file(&path, &target);