修复AGC跨平台资源构建目标
让Linux no-bundle smoke不再误用Windows随包资源目标。 统一dev与release的Cargo目标和feature解析,补充跨平台回归测试。 同步随包资源staging技术方案中的实际构建目标约定。
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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: '修复与改进' }, () => {
|
||||
|
||||
@@ -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 <triple>` / `--target=<triple>` / `-t <triple>`。
|
||||
* 应用参数区(第二个 `--` 之后)不参与解析。未显式指定返回 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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>;
|
||||
}) => {
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 退化后的职责
|
||||
|
||||
|
||||
Reference in New Issue
Block a user