AGC 编辑器分支产物归位:构建脚本彻底退出写入
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m29s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m5s
Project CI / Backend tests (pull_request) Successful in 3m49s
Project CI / Frontend tests (pull_request) Successful in 1m46s
Project CI / Native shell tests (pull_request) Successful in 6m1s
Project CI / Repository checks (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m11s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m27s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m53s

- 声明扩展:subdirectories 新增 origin=prepared;libraryStaging 增加 prepare/files;新增 nativePayloads 与 plugins.prepareSteps(程序类型、工作目录、指纹、必需产物)
- Godot 随包文件清单改由声明提供,godot_bundle::BUNDLE_FILES 从生成的编译期常量取值,不再各写一份
- 准备步骤按声明运行 powershell.exe -File build.ps1(Unity/Godot,Godot 移除 PSModulePath)与 cargo build -p cocos-editor-bridge --target … --features windows-injection,内容指纹命中且必需产物齐全时零写入,命令执行器可注入以便在 macOS 上覆盖调度与失败关闭
- build.rs 删除 prepare_unity_editor_helper、prepare_godot_editor_extension、stage_cocos_editor_payload、stage_build_generated_plugin_payloads 及辅助函数(415 → 193 行),只留只读校验,并新增已准备产物存在性与 Godot 随包库深度校验;AGC_SKIP_RESOURCE_STAGING 开关随写入分支删除
- 删除两份只含 staging 条目的 .taurignore;发布入口传 profile=release 以定位 Cocos 产物
- 文档:技术方案 §4.9/§8、M3 里程碑(含 Windows 验收清单)、运维文档、决策日志与排障经验同步
- 验证:准备步骤 13 条用例通过(三类准备步骤调度、指纹跳过、缺产物失败关闭、幂等);npm run agc:bundled-resources:check 通过;cargo check --no-default-features 通过且第二次 0.62 秒 fresh
- 未验证:Windows 真机(powershell/cargo 路径、产物归位、包内容一致性与客户端加载),按 M3 里程碑验收清单在 Windows 上确认
This commit is contained in:
2026-09-27 23:33:48 +08:00
parent 0b19df467e
commit 44c5a69185
17 changed files with 665 additions and 337 deletions
-4
View File
@@ -1,4 +0,0 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
@@ -440,6 +440,7 @@ export function stageBundledResources(
const summaries = prepare({
target,
features: new Set(defaultEditorFeatures(target)),
profile: 'release',
log: (line) => console.log(`[ai-game-creator-shell] ${line}`),
});
for (const summary of summaries) {
@@ -81,11 +81,11 @@ function expectInteger(value, at) {
return value;
}
// 随包子目录来源:`source` 由声明与仓库源码就能生成(准备步骤负责),
// `build` 由构建期工具链产出(构建脚本在产物生成后负责)。
// 随包子目录来源:`source` 直接来自仓库源码(准备步骤复制),
// `prepared` 需要先由准备步骤运行声明的构建命令产出,再复制进随包目录。
function expectOrigin(value, at) {
if (value !== 'source' && value !== 'build') {
fail(`${at} 必须是 source 或 build(实际 ${String(value)})`);
if (value !== 'source' && value !== 'prepared') {
fail(`${at} 必须是 source 或 prepared(实际 ${String(value)})`);
}
return value;
}
@@ -227,15 +227,21 @@ function parseDeclaration(raw) {
).map((entry, index) => {
const at = `plugins.libraryStaging[${index}]`;
const parsed = expectObject(entry, at);
const files = expectStringArray(parsed.files, `${at}.files`);
if (files.length === 0) {
fail(`${at}.files 不能为空`);
}
return {
plugin: expectString(parsed.plugin, `${at}.plugin`),
sourceSubdirectory: expectString(
parsed.sourceSubdirectory,
`${at}.sourceSubdirectory`,
),
prepare: expectString(parsed.prepare, `${at}.prepare`),
targets: expectStringArray(parsed.targets, `${at}.targets`),
features: expectStringArray(parsed.features, `${at}.features`),
layout: expectString(parsed.layout, `${at}.layout`),
files,
};
});
@@ -445,6 +451,10 @@ pub const CODEX_VERSION: &str = ${rustString(declaration.codex.version)};
pub const CODEX_CLI_VERSION: &str = ${rustString(declaration.codex.cliVersionPrefix + declaration.codex.version)};
pub const CODEX_MANIFEST_SCHEMA: &str = ${rustString(declaration.codex.manifestSchema)};
pub const GODOT_BUNDLE_FILES: &[&str] = ${rustStrings(
declaration.plugins.libraryStaging[0]?.files ?? [],
)};
pub const CODEX: Codex = ${codexStruct};
pub const PLUGINS: Plugins = ${pluginsStruct};
@@ -11,6 +11,7 @@
// 用法(在 apps/ai-game-creator-shell 下):
// node scripts/prepare-bundled-resources.mjs [--target <triple>] [--dry-run]
import { spawnSync } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import {
chmodSync,
@@ -700,9 +701,6 @@ function pluginTreeMatches(
return false;
}
for (const subdirectory of declaration.plugins.subdirectories) {
if (subdirectory.origin !== 'source') {
continue;
}
if (!subdirectoryEnabled(subdirectory, target, features)) {
continue;
}
@@ -743,13 +741,330 @@ function assertOwnedPluginRoot(destination, plugins) {
}
}
/// 声明的准备步骤:需要外部工具链或同一次 cargo 构建才能产出的随包内容。
function prepareStepsReferencedBy(declaration, target, features) {
const required = new Set();
for (const subdirectory of declaration.plugins.subdirectories) {
if (
subdirectory.origin === 'prepared' &&
subdirectoryEnabled(subdirectory, target, features)
) {
required.add(subdirectory.prepare);
}
}
for (const staging of declaration.plugins.libraryStaging ?? []) {
if (libraryStagingEnabled(staging, target, features)) {
required.add(staging.prepare);
}
}
for (const payload of declaration.plugins.nativePayloads ?? []) {
if (nativePayloadEnabled(payload, target, features)) {
required.add(payload.prepare);
}
}
return required;
}
function libraryStagingEnabled(staging, target, features) {
return (
(!staging.targets?.length || staging.targets.includes(target)) &&
(staging.features ?? []).every((name) => features.has(name))
);
}
function nativePayloadEnabled(payload, target, features) {
return (
(!payload.targets?.length || payload.targets.includes(target)) &&
(payload.features ?? []).every((name) => features.has(name))
);
}
/// 指纹覆盖声明的根目录(跳过排除目录),与构建脚本原先的规则一致:按相对路径排序后逐文件哈希。
function fingerprintSources(repoRoot, step) {
const { roots, excludeDirectoryNames } = step.fingerprint;
const lines = [];
for (const root of roots) {
const absoluteRoot = path.join(repoRoot, step.workingDirectory, root);
for (const relative of collectFingerprintFiles(
absoluteRoot,
excludeDirectoryNames,
)) {
const file = path.join(absoluteRoot, relative);
const info = statSync(file);
lines.push(`${relative}\u0000${info.size}\u0000${sha256File(file)}`);
}
}
return sha256Text(lines.join('\n'));
}
function collectFingerprintFiles(root, excludeDirectoryNames, prefix = '') {
const files = [];
if (!existsSync(root)) {
return files;
}
for (const entry of readdirSync(root, { withFileTypes: true })) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isSymbolicLink()) {
fail(`准备步骤源码不允许符号链接:${path.join(root, entry.name)}`);
}
if (entry.isDirectory()) {
if (!excludeDirectoryNames.includes(entry.name)) {
files.push(
...collectFingerprintFiles(
path.join(root, entry.name),
excludeDirectoryNames,
relative,
),
);
}
} else if (entry.isFile()) {
files.push(relative);
}
}
return files.sort();
}
function requiredOutputsPresent(repoRoot, step) {
return step.requiredOutputs.every((relative) => {
const file = path.join(repoRoot, relative);
return (
existsSync(file) && statSync(file).isFile() && statSync(file).size > 0
);
});
}
function defaultRunCommand({ program, args, cwd, removeEnvironment }) {
const environment = { ...process.env };
for (const name of removeEnvironment ?? []) {
delete environment[name];
}
const result = spawnSync(program, args, {
cwd,
env: environment,
stdio: 'inherit',
});
if (result.error) {
throw new PrepareError(`执行 ${program} 失败:${result.error.message}`);
}
if (result.status !== 0) {
throw new PrepareError(`${program} 退出码 ${result.status}`);
}
}
/// 按声明产出「已准备」随包内容;命中指纹且必需产物齐全时零写入。
export function ensurePreparedArtifacts({
declaration,
repoRoot,
target,
features,
profile = 'debug',
runCommand = defaultRunCommand,
log = () => {},
dryRun = false,
}) {
const required = prepareStepsReferencedBy(declaration, target, features);
const steps = new Map(
(declaration.plugins.prepareSteps ?? []).map((step) => [step.name, step]),
);
for (const name of required) {
const step = steps.get(name);
if (!step) {
fail(`声明引用了未定义的准备步骤:${name}`);
}
const stampPath = step.fingerprint
? path.join(
repoRoot,
step.workingDirectory,
step.fingerprint.stampRelativePath,
)
: undefined;
if (step.fingerprint && step.requiredOutputs.length > 0) {
const expected = fingerprintSources(repoRoot, step);
const current = existsSync(stampPath)
? readFileSync(stampPath, 'utf8').trim()
: '';
if (current === expected && requiredOutputsPresent(repoRoot, step)) {
log(`${name} 命中缓存(指纹一致)`);
continue;
}
}
if (dryRun) {
log(`${name} 需要重新构建(dry-run 未执行)`);
continue;
}
if (step.kind === 'powershell') {
runCommand({
program: 'powershell.exe',
args: [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
path.join(repoRoot, step.workingDirectory, step.scriptFileName),
],
cwd: path.join(repoRoot, step.workingDirectory),
removeEnvironment: step.removeEnvironment ?? [],
});
} else if (step.kind === 'cargo') {
runCommand({
program: 'cargo',
args: [
'build',
'--manifest-path',
path.join(
repoRoot,
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
),
'-p',
path.basename(step.packageDirectory),
'--target',
target,
...(profile === 'release' ? ['--release'] : []),
...(step.features?.length
? ['--features', step.features.join(',')]
: []),
],
cwd: repoRoot,
removeEnvironment: [],
});
} else {
fail(`未实现的准备步骤类型:${step.kind}`);
}
if (!requiredOutputsPresent(repoRoot, step)) {
const missing = step.requiredOutputs.filter(
(relative) => !existsSync(path.join(repoRoot, relative)),
);
fail(`${name} 未产出必需文件:${missing.join('、') || '(未知)'}`);
}
if (stampPath && step.fingerprint) {
mkdirSync(path.dirname(stampPath), { recursive: true });
writeFileSync(stampPath, `${fingerprintSources(repoRoot, step)}\n`);
}
log(`${name} 已构建`);
}
}
/// 复制声明为已准备的随包子目录与随包库。
function copyPreparedPayloads({
declaration,
repoRoot,
staging,
target,
features,
}) {
for (const plugin of pluginDirectories(declaration, repoRoot)) {
for (const subdirectory of declaration.plugins.subdirectories) {
if (
subdirectory.origin !== 'prepared' ||
!subdirectoryEnabled(subdirectory, target, features)
) {
continue;
}
copyPluginSubdirectory(
declaration,
path.join(plugin.root, subdirectory.path),
path.join(staging, plugin.name, subdirectory.path),
);
}
for (const stagingEntry of declaration.plugins.libraryStaging ?? []) {
if (
plugin.name !== stagingEntry.plugin ||
!libraryStagingEnabled(stagingEntry, target, features)
) {
continue;
}
for (const relative of stagingEntry.files) {
copyFilePreservingMode(
path.join(plugin.root, stagingEntry.sourceSubdirectory, relative),
path.join(
staging,
plugin.name,
stagingEntry.sourceSubdirectory,
relative,
),
);
}
}
}
}
/// Cocos bridge 的 dll 既要进插件工作区(唯一真源),也要进随包目录。
function copyNativePayloads({
declaration,
repoRoot,
srcTauriRoot,
staging,
target,
features,
profile,
}) {
for (const payload of declaration.plugins.nativePayloads ?? []) {
if (!nativePayloadEnabled(payload, target, features)) {
continue;
}
const candidates = [
path.join(
srcTauriRoot,
'target',
target,
profile,
'deps',
payload.sourceFileName,
),
path.join(
srcTauriRoot,
'target',
target,
profile,
payload.sourceFileName,
),
path.join(
srcTauriRoot,
'target',
profile,
'deps',
payload.sourceFileName,
),
path.join(srcTauriRoot, 'target', profile, payload.sourceFileName),
];
const source = candidates.find((candidate) => existsSync(candidate));
if (!source) {
fail(
`Cocos bridge native payload 未构建:${candidates.map((candidate) => path.relative(repoRoot, candidate)).join(';')}`,
);
}
copyFilePreservingMode(
source,
path.join(
repoRoot,
'plugins',
payload.plugin,
payload.destinationSubdirectory,
payload.sourceFileName,
),
);
copyFilePreservingMode(
source,
path.join(
staging,
payload.plugin,
payload.destinationSubdirectory,
payload.sourceFileName,
),
);
}
}
function preparePlugins({
declaration,
target,
destinationRoot,
repoRoot,
features,
plugins,
record,
profile,
dryRun,
}) {
const destination = path.join(
@@ -799,6 +1114,22 @@ function preparePlugins({
);
}
}
copyPreparedPayloads({
declaration,
repoRoot,
staging,
target,
features,
});
copyNativePayloads({
declaration,
repoRoot,
srcTauriRoot: destinationRoot,
staging,
target,
features,
profile,
});
});
return {
summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`,
@@ -814,6 +1145,9 @@ export function prepareBundledResources({
features = new Set(defaultEditorFeatures(target)),
recordPath = RECORD_PATH,
lockfilePath = path.join(REPO_ROOT, 'package-lock.json'),
profile = 'debug',
runCommand = defaultRunCommand,
log = () => {},
dryRun = false,
repoRoot = REPO_ROOT,
appRoot = path.dirname(destinationRoot),
@@ -844,13 +1178,25 @@ export function prepareBundledResources({
target.includes(needle),
)
) {
ensurePreparedArtifacts({
declaration,
repoRoot,
target,
features,
profile,
runCommand,
dryRun,
log,
});
const plugins = preparePlugins({
declaration,
target,
destinationRoot,
repoRoot,
features,
plugins: pluginDirectories(declaration, repoRoot),
record,
profile,
dryRun,
});
summaries.push(plugins.summary);
@@ -23,6 +23,33 @@ function sha256File(file) {
}
/// 造一个最小工作区:app(含 node_modules 上游包)、repo(含 plugins 工作区)、lockfile。
/// 造出声明里所有「已准备」产物:真实构建要 Windows 工具链,用例只需要文件在位。
function writePrepareArtifacts(root, declaration) {
const created = [];
for (const step of declaration.plugins.prepareSteps ?? []) {
for (const relative of step.requiredOutputs) {
const file = path.join(root, relative);
if (fs.existsSync(file)) {
continue;
}
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `prepared ${relative}\n`);
created.push(relative);
}
}
return created;
}
/// 记录调用的假执行器:默认把声明的必需产物造出来。
function fakeRunCommand({ created = [], failOn = null } = {}) {
return (invocation) => {
created.push(`${invocation.program} ${(invocation.args ?? []).join(' ')}`);
if (failOn && invocation.program === failOn) {
throw new Error('fake run failure');
}
};
}
function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-resources-'));
const appRoot = path.join(root, 'app');
@@ -114,6 +141,18 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) {
fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n');
}
writePrepareArtifacts(repoRoot, declaration);
const cocosDll = path.join(
destinationRoot,
'target',
WINDOWS_TARGET,
'debug',
'deps',
'cocos-editor-bridge.dll',
);
fs.mkdirSync(path.dirname(cocosDll), { recursive: true });
fs.writeFileSync(cocosDll, 'cocos bridge payload\n');
const lockfilePath = path.join(root, 'package-lock.json');
fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2));
return {
@@ -156,6 +195,7 @@ function snapshot(directory) {
}
function prepare(fixture, overrides = {}) {
const runner = overrides.runCommand ?? fakeRunCommand();
return prepareBundledResources({
target: WINDOWS_TARGET,
destinationRoot: fixture.destinationRoot,
@@ -164,6 +204,7 @@ function prepare(fixture, overrides = {}) {
lockfilePath: fixture.lockfilePath,
repoRoot: fixture.repoRoot,
appRoot: fixture.appRoot,
runCommand: runner,
...overrides,
});
}
@@ -458,3 +499,88 @@ test('declaration drives source lookup and staging units', () => {
fixture.cleanup();
}
});
test('runs declared prepare steps once and copies their artifacts into the staged tree', () => {
const fixture = buildFixture();
try {
const created = [];
const summaries = prepare(fixture, {
runCommand: fakeRunCommand({ created }),
features: new Set([
'unity-editor-execute',
'godot-editor-execute',
'cocos-editor-injection',
]),
});
assert.match(summaries[0], /命中缓存|重新生成/);
assert.ok(
created.some(
(entry) =>
entry.startsWith('powershell.exe') && entry.includes('build.ps1'),
),
'必须执行声明的 powershell 准备步骤',
);
assert.ok(
created.some(
(entry) =>
entry.startsWith('cargo build') && entry.includes('--target'),
),
'必须执行声明的 cargo 准备步骤',
);
const stagedPayload = path.join(
fixture.destinationRoot,
'resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
);
assert.ok(fs.existsSync(stagedPayload), 'native payload 必须进随包目录');
assert.ok(
fs.existsSync(
path.join(
fixture.repoRoot,
'plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
),
),
'native payload 必须写回插件工作区(唯一真源)',
);
} finally {
fixture.cleanup();
}
});
test('skips prepare steps whose fingerprint is unchanged', () => {
const fixture = buildFixture();
try {
const first = [];
prepare(fixture, { runCommand: fakeRunCommand({ created: first }) });
assert.ok(first.length > 0, '首次必须执行准备步骤');
const second = [];
prepare(fixture, { runCommand: fakeRunCommand({ created: second }) });
const unityRuns = second.filter(
(entry) =>
entry.startsWith('powershell.exe') &&
entry.includes('agc-unity-editor'),
);
assert.deepEqual(
unityRuns,
[],
'指纹一致时不应再跑声明了指纹的 Unity 准备步骤',
);
} finally {
fixture.cleanup();
}
});
test('fails closed when a prepare step does not produce its declared outputs', () => {
const fixture = buildFixture();
try {
fs.rmSync(path.join(fixture.repoRoot, 'plugins/agc-unity-editor'), {
recursive: true,
force: true,
});
assert.throws(
() => prepare(fixture, { runCommand: fakeRunCommand() }),
/未产出必需文件/,
);
} finally {
fixture.cleanup();
}
});
@@ -1,4 +0,0 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
+56 -288
View File
@@ -10,7 +10,6 @@ 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;
@@ -76,9 +75,65 @@ fn validate_staged_resources(manifest_dir: &std::path::Path) {
);
}
validate_staged_plugin_workspace(manifest_dir, &target);
validate_prepared_payloads(manifest_dir, &target);
}
/// 只读校验插件随包工作区:声明的源码派生内容必须与仓库源码逐文件一致,整树无符号链接。
/// 已准备产物与随包库在本机无法重建,构建期至少要确认它们已经就位。
fn validate_prepared_payloads(manifest_dir: &std::path::Path, target: &str) {
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 目录下");
let destination_root = manifest_dir.join(declared.destination_directory);
for plugin in package_layout::plugin_directories(
&repo_root.join(declared.source_directory),
declared.manifest_file_name,
)
.unwrap_or_else(|error| panic!("{error}"))
{
for subdirectory in declared.subdirectories {
if !package_layout::subdirectory_is_prepared(subdirectory)
|| !package_layout::subdirectory_enabled(
subdirectory,
target,
package_layout::cargo_feature_enabled,
)
{
continue;
}
let relative = package_layout::declared_relative_path(subdirectory.path);
let staged = destination_root.join(&plugin.name).join(&relative);
if !staged.is_dir() {
panic!(
"随包已准备产物缺失:{}(请先执行随包资源准备步骤)",
staged.display()
);
}
}
for staging in declared.library_staging {
if plugin.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);
let staged = destination_root.join(&plugin.name).join(&relative);
godot_bundle::validate(&staged)
.unwrap_or_else(|error| panic!("Godot 随包资源校验失败:{error}"));
}
}
}
fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) {
let declared = package_layout::plugins();
let repo_root = manifest_dir
@@ -104,14 +159,6 @@ fn main() {
"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() {
prepare_unity_editor_helper(&manifest_dir);
prepare_godot_editor_extension(&manifest_dir);
stage_build_generated_plugin_payloads(&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}"));
@@ -134,282 +181,3 @@ fn main() {
}
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}"));
}
/// 构建期产物归位:只有构建过程才产出、因而无法由准备步骤生成的随包子目录。
///
/// 源码派生的子目录由准备步骤在 `tauri dev|build` 之前写入;这里只补构建期才存在的产物。
/// 把这批产物也归位到准备步骤(连同编辑器分支产物)在后续里程碑完成。
fn stage_build_generated_plugin_payloads(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET");
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 目录下");
let destination_root = manifest_dir.join(declared.destination_directory);
let plugins = package_layout::plugin_directories(
&repo_root.join(declared.source_directory),
declared.manifest_file_name,
)
.unwrap_or_else(|error| panic!("{error}"));
for plugin in plugins {
for subdirectory in declared.subdirectories {
if !package_layout::subdirectory_is_build_derived(subdirectory)
|| !package_layout::subdirectory_enabled(
subdirectory,
&target,
package_layout::cargo_feature_enabled,
)
{
continue;
}
let relative = package_layout::declared_relative_path(subdirectory.path);
let source = plugin.path.join(&relative);
if !source.is_dir() {
continue;
}
copy_staged_tree(
&source,
&destination_root.join(&plugin.name).join(&relative),
);
}
for staging in declared.library_staging {
if plugin.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);
match staging.layout {
"godot-bundle" => godot_bundle::stage(
&plugin.path.join(&relative),
&destination_root.join(&plugin.name).join(&relative),
&target,
true,
)
.unwrap_or_else(|error| panic!("{error}")),
other => panic!("未实现的随包库 staging 布局:{other}"),
}
}
}
}
/// 复制一棵目录树(按声明跳过构建产物与测试文件);内容一致时不重写。
fn copy_staged_tree(source: &std::path::Path, destination: &std::path::Path) {
if !source.is_dir() {
return;
}
fs::create_dir_all(destination).expect("创建插件资源目录失败");
for entry in fs::read_dir(source).expect("读取插件资源失败").flatten() {
let path = entry.path();
let file_name = entry.file_name();
let name = file_name.to_string_lossy().to_string();
let target = destination.join(&file_name);
assert!(
entry
.file_type()
.expect("读取插件文件类型失败")
.is_symlink(),
"插件资源不允许符号链接"
);
if path.is_dir() {
if !package_layout::skip_directory(&name) {
copy_staged_tree(&path, &target);
}
} else if !package_layout::skip_file_name(&name) {
let bytes = fs::read(&path).expect("读取插件资源失败");
if fs::read(&target).is_ok_and(|existing| existing == bytes) {
continue;
}
fs::write(&target, bytes).expect("复制插件资源失败");
}
}
}
@@ -2,12 +2,12 @@ use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
pub const BUNDLE_FILES: [&str; 4] = [
"bin/win-x64/agc_godot_editor.dll",
"bin/win-x64/metadata.json",
"vendor/LICENSE.txt",
"vendor/provenance.json",
];
// 共享随包资源声明:Godot 随包文件清单与构建期校验共用同一份来源。
#[allow(dead_code)]
#[path = "package_layout.rs"]
mod package_layout;
pub const BUNDLE_FILES: &[&str] = package_layout::GODOT_BUNDLE_FILES;
fn plain_metadata(path: &Path) -> Result<fs::Metadata, String> {
let metadata = fs::symlink_metadata(path)
@@ -11,6 +11,8 @@ pub const CODEX_VERSION: &str = "0.155.1";
pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1";
pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2";
pub const GODOT_BUNDLE_FILES: &[&str] = &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"];
pub const CODEX: Codex = Codex {
package_metadata: PackageMetadata {
layout_version: 1,
@@ -103,7 +105,7 @@ Subdirectory {
},
Subdirectory {
path: "dotnet/publish/win-x64",
origin: "build",
origin: "prepared",
target_contains: &[],
targets: &["x86_64-pc-windows-msvc"],
features: &["unity-editor-execute"],
@@ -94,7 +94,8 @@
{ "path": "native/payload", "origin": "source", "targetContains": ["windows"] },
{
"path": "dotnet/publish/win-x64",
"origin": "build",
"origin": "prepared",
"prepare": "unity-helper-publish",
"targets": ["x86_64-pc-windows-msvc"],
"features": ["unity-editor-execute"]
}
@@ -103,9 +104,71 @@
{
"plugin": "agc-godot-editor",
"sourceSubdirectory": "native/gdextension",
"prepare": "godot-extension-build",
"targets": ["x86_64-pc-windows-msvc"],
"features": ["godot-editor-execute"],
"layout": "godot-bundle"
"layout": "godot-bundle",
"files": [
"bin/win-x64/agc_godot_editor.dll",
"bin/win-x64/metadata.json",
"vendor/LICENSE.txt",
"vendor/provenance.json"
]
}
],
"nativePayloads": [
{
"plugin": "agc-cocos-editor",
"prepare": "cocos-bridge-build",
"sourceFileName": "cocos-editor-bridge.dll",
"destinationSubdirectory": "native/payload",
"targets": ["x86_64-pc-windows-msvc"],
"features": ["cocos-editor-injection"]
}
],
"prepareSteps": [
{
"name": "unity-helper-publish",
"kind": "powershell",
"workingDirectory": "plugins/agc-unity-editor/dotnet",
"scriptFileName": "build.ps1",
"fingerprint": {
"roots": ["."],
"excludeDirectoryNames": ["bin", "obj", "publish", "native-build"],
"stampRelativePath": "publish/win-x64/.agc-source.sha256"
},
"requiredOutputs": [
"plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe",
"plugins/agc-unity-editor/dotnet/publish/win-x64/NOTICE",
"plugins/agc-unity-editor/dotnet/publish/win-x64/THIRD-PARTY-NOTICES.txt",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/DotCraft-Apache-2.0.txt",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/Roslyn-MIT.txt",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/upstream.json",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-LICENSE.TXT",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-THIRD-PARTY-NOTICES.TXT",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf"
]
},
{
"name": "godot-extension-build",
"kind": "powershell",
"workingDirectory": "plugins/agc-godot-editor/native/gdextension",
"scriptFileName": "build.ps1",
"removeEnvironment": ["PSModulePath"],
"requiredOutputs": [
"plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll",
"plugins/agc-godot-editor/native/gdextension/bin/win-x64/metadata.json",
"plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt",
"plugins/agc-godot-editor/native/gdextension/vendor/provenance.json"
]
},
{
"name": "cocos-bridge-build",
"kind": "cargo",
"packageDirectory": "plugins/agc-cocos-editor/native/cocos-editor-bridge",
"features": ["windows-injection"],
"requiredOutputs": []
}
],
"skipDirectoryNames": ["target", "node_modules"],
@@ -286,7 +286,7 @@ pub fn validate_staged_plugins(
let relative = declared_relative_path(subdirectory.path);
let source = plugin.path.join(&relative);
let staged_directory = staged.join(&relative);
if subdirectory_is_build_derived(subdirectory) {
if subdirectory_is_prepared(subdirectory) {
if source.exists() && !staged_directory.is_dir() {
return Err(format!(
"随包构建期产物缺失:{}(插件 {})",
@@ -330,9 +330,9 @@ pub fn subdirectory_is_source_derived(subdirectory: &Subdirectory) -> bool {
subdirectory.origin == "source"
}
/// 声明为「由构建期工具链产出」的子目录。
pub fn subdirectory_is_build_derived(subdirectory: &Subdirectory) -> bool {
subdirectory.origin == "build"
/// 声明为「需要先由准备步骤运行构建命令产出」的子目录。
pub fn subdirectory_is_prepared(subdirectory: &Subdirectory) -> bool {
subdirectory.origin == "prepared"
}
/// 仓库插件目录:含清单文件的普通目录,按名字排序。
@@ -853,14 +853,14 @@ mod tests {
.filter(|entry| subdirectory_is_source_derived(entry))
.map(|entry| entry.path)
.collect::<Vec<_>>();
let build = PLUGINS
let prepared = PLUGINS
.subdirectories
.iter()
.filter(|entry| subdirectory_is_build_derived(entry))
.filter(|entry| subdirectory_is_prepared(entry))
.map(|entry| entry.path)
.collect::<Vec<_>>();
assert_eq!(source, ["src", "panels", "skills", "native/payload"]);
assert_eq!(build, ["dotnet/publish/win-x64"]);
assert_eq!(prepared, ["dotnet/publish/win-x64"]);
}
#[test]