diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 00f144de6..f007e8bd4 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -11,6 +11,7 @@ import { } from './agc-global-version.mjs'; import { defaultEditorFeatures, + readCargoTarget, resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; @@ -20,7 +21,10 @@ import { resolveReleaseChannel, } from './channel-identity.mjs'; import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; -import { prepareBundledResources } from './prepare-bundled-resources.mjs'; +import { + prepareBundledResources, + supportedHostTarget, +} from './prepare-bundled-resources.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -42,26 +46,7 @@ function defaultTarget() { } function explicitBuildTarget(args) { - let target; - const separator = args.indexOf('--'); - const options = separator < 0 ? args : args.slice(0, separator); - for (let index = 0; index < options.length; index += 1) { - const argument = options[index]; - let value; - if (argument === '--target' || argument === '-t') { - value = options[++index]; - } else if (argument.startsWith('--target=')) { - value = argument.slice('--target='.length); - } else { - continue; - } - if (!value?.trim() || value.startsWith('-')) { - throw new Error('--target 缺少有效目标'); - } - if (target !== undefined) throw new Error('不能重复指定 --target'); - target = value.trim(); - } - return target; + return readCargoTarget(args); } function validateReleaseTarget(target) { @@ -494,6 +479,8 @@ export function runTauriBuild( spawn = spawnSync, stageRuntime = stageNodeRuntime, stageBundled = stageBundledResources, + platform = process.platform, + arch = process.arch, } = {}, ) { if ( @@ -502,19 +489,35 @@ export function runTauriBuild( ) { throw new Error('构建参数与发布上下文目标不一致'); } - const tauriArguments = buildTauriBuildArguments(args, context.target); - const { channel, target } = context; - const features = resolveEditorFeatures({ argv: args, target }); - // --no-bundle 只跳过发行运行时资源;应用自身构建仍需先准备随包资源。 - if (!args.includes('--no-bundle')) { - stageRuntime(target); - } - stageBundled(target, { features }); - const configPath = writeChannelConfigFile( - channel, - target, - !args.includes('--no-bundle'), + const tauriArguments = buildTauriBuildArguments( + args, + context.target, + platform, ); + const bundling = !args.includes('--no-bundle'); + // 无显式 target 的 no-bundle 由 Cargo 构建宿主平台,不使用默认发布目标。 + const resourceTarget = + explicitBuildTarget(args) || + (bundling ? context.target : supportedHostTarget(platform, arch)); + const features = resolveEditorFeatures({ + argv: tauriArguments, + target: resourceTarget ?? platform, + env: {}, + }); + const { channel } = context; + if (bundling) { + stageRuntime(resourceTarget); + } + if (resourceTarget) { + stageBundled(resourceTarget, { features }); + } else { + console.log( + '[ai-game-creator-shell] 当前宿主平台不参与客户端随包资源构建,跳过资源准备', + ); + } + // Linux smoke 不发布 updater,沿用渠道上下文;桌面宿主必须与实际构建目标一致。 + const target = resourceTarget ?? context.target; + const configPath = writeChannelConfigFile(channel, target, bundling); console.log( `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`, ); diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index e6438ed7f..4b6fc20cc 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -38,6 +38,7 @@ import { AGC_PRODUCT_NAME, resolveChannelInstallIdentity, } from './channel-identity.mjs'; +import { supportedHostTarget } from './prepare-bundled-resources.mjs'; const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; @@ -1032,6 +1033,297 @@ test('release stages Node before Tauri and injects its resource mapping only for ); }); +const windowsEditorFeatures = [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', +]; + +function uniqueSorted(values) { + return [...new Set(values ?? [])].sort(); +} + +function splitFeatureList(value) { + return String(value ?? '') + .split(',') + .map((name) => name.trim()) + .filter(Boolean); +} + +/// 最终命令行里 Cargo 实际会收到的 feature 集:tauri 自身的 `--features` / `-f` +/// 与 `--` 之后的 runner 参数(tauri 会把它们追加到 cargo 命令)都算数, +/// 随包资源准备必须拿到同一集合。 +function cargoFeaturesFromCommand(command) { + const argv = command.slice(command.indexOf('--') + 1); + const features = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = String(argv[index]); + if (argument.startsWith('--features=')) { + features.push(...splitFeatureList(argument.slice('--features='.length))); + } else if (argument === '--features' || argument === '-f') { + features.push(...splitFeatureList(argv[index + 1])); + index += 1; + } else if (/^-f.+/u.test(argument)) { + features.push(...splitFeatureList(argument.slice(2))); + } + } + return uniqueSorted(features); +} + +/** + * PR520 的 CI 回归:`tauri build --no-bundle` 不带显式 target 时编译的是**宿主**平台, + * 而发布上下文默认目标始终是 Windows。随包资源与 feature 集必须跟着宿主走, + * 否则 Linux 宿主会拿着 Windows 目标去 staging(CI smoke 直接失败), + * 还会出现「cargo 没开 feature、staging 开了」的错配。 + */ +test('no-bundle 在无随包资源声明的宿主(Linux)既不 staging 也不注入 Windows feature', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + assert.equal(context.target, windowsTarget); + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + platform: 'linux', + arch: 'x64', + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target) { + assert.fail(`Linux 宿主没有随包资源声明,不得 staging ${target}`); + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.ok(command, '宿主不受声明覆盖时仍必须完成本次构建'); + assert.ok(!command.includes('--target'), '宿主构建不得打显式 target'); + assert.deepEqual( + cargoFeaturesFromCommand(command), + [], + 'Linux 宿主不得注入 Windows 编辑器 feature', + ); +}); + +test('no-bundle 在 macOS / Windows 宿主按真实宿主三元组准备随包资源', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + for (const [platform, arch, hostTarget, expectedFeatures] of [ + ['darwin', 'arm64', 'aarch64-apple-darwin', []], + ['darwin', 'x64', 'x86_64-apple-darwin', []], + ['win32', 'x64', windowsTarget, windowsEditorFeatures], + ]) { + const label = `${platform}/${arch}`; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + platform, + arch, + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.equal( + staged?.target, + hostTarget, + `${label} 必须按宿主三元组 staging`, + ); + assert.deepEqual( + uniqueSorted(staged?.features), + uniqueSorted(expectedFeatures), + `${label} staging feature 与宿主不一致`, + ); + assert.deepEqual( + cargoFeaturesFromCommand(command), + uniqueSorted(expectedFeatures), + `${label} Cargo feature 与 staging 不一致`, + ); + assert.ok( + !command.includes('--target'), + `${label} 宿主构建不得打显式 target`, + ); + } +}); + +test('no-bundle 的显式 target 优先于宿主:跨平台 / 跨架构仍按参数准备', () => { + for (const [platform, arch, target, expectedFeatures] of [ + ['linux', 'x64', windowsTarget, windowsEditorFeatures], + ['darwin', 'arm64', 'x86_64-apple-darwin', []], + ]) { + const args = ['--no-bundle', '--target', target]; + const context = resolveReleaseContext(args, {}); + const label = `${platform}/${arch} → ${target}`; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(args, context, { + platform, + arch, + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(stageTarget, options) { + staged = { target: stageTarget, features: options?.features }; + }, + spawn(_binary, argv) { + command = argv; + return { status: 0 }; + }, + }); + }); + assert.equal(staged?.target, target, `${label} 必须按显式 target staging`); + assert.ok( + command.includes(target), + `${label} 必须把显式 target 传给 Tauri`, + ); + assert.deepEqual( + uniqueSorted(staged?.features), + uniqueSorted(expectedFeatures), + `${label} staging feature 与预期不一致`, + ); + assert.deepEqual( + cargoFeaturesFromCommand(command), + uniqueSorted(expectedFeatures), + `${label} Cargo feature 与 staging 不一致`, + ); + } +}); + +test('打包构建(非 no-bundle)的 staging 目标跟随发布上下文而不是宿主', () => { + const crossArch = 'x86_64-apple-darwin'; + const context = resolveReleaseContext([], { AGC_BUILD_TARGET: crossArch }); + assert.equal(context.target, crossArch); + let runtimeTarget; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild([], context, { + platform: 'darwin', + arch: 'arm64', + stageRuntime(target) { + runtimeTarget = target; + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.equal(runtimeTarget, crossArch); + assert.equal( + staged?.target, + crossArch, + '打包构建必须按发布上下文目标 staging', + ); + assert.deepEqual(uniqueSorted(staged?.features), []); + assert.ok( + command.includes(crossArch), + '打包构建必须把发布上下文目标传给 Tauri', + ); + assert.deepEqual(cargoFeaturesFromCommand(command), []); +}); + +test('staging feature 与最终 Cargo feature 一致:-f / --features / -- 之后的 runner 参数', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + const cases = [ + { + args: ['--no-bundle', '--features', 'custom-editor-feature'], + expected: ['custom-editor-feature'], + }, + { + args: ['--no-bundle', '--features=custom-a,custom-b'], + expected: ['custom-a', 'custom-b'], + }, + { + args: ['--no-bundle', '-f', 'custom-editor-feature'], + expected: ['custom-editor-feature'], + }, + { + args: ['--no-bundle', '-fcustom-editor-feature'], + expected: ['custom-editor-feature'], + }, + // `--` 之后的参数是 runner 参数:tauri 会把它们追加到 cargo 命令行 + // (`tauri build --help`:Command line arguments passed to the runner), + // 因此 Cargo 最终拿到的是「注入的默认值 ∪ runner 参数」,staging 必须一致。 + { + args: ['--no-bundle', '--', '--features=app-runner'], + expected: [...windowsEditorFeatures, 'app-runner'], + }, + // 环境变量泄漏(Jenkins Job 参数)不得让 staging 与 Cargo 分叉。 + { + args: ['--no-bundle'], + env: { AGC_DEV_CARGO_FEATURES: 'cocos-editor-execute' }, + }, + ]; + for (const entry of cases) { + const { args, expected } = entry; + const label = args.join(' '); + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined, ...entry.env }, () => { + runTauriBuild(args, context, { + platform: 'win32', + arch: 'x64', + stageRuntime() { + assert.fail(`${label} 不得准备 Node 运行时`); + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, argv) { + command = argv; + return { status: 0 }; + }, + }); + }); + assert.equal(staged?.target, windowsTarget, label); + const cargoFeatures = cargoFeaturesFromCommand(command); + assert.deepEqual( + uniqueSorted(staged?.features), + cargoFeatures, + `${label}:staging feature 必须等于 Cargo 收到的 feature`, + ); + if (expected) { + assert.deepEqual( + cargoFeatures, + uniqueSorted(expected), + `${label}:Cargo feature 与预期不一致`, + ); + } + } +}); + +test('未注入 platform / arch 时按真实 process 平台取宿主随包目标', () => { + const hostTarget = supportedHostTarget(process.platform, process.arch); + const context = resolveReleaseContext(['--no-bundle'], {}); + const events = []; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target) { + events.push(target); + }, + spawn() { + return { status: 0 }; + }, + }); + }); + assert.deepEqual(events, hostTarget ? [hostTarget] : []); +}); + test('channel manifest carries version, platform keys and signature', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => { diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.mjs index c8303d42b..289b0c515 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.mjs @@ -1,69 +1,134 @@ /** 默认桌面能力;显式 feature 参数优先,不把应用参数当 Cargo 参数。 */ export function withDefaultCargoFeatures(argv, features) { - const separator = argv.indexOf('--'); - const cargoArgs = separator < 0 ? argv : argv.slice(0, separator); - if ( - !features.length || - cargoArgs.some( - (value) => - value === '--features' || - value === '-f' || - value.startsWith('--features=') || - /^-f.+/u.test(value), - ) - ) { + // 只看第一个 `--` 之前的 Tauri 选项:runner 参数区里的 `--features` 会与这里注入的 + // 默认值在 Cargo 侧合并,不能当作「用户已经指定过了」而跳过注入。 + if (!features.length || tauriOptions(argv).some(hasExplicitFeatures)) { return argv; } return [`--features=${features.join(',')}`, ...argv]; } +/// 第一个 `--` 之前是 Tauri 自己的选项,之后是 runner(Cargo)参数。 +function tauriOptions(argv) { + const separator = argv.indexOf('--'); + return separator < 0 ? argv : argv.slice(0, separator); +} + +/** + * 交给 Tauri 与 Cargo 的参数区间:第一个 `--` 之前是 Tauri 选项,之后到第二个 `--` 是 + * runner(Cargo)参数;第二个 `--` 之后由 Tauri 转交应用,不属于构建参数。 + * dev 入口(`buildTauriArguments` 会把启动器的单个 `--` 补成 runner + 应用两段)与发布 + * 入口都用这一份区间解析,两边的目标与 feature 必须来自同一个口径。 + */ +function cargoArguments(argv) { + const separator = argv.indexOf('--'); + if (separator < 0) return argv; + const applicationStart = argv.indexOf('--', separator + 1); + return applicationStart < 0 ? argv : argv.slice(0, applicationStart); +} + +/// 四种拼写(`--features x` / `--features=x` / `-f x` / `-fx`)都算显式指定。 +function hasExplicitFeatures(value) { + return ( + value === '--features' || + value === '-f' || + value.startsWith('--features=') || + /^-f.+/u.test(value) + ); +} + +function parseFeatureNames(raw) { + return String(raw) + .split(',') + .map((name) => name.trim()) + .filter(Boolean); +} + export function defaultEditorFeatures(target) { return target === 'win32' || target.includes('windows') ? ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'] : []; } -/// 单一声明式解析:本次构建实际生效的编辑器 feature 集。 -/// 解析顺序:环境变量 `AGC_DEV_CARGO_FEATURES` > 命令行 `--features` > 目标平台默认值。 -/// dev 入口、发布入口的 cargo 参数与随包资源准备步骤都必须用这里的返回值, -/// 否则会出现「cargo 开了 feature、staging 没开(或反之)」的窗口。 +/** + * 显式 Cargo 目标解析:`--target ` / `--target=` / `-t `。 + * 应用参数区(第二个 `--` 之后)不参与解析。未显式指定返回 undefined;取值缺失、 + * 取到另一个选项或重复指定都失败关闭。 + * dev 入口与发布入口共用这一份解析:cargo 的构建目标与随包资源 staging 目标 + * 必须来自同一个显式来源,不能一方读参数、另一方回退宿主默认值。 + */ +export function readCargoTarget(argv = []) { + const options = cargoArguments(argv); + let target; + for (let index = 0; index < options.length; index += 1) { + const argument = String(options[index]); + let value; + if (argument === '--target' || argument === '-t') { + value = options[index + 1]; + index += 1; + } else if (argument.startsWith('--target=')) { + value = argument.slice('--target='.length); + } else if (argument.startsWith('-t') && argument.length > 2) { + value = argument.slice(2).replace(/^=/u, ''); + } else { + continue; + } + if (!value?.trim() || String(value).startsWith('-')) { + throw new Error('--target 缺少有效目标'); + } + if (target !== undefined) throw new Error('不能重复指定 --target'); + target = String(value).trim(); + } + return target; +} + +/** + * 单一声明式解析:本次构建实际生效的编辑器 feature 集。 + * 解析顺序:命令行 `--features`(Cargo 真正收到的参数,不能被环境覆盖) + * > 环境变量 `AGC_DEV_CARGO_FEATURES`(存在即以它为准,空串即关闭默认 feature) + * > 目标平台默认值。 + * 传入的必须是「最终交给 Tauri 的参数」:dev 入口与发布入口的 cargo 参数与随包资源 + * 准备步骤都消费这里的返回值,否则会出现「cargo 开了 feature、staging 没开(或反之)」。 + */ export function resolveEditorFeatures({ argv = [], target, env = process.env, } = {}) { - // 顺序:环境变量(dev 的显式覆盖)> 命令行 --features > 目标平台默认值。 - const fromEnv = String(env.AGC_DEV_CARGO_FEATURES ?? '').trim(); - if (fromEnv) { - return fromEnv - .split(',') - .map((name) => name.trim()) - .filter(Boolean); - } const fromArgv = featuresFromArgv(argv); if (fromArgv) { return fromArgv; } + // 变量存在本身即声明「本次 feature 集由环境决定」:空串是有意的关闭,不是未设置。 + if (env.AGC_DEV_CARGO_FEATURES !== undefined) { + return parseFeatureNames(env.AGC_DEV_CARGO_FEATURES); + } return defaultEditorFeatures(target); } -/// 从 argv 里解析 `--features a,b` / `--features=a,b`;没有则返回 null。 +/// 汇总 cargo 参数区里出现在任何位置的 `--features` / `-f`(Cargo 会把多个 flag 合并); +/// 没有任何 feature flag 时返回 null。显式但为空(如 `--features=`)返回空数组。 export function featuresFromArgv(argv = []) { - for (let index = 0; index < argv.length; index += 1) { - const value = String(argv[index]); + const options = cargoArguments(argv); + const features = []; + let found = false; + for (let index = 0; index < options.length; index += 1) { + const value = String(options[index]); if (value.startsWith('--features=')) { - return value - .slice('--features='.length) - .split(',') - .map((name) => name.trim()) - .filter(Boolean); - } - if (value === '--features' && argv[index + 1] !== undefined) { - return String(argv[index + 1]) - .split(',') - .map((name) => name.trim()) - .filter(Boolean); + found = true; + features.push(...parseFeatureNames(value.slice('--features='.length))); + } else if (value === '--features' || value === '-f') { + found = true; + index += 1; + const next = options[index]; + // 变参 flag:后面不是选项时才是它的取值。 + if (next !== undefined && !String(next).startsWith('-')) { + features.push(...parseFeatureNames(next)); + } + } else if (value.startsWith('-f') && value.length > 2) { + found = true; + features.push(...parseFeatureNames(value.slice(2).replace(/^=/u, ''))); } } - return null; + return found ? features : null; } diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index a5be013ac..7cc06d82a 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs'; import { + readCargoTarget, resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; @@ -56,9 +57,14 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { } // 开发和发行构建使用同一平台编辑器 feature 集合。 -// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。 +// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭; +// 命令行显式 `--features` / `-f` 优先于环境变量(cargo 实际收到的是命令行参数)。 +// 入参是「最终交给 Tauri 的参数」:默认平台由其中的显式 `--target` 决定,未指定才是宿主平台。 function readDevCargoFeatures(argv = []) { - return resolveEditorFeatures({ argv, target: process.platform }); + return resolveEditorFeatures({ + argv, + target: readCargoTarget(argv) ?? process.platform, + }); } function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) { @@ -67,11 +73,19 @@ function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) { /// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。 /// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。 +/// `argv` 是「最终交给 Tauri 的参数」;staging 目标与本次 cargo 构建目标同源: +/// 显式 `--target` 优先,未指定时回落宿主目标;宿主平台不受声明覆盖(如 Linux)时保持跳过, +/// 不为其新增随包资源准备。`features` 省略时按同一份参数解析,不允许「cargo 一套、staging 另一套」。 function prepareBundledResourcesBeforeTauri( - features = readDevCargoFeatures(), - { prepare = prepareBundledResources, log = console.log } = {}, + features, + argv = [], + { + prepare = prepareBundledResources, + log = console.log, + hostTarget = supportedHostTarget(), + } = {}, ) { - const target = supportedHostTarget(); + const target = readCargoTarget(argv) ?? hostTarget; if (!target) { log( '[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备', @@ -80,7 +94,7 @@ function prepareBundledResourcesBeforeTauri( } const summaries = prepare({ target, - features: new Set(features), + features: new Set(features ?? readDevCargoFeatures(argv)), log: (line) => log(`[ai-game-creator-shell] ${line}`), }); for (const summary of summaries) { @@ -152,8 +166,11 @@ async function runTauriDev( } try { - const devFeatures = readDevCargoFeatures(); - prepareResources(devFeatures); + // 目标与 feature 都从「最终交给 Tauri 的参数」解析:启动器自己的 `--` 之后是应用参数, + // 不能参与解析;注入默认 feature 也不改变这份解析结果。 + const devTauriArguments = buildTauriArguments(argv, endpoint.url); + const devFeatures = readDevCargoFeatures(devTauriArguments); + prepareResources(devFeatures, devTauriArguments); // Windows 下 Vite 会监听资源目录;必须在启动前完成目录替换,避免 rename 被占用。 const preparation = prepareFrontend(endpoint, { signal: preparationAbort.signal, diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index 1becc9303..b3de87823 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -5,6 +5,10 @@ import { join } from 'node:path'; import { describe, expect, test, vi } from 'vitest'; +import { + readCargoTarget, + resolveEditorFeatures, +} from '../scripts/cargo-features.mjs'; import { isProcessGroupAlive, spawnChild, @@ -13,6 +17,7 @@ import { import { buildTauriArguments, buildTauriDevProcessEnv, + prepareBundledResourcesBeforeTauri, runTauriDev as runTauriDevImpl, withDevCargoFeatures, } from '../scripts/start-tauri-dev.mjs'; @@ -363,3 +368,260 @@ describe('AI 游戏创作 Tauri dev 进程环境', () => { } }); }); + +describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { + const windowsFeatures = [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', + ]; + // buildTauriArguments 注入的动态 devUrl 配置:最终交给 Tauri 的参数里长这样。 + const devConfig = + '{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}'; + + test('显式 --target 决定 staging 目标,未指定或属于应用参数时回落宿主目标', () => { + const calls: [string, string[]][] = []; + const hostTarget = 'aarch64-apple-darwin'; + const prepare = (options: { + target: string; + features: Iterable; + }) => { + calls.push([options.target, [...options.features]]); + return []; + }; + + for (const argv of [ + ['dev', '--config', devConfig], + ['dev', '--target', 'x86_64-apple-darwin', '--config', devConfig], + ['dev', '--target=x86_64-pc-windows-msvc', '--config', devConfig], + // 启动器把 `--` 之后的参数交给应用(补成第二段分隔符):不能拿它改随包资源目标。 + [ + 'dev', + '--config', + devConfig, + '--', + '--', + '--target=x86_64-pc-windows-msvc', + ], + ]) { + prepareBundledResourcesBeforeTauri(windowsFeatures, argv, { + hostTarget, + prepare, + log: () => {}, + }); + } + + expect(calls.map(([target]) => target)).toEqual([ + hostTarget, + 'x86_64-apple-darwin', + 'x86_64-pc-windows-msvc', + hostTarget, + ]); + expect(calls[0][1]).toEqual(windowsFeatures); + }); + + test('宿主平台不受声明覆盖时跳过准备,且不为其新增随包支持', () => { + const prepare = vi.fn(); + const log = vi.fn(); + + prepareBundledResourcesBeforeTauri( + windowsFeatures, + ['dev', '--config', devConfig], + { hostTarget: null, prepare, log }, + ); + + expect(prepare).not.toHaveBeenCalled(); + expect(log.mock.calls.flat().join('\n')).toContain('跳过随包资源准备'); + }); + + async function runCapturingDev(argv: string[]) { + const prepared: unknown[][] = []; + const spawned: string[][] = []; + const child = Object.assign(new EventEmitter(), { + pid: 1234, + exitCode: 0, + signalCode: null, + kill: vi.fn(), + }); + const result = await runTauriDev(argv, { + resolveDevEndpoint: resolveTestEndpoint, + preflight: async () => {}, + prepareResources: (...args: unknown[]) => { + prepared.push(args); + }, + spawnCli: (args: string[]) => { + spawned.push(args); + return child; + }, + waitForCli: async () => ({ type: 'exit', code: 0, signal: null }), + terminateTree: async () => ({ stopped: true, forced: false }), + }); + return { result, prepared, spawned }; + } + + test('显式 -f 优先于环境变量,并同时决定随包资源 feature 与 cargo 参数', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures'); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '-f', + 'custom-feature', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([ + ['custom-feature'], + ]); + expect(spawned[0].slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']); + expect(spawned[0]).not.toContain( + `--features=${windowsFeatures.join(',')}`, + ); + // 随包资源准备拿到的是最终交给 Tauri 的参数,不是启动器收到的原始参数。 + expect(prepared[0][1]).toEqual(spawned[0]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('显式 --target 同时进入随包资源准备与 Tauri 参数', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '--target', + 'x86_64-apple-darwin', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect(prepared[0][1]).toEqual([ + 'dev', + '--target', + 'x86_64-apple-darwin', + '--config', + devConfig, + ]); + expect(spawned[0]).toEqual(prepared[0][1]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('启动器 `--` 之后的应用参数不参与 staging feature 解析', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '--', + '--features=app-tool', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect(spawned[0]).toEqual([ + 'dev', + '--config', + devConfig, + '--', + '--', + '--features=app-tool', + ]); + expect(prepared[0][1]).toEqual(spawned[0]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('空串 AGC_DEV_CARGO_FEATURES 关闭默认 feature,staging 与 cargo 都不带 feature', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect( + spawned[0].some((argument) => argument.startsWith('--features')), + ).toBe(false); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('环境变量只在不与显式 CLI 冲突时生效', () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures'); + try { + expect( + resolveEditorFeatures({ + argv: ['dev', '--config', devConfig], + target: 'win32', + }), + ).toEqual(['template-library-fixtures']); + expect( + resolveEditorFeatures({ + argv: ['dev', '-f', 'cli-feature', '--config', devConfig], + target: 'win32', + }), + ).toEqual(['cli-feature']); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + +describe('AI 游戏创作 Tauri dev 目标与 feature 解析', () => { + test('目标只从构建参数区解析,取值缺失、取到选项或重复都失败关闭', () => { + expect(readCargoTarget([])).toBeUndefined(); + expect(readCargoTarget(['--no-watch'])).toBeUndefined(); + expect(readCargoTarget(['-t', 'x86_64-apple-darwin'])).toBe( + 'x86_64-apple-darwin', + ); + expect(readCargoTarget(['--target=x86_64-apple-darwin'])).toBe( + 'x86_64-apple-darwin', + ); + // runner 参数由 Tauri 追加给 cargo:仍然算构建目标。 + expect( + readCargoTarget(['build', '--', '--target=aarch64-apple-darwin']), + ).toBe('aarch64-apple-darwin'); + // 第二个 `--` 之后是应用参数。 + expect( + readCargoTarget(['dev', '--', '--', '--target=aarch64-apple-darwin']), + ).toBeUndefined(); + expect(() => readCargoTarget(['--target'])).toThrow('缺少有效目标'); + expect(() => readCargoTarget(['--target', '--no-watch'])).toThrow( + '缺少有效目标', + ); + expect(() => readCargoTarget(['--target', 'a', '-t', 'b'])).toThrow( + '不能重复指定', + ); + }); + + test('feature 解析覆盖 -f 拼写、合并多个 flag 并止步于应用参数', () => { + const cases: [string[], string[]][] = [ + [ + ['-f', 'a,b'], + ['a', 'b'], + ], + [['-fa'], ['a']], + [['--features=explicit'], ['explicit']], + // Cargo 会合并多个 feature flag:staging 必须拿到同一集合。 + [ + ['--features', 'a', '-f', 'b'], + ['a', 'b'], + ], + // 显式给空集合即关闭默认,而不是回落默认值。 + [['--features='], []], + // 第二个 `--` 之后是应用参数。 + [ + ['dev', '--config', '{}', '--', '--', '-f', 'app-feature'], + [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', + ], + ], + ]; + + for (const [argv, expected] of cases) { + expect(resolveEditorFeatures({ argv, target: 'win32', env: {} })).toEqual( + expected, + ); + } + }); +}); diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index a72d6d1fa..6a6f157bd 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -102,6 +102,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 6. **失败语义**:上游缺失、integrity 不匹配、目标平台不支持、外部工具链缺失 → 立即失败并给出可执行提示;不允许「跳过生成、继续打包」。 7. **可观测**:输出一行汇总(命中缓存 / 重新生成 / 跳过原因),供本地与 CI 排障。 8. **并发**:不做并发支持(无实际场景)。同一 staging 目录的并发调用未加锁,会以可读错误失败并保留已生成的 staging 目录;需要并发时由调用方外部串行化。 +9. **实际构建目标**:资源准备与 Cargo 必须使用同一目标和 feature 集。正式发布使用发布目标;无显式 `--target` 的 `--no-bundle` 使用宿主平台,Windows/macOS 仍须准备资源,Linux 编译 smoke 不准备桌面平台专属资源。feature 从最终 Tauri/Cargo 参数解析,不能被开发环境变量单独覆盖;dev 启动器转交的应用参数不参与构建参数解析。 ### 4.4 build.rs 退化后的职责