From 6a0b75779b2aa6a317446ca75a7473fa34fdab3e Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 21 Sep 2026 11:57:43 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E9=9A=8F=E5=8C=85=20Node=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20universal=20=E5=8F=8C=E6=9E=B6=E6=9E=84=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=20-=20stage-node-runtime=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20targetRuntimes=EF=BC=9Auniversal=20=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E4=B8=BA=20aarch64/x86=5F64=20=E4=B8=A4=E4=BB=BD?= =?UTF-8?q?=E5=8D=95=E6=9E=B6=E6=9E=84=E8=BF=90=E8=A1=8C=E6=97=B6=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20runtimeSourceForTarget=EF=BC=9A=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E6=9E=B6=E6=9E=84=E5=8F=96=E5=BD=93=E5=89=8D=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=20Node=EF=BC=8C=E5=85=B6=E5=AE=83=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E5=BF=85=E9=A1=BB=E7=94=B1=20AGC=5FNODE=5FRUNTIME=5F?= =?UTF-8?q?=5F=5FPATH=20=E6=98=BE=E5=BC=8F=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=EF=BC=8C=E7=BC=BA=E5=A4=B1=E5=8D=B3=E5=A4=B1=E8=B4=A5=E5=85=B3?= =?UTF-8?q?=E9=97=AD=20-=20=E6=8A=8A=20stageNodeRuntime=20=E6=8B=86?= =?UTF-8?q?=E6=88=90=20inspectRuntimeSource=EF=BC=88=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=EF=BC=89+=20writeRuntimeBundle=EF=BC=88?= =?UTF-8?q?=E8=90=BD=E7=9B=98=EF=BC=89=EF=BC=8Cuniversal=20=E5=9C=A8?= =?UTF-8?q?=E5=86=99=E5=87=BA=E4=BB=BB=E4=BD=95=E7=9B=AE=E5=BD=95=E5=89=8D?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=85=A8=E9=83=A8=E6=9D=A5=E6=BA=90=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E=E7=89=88=E6=9C=AC=E4=B8=80=E8=87=B4=E6=80=A7?= =?UTF-8?q?=E6=A0=B8=E5=AF=B9=20-=20stageNodeRuntimeForTarget=20=E5=8D=95?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E4=BB=8D=E5=86=99=E6=89=81=E5=B9=B3=20node-r?= =?UTF-8?q?untime/=EF=BC=8Cuniversal=20=E6=8C=89=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E5=86=99=20node-runtime/-/=20-=20build-release?= =?UTF-8?q?=20=E9=BB=98=E8=AE=A4=E6=8C=89=E5=8F=91=E5=B8=83=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=20stage=EF=BC=8Cuniversal=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=8F=AA=E5=B8=A6=E5=AE=BF=E4=B8=BB=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E7=9A=84=E8=BF=90=E8=A1=8C=E6=97=B6=20-=20environment?= =?UTF-8?q?=5Fcheck=20resolve=5Fat=20=E5=9C=A8=E6=89=81=E5=B9=B3=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E4=B9=8B=E5=90=8E=E6=8C=89=E5=BD=93=E5=89=8D=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=9E=B6=E6=9E=84=E6=9F=A5=E6=89=BE=20-=20=E5=AD=90=E7=9B=AE=E5=BD=95=EF=BC=8C=E4=B8=A4=E4=BB=BD?= =?UTF-8?q?=E5=80=99=E9=80=89=E9=83=BD=E8=A6=81=E8=BF=87=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=EF=BC=8C=E5=AD=98=E5=9C=A8=E4=BD=86=E6=8D=9F?= =?UTF-8?q?=E5=9D=8F=E5=88=99=E5=A4=B1=E8=B4=A5=E5=85=B3=E9=97=AD=20-=20?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=8F=8C=E6=9E=B6=E6=9E=84=20staging?= =?UTF-8?q?=E3=80=81=E7=BC=BA=E9=9D=9E=E5=AE=BF=E4=B8=BB=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E3=80=81=E7=89=88=E6=9C=AC=E4=B8=8D=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E3=80=81=E6=8C=89=E8=BF=90=E8=A1=8C=E6=9E=B6=E6=9E=84=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E7=AD=89=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/build-release.mjs | 5 +- .../scripts/check-macos-bundle.mjs | 137 +++++++++++---- .../scripts/stage-node-runtime.mjs | 156 +++++++++++++++++- .../scripts/stage-node-runtime.test.mjs | 151 ++++++++++++++++- .../src-tauri/src/environment_check.rs | 86 +++++++++- 5 files changed, 491 insertions(+), 44 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 92b719262..7699e30ff 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -13,7 +13,7 @@ import { defaultEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; -import { stageNodeRuntime } from './stage-node-runtime.mjs'; +import { stageNodeRuntimeForTarget } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); // 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。 @@ -459,7 +459,8 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) { export function runTauriBuild( args = [], context = resolveReleaseContext(args), - { spawn = spawnSync, stageRuntime = stageNodeRuntime } = {}, + // 默认按发布目标 stage:universal 需要两份架构运行时,单架构目标行为不变。 + { spawn = spawnSync, stageRuntime = stageNodeRuntimeForTarget } = {}, ) { if ( explicitBuildTarget(args) && diff --git a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs index e23db686a..951542e19 100644 --- a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs +++ b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs @@ -76,6 +76,33 @@ function run(command, args) { return result; } +/** + * 单独执行随包 Node 分片:分片架构与宿主一致时直接跑,不一致时用 `arch` 强制 + * (arm64 机器上的 x86_64 分片依赖 Rosetta,与 `.app` 双架构 smoke 的前提相同)。 + */ +function runNodeSlice(directory, args) { + const native = process.arch === 'arm64' ? 'arm64' : 'x86_64'; + const binary = path.join(directory, 'node'); + const [command, argv] = + architecture === native + ? [binary, args] + : ['/usr/bin/arch', [`-${architecture}`, binary, ...args]]; + const result = spawnSync(command, argv, { + cwd: root, + env, + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 1024 * 1024, + }); + assert.ifError(result.error); + assert.equal( + result.status, + 0, + `${directory} 随包 Node 执行失败:${result.stderr || result.stdout}`, + ); + return result.stdout.trim(); +} + /** * APFS 上优先用 `ditto --clone`:整包按区块克隆,秒级完成且几乎不占额外空间。 * 跨卷或非 APFS 时回退到真实复制;两种路径都必须产出可独立改动的副本, @@ -248,37 +275,84 @@ try { } } assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md'))); + // 随包 Node:单架构构建是扁平目录,universal 构建把两套架构运行时并列放进 + // `game-runtime/node/-/`(与 Codex 侧车同形态,由 Rust 侧按运行架构选择)。 + // 两套清单在本函数里都做完整性与架构校验;只执行与本次 smoke 架构一致的那一份, + // 另一份由另一次架构的 smoke 覆盖(build-macos-ci.mjs 会对 arm64 / x86_64 各跑一次)。 const nodeRoot = path.join(resources, 'game-runtime/node'); - const nodeManifest = JSON.parse( - fs.readFileSync(path.join(nodeRoot, 'manifest.json'), 'utf8'), - ); - assert.equal(nodeManifest.schemaVersion, 'agc-node-runtime.v1'); - assert.equal(nodeManifest.platform, 'darwin'); - assert.equal(nodeManifest.arch, process.arch); - const runtimeFiles = fs - .readdirSync(nodeRoot, { recursive: true }) - .filter( - (file) => - fs.statSync(path.join(nodeRoot, file)).isFile() && - file !== 'manifest.json', + const nodeSlices = requireUniversal + ? ['darwin-arm64', 'darwin-x64'].map((platform) => ({ + platform, + directory: path.join(nodeRoot, platform), + })) + : [{ platform: null, directory: nodeRoot }]; + for (const slice of nodeSlices) { + const { directory } = slice; + const nodeManifest = JSON.parse( + fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'), ); - assert.deepEqual(runtimeFiles.sort(), Object.keys(nodeManifest.files).sort()); - for (const [file, digest] of Object.entries(nodeManifest.files)) { - assert.equal(await hashFile(path.join(nodeRoot, file)), digest, file); + assert.equal(nodeManifest.schemaVersion, 'agc-node-runtime.v1'); + assert.equal(nodeManifest.platform, 'darwin'); + if (slice.platform) { + // 目录名与清单架构必须一致:错位会让用户拿到跑不起来的运行时。 + assert.equal(`darwin-${nodeManifest.arch}`, slice.platform); + assert.ok( + /^(arm64|x64)$/u.test(nodeManifest.arch), + `未知运行架构:${nodeManifest.arch}`, + ); + } else { + assert.equal(nodeManifest.arch, process.arch); + } + const runtimeFiles = fs + .readdirSync(directory, { recursive: true }) + .filter( + (file) => + fs.statSync(path.join(directory, file)).isFile() && + file !== 'manifest.json', + ); + assert.deepEqual( + runtimeFiles.sort(), + Object.keys(nodeManifest.files).sort(), + slice.platform ?? 'flat', + ); + for (const [file, digest] of Object.entries(nodeManifest.files)) { + assert.equal(await hashFile(path.join(directory, file)), digest, file); + } + assert.ok(nodeManifest.files['NODE-LICENSE']); + assert.ok(nodeManifest.files['node_modules/npm/LICENSE']); + fs.accessSync(path.join(directory, 'node'), fs.constants.X_OK); + // 二进制本身必须是本分片的单一架构:官方发行版不做 universal,lipo 能直接证明。 + const sliceArchitecture = spawnSync( + '/usr/bin/lipo', + ['-archs', path.join(directory, 'node')], + { encoding: 'utf8' }, + ); + assert.equal(sliceArchitecture.status, 0, 'lipo -archs node'); + assert.equal( + sliceArchitecture.stdout.trim(), + nodeManifest.arch === 'x64' ? 'x86_64' : 'arm64', + '随包 Node 的二进制架构必须等于清单架构', + ); + // 只有与本次 smoke 架构一致的分片才执行;另一架构留给对应的那次 smoke。 + if ( + !requireUniversal || + nodeManifest.arch === (architecture === 'arm64' ? 'arm64' : 'x64') + ) { + assert.equal( + runNodeSlice(directory, ['--version']), + nodeManifest.nodeVersion, + `${slice.platform ?? 'flat'} node --version`, + ); + assert.equal( + runNodeSlice(directory, [ + path.join(directory, 'node_modules/npm/bin/npm-cli.js'), + '--version', + ]), + nodeManifest.npmVersion, + `${slice.platform ?? 'flat'} npm --version`, + ); + } } - assert.ok(nodeManifest.files['NODE-LICENSE']); - assert.ok(nodeManifest.files['node_modules/npm/LICENSE']); - assert.equal( - run(path.join(nodeRoot, 'node'), ['--version']).stdout.trim(), - nodeManifest.nodeVersion, - ); - assert.equal( - run(path.join(nodeRoot, 'node'), [ - path.join(nodeRoot, 'node_modules/npm/bin/npm-cli.js'), - '--version', - ]).stdout.trim(), - nodeManifest.npmVersion, - ); const plugin = path.join(resources, 'plugins/agc-cocos-editor'); for (const file of [ 'plugin.json', @@ -294,8 +368,13 @@ try { /(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test( file, ) || + // 只有随包 Node 自带的 npm 允许出现 node_modules;两种布局都要放行: + // 单架构的 `game-runtime/node/node_modules/npm` 与 universal 的 + // `game-runtime/node/-/node_modules/npm`。 (/(^|\/)node_modules(\/|$)/.test(file) && - !file.startsWith('game-runtime/node/node_modules/npm')), + !/^game-runtime\/node\/((darwin-(arm64|x64))\/)?node_modules\/npm(\/|$)/u.test( + file, + )), ), ); assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version); diff --git a/apps/ai-game-creator-shell/scripts/stage-node-runtime.mjs b/apps/ai-game-creator-shell/scripts/stage-node-runtime.mjs index e712534d6..9b6c5a934 100644 --- a/apps/ai-game-creator-shell/scripts/stage-node-runtime.mjs +++ b/apps/ai-game-creator-shell/scripts/stage-node-runtime.mjs @@ -6,6 +6,13 @@ import { fileURLToPath } from 'node:url'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); export const nodeRuntimeSchema = 'agc-node-runtime.v1'; +// 单架构目标写扁平目录;universal 在此目录下按架构分目录,见 stageNodeRuntimeForTarget。 +const defaultRuntimeDestination = path.join( + appRoot, + 'src-tauri', + 'resources', + 'node-runtime', +); export function readInstalledNodeLicense( version, @@ -66,10 +73,116 @@ export function targetRuntime(target) { 'x86_64-apple-darwin': ['darwin', 'x64'], }; const value = targets[target]; - if (!value) throw new Error(`Node 运行时不支持发布目标:${target}`); + // universal 不是单份运行时能表达的目标:它必须按架构展开成两份,见 targetRuntimes。 + if (!value) + throw new Error( + target === 'universal-apple-darwin' + ? 'Node 运行时不能直接按 universal-apple-darwin 制作:双架构请用 stageNodeRuntimeForTarget 按架构展开' + : `Node 运行时不支持发布目标:${target}`, + ); return { platform: value[0], arch: value[1] }; } +/** + * 发布目标需要的随包运行时列表:universal 需要两份单架构运行时, + * 其余目标仍然是一份(保持既有扁平布局与行为)。 + */ +export function targetRuntimes(target) { + if (target === 'universal-apple-darwin') + return ['aarch64-apple-darwin', 'x86_64-apple-darwin']; + return [target]; +} + +/** + * 按架构选择 staging 输入。 + * + * 契约(见实施计划):发布包只从**本机已安装且与目标平台/架构一致**的工具链取材, + * 不得使用项目内或相对 PATH 的伪造运行时。宿主架构直接复用当前 Node; + * 其它架构必须由构建节点显式提供,缺失即失败关闭——不静默跳过、不回退系统 Node。 + */ +export function runtimeSourceForTarget( + archTarget, + { + env = process.env, + nodePath, + npmCli, + licensePath, + hostNodePath = process.execPath, + hostNpmCli = process.env.npm_execpath, + hostLicensePath = env.AGC_NODE_LICENSE_PATH, + hostPlatform = process.platform, + hostArch = process.arch, + } = {}, +) { + const native = targetRuntime(archTarget); + if (native.platform === hostPlatform && native.arch === hostArch) { + return { + nodePath: nodePath ?? hostNodePath, + npmCli: npmCli ?? hostNpmCli, + licensePath: licensePath ?? hostLicensePath, + }; + } + const key = `${native.platform}_${native.arch}`.toUpperCase(); + const configured = env[`AGC_NODE_RUNTIME_${key}_PATH`]?.trim(); + if (!configured) { + throw new Error( + `缺少 ${native.platform}/${native.arch} 的 Node 运行时:该架构不是构建宿主,` + + `请先在本机安装同架构 Node,再用 AGC_NODE_RUNTIME_${key}_PATH 指向其 bin/node`, + ); + } + return { + nodePath: configured, + npmCli: undefined, + licensePath: env[`AGC_NODE_RUNTIME_${key}_LICENSE_PATH`]?.trim(), + }; +} + +/** + * 按发布目标 stage 运行时资源。 + * + * 单架构目标沿用既有扁平目录(`node-runtime/`),universal 目标写进 + * `node-runtime/-/`——与捆绑 Codex 的分架构目录同一形态, + * 由 Rust 侧按当前运行架构选择;构建期资源映射仍是整目录映射,无需按架构分叉。 + * + * 先解析并校验**全部**来源,再逐个落盘:非宿主架构的运行时缺失、平台/架构不符、 + * 两套版本不一致,都在写出任何运行时目录之前失败,不留下半套资源冒充发布内容。 + */ +export function stageNodeRuntimeForTarget( + target, + { destination = defaultRuntimeDestination, ...options } = {}, +) { + const targets = targetRuntimes(target); + const plans = targets.map((archTarget) => { + const native = targetRuntime(archTarget); + return { + destination: + targets.length === 1 + ? destination + : path.join(destination, `${native.platform}-${native.arch}`), + inspected: inspectRuntimeSource(archTarget, { + ...options, + ...runtimeSourceForTarget(archTarget, options), + }), + }; + }); + // 两个切片必须是同一套 Node:版本不一致意味着其中一份被换过, + // 用户在不同架构上会拿到行为不同的工具链。 + const identities = new Set( + plans.map( + ({ inspected }) => + `${inspected.info.version}|${inspected.npmPackage.version}`, + ), + ); + if (identities.size !== 1) { + throw new Error( + `多架构 Node 运行时版本不一致:${[...identities].join(' / ')}`, + ); + } + return plans.map(({ inspected, destination }) => + writeRuntimeBundle(inspected, { destination }), + ); +} + function inside(root, file) { const relative = path.relative(root, file); return ( @@ -212,13 +325,19 @@ export function assertPortableMacNode(output) { } } -export function stageNodeRuntime( +/** + * 只读校验一份运行时来源,返回 staging 需要的全部事实。 + * + * 与写盘分离的原因:多架构发布必须能在写出任何文件之前发现「另一份来源缺失或 + * 与目标不符」。校验口径保持原样——平台/架构必须等于目标、macOS 二进制只能链接 + * 系统动态库、npm 的身份与实际版本必须一致、许可必须来自发行版本体。 + */ +export function inspectRuntimeSource( target, { nodePath = process.execPath, npmCli = process.env.npm_execpath, licensePath = process.env.AGC_NODE_LICENSE_PATH, - destination = path.join(appRoot, 'src-tauri', 'resources', 'node-runtime'), execute = execFileSync, installedLicense = readInstalledNodeLicense, } = {}, @@ -309,6 +428,14 @@ export function stageNodeRuntime( throw new Error('Node LICENSE 不包含发行许可'); if (!fs.statSync(path.join(npmRoot, 'LICENSE')).isFile()) throw new Error('npm 缺少 LICENSE'); + return { native, node, npmRoot, npmPackage, info, license, licenseName }; +} + +/** 把已校验的来源写成一份发布资源,返回清单。 */ +function writeRuntimeBundle( + { native, node, npmRoot, npmPackage, info, license, licenseName }, + { destination }, +) { // 临时同级目录完成后才替换资源;不污染 Node 安装或项目工作区。 const requestedDestination = path.resolve(destination); if (requestedDestination === path.dirname(requestedDestination)) @@ -394,3 +521,26 @@ export function stageNodeRuntime( cleanupStaging(staging, parent, stagingPrefix, stagingIdentity); } } + +export function stageNodeRuntime( + target, + { + nodePath = process.execPath, + npmCli = process.env.npm_execpath, + licensePath = process.env.AGC_NODE_LICENSE_PATH, + destination = defaultRuntimeDestination, + execute = execFileSync, + installedLicense = readInstalledNodeLicense, + } = {}, +) { + return writeRuntimeBundle( + inspectRuntimeSource(target, { + nodePath, + npmCli, + licensePath, + execute, + installedLicense, + }), + { destination }, + ); +} diff --git a/apps/ai-game-creator-shell/scripts/stage-node-runtime.test.mjs b/apps/ai-game-creator-shell/scripts/stage-node-runtime.test.mjs index 8a55c6b3a..23836fe21 100644 --- a/apps/ai-game-creator-shell/scripts/stage-node-runtime.test.mjs +++ b/apps/ai-game-creator-shell/scripts/stage-node-runtime.test.mjs @@ -9,7 +9,9 @@ import { assertPortableMacNode, readInstalledNodeLicense, stageNodeRuntime, + stageNodeRuntimeForTarget, targetRuntime, + targetRuntimes, } from './stage-node-runtime.mjs'; function fixture(run) { @@ -281,7 +283,11 @@ test('native target and macOS dynamic dependency policy reject nonportable Node' platform: 'darwin', arch: 'arm64', }); - assert.throws(() => targetRuntime('universal-apple-darwin'), /不支持/u); + // universal 不是单份运行时目标:必须报出「按架构展开」而不是笼统的「不支持」。 + assert.throws( + () => targetRuntime('universal-apple-darwin'), + /stageNodeRuntimeForTarget/u, + ); assertPortableMacNode( '/node:\n\t/usr/lib/libSystem.B.dylib (compatibility version 1)\n', ); @@ -293,3 +299,146 @@ test('native target and macOS dynamic dependency policy reject nonportable Node' /非系统动态库/u, ); }); + +// 双架构夹具:每个架构一份来源目录,execute 按被查询的二进制回报对应架构。 +function universalFixture(run) { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'agc-node-universal-test-'), + ); + const sourceFor = (arch, nodeVersion) => { + const dir = path.join(root, `source-${arch}`); + const npm = path.join(dir, 'node_modules/npm'); + fs.mkdirSync(path.join(npm, 'bin'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'node'), `node-${arch}`); + fs.writeFileSync( + path.join(dir, 'LICENSE'), + 'Node.js\nPermission is hereby granted', + ); + fs.writeFileSync(path.join(npm, 'LICENSE'), 'npm distribution license'); + fs.writeFileSync( + path.join(npm, 'package.json'), + JSON.stringify({ name: 'npm', version: '11.0.0' }), + ); + for (const name of ['npm', 'npx']) + fs.writeFileSync(path.join(npm, `bin/${name}-cli.js`), `// ${arch}`); + return { + nodePath: path.join(dir, 'node'), + npmCli: path.join(npm, 'bin/npm-cli.js'), + licensePath: path.join(dir, 'LICENSE'), + nodeVersion, + }; + }; + try { + return run(root, { + arm64: sourceFor('arm64', 'v22.23.2'), + x64: sourceFor('x64', 'v22.23.2'), + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function optionsFor(root, sources, overrides = {}) { + const archOf = (file) => (file.includes('source-x64') ? 'x64' : 'arm64'); + return { + destination: path.join(root, 'resources/node-runtime'), + hostPlatform: 'darwin', + hostArch: 'arm64', + hostNodePath: sources.arm64.nodePath, + hostNpmCli: sources.arm64.npmCli, + env: { + AGC_NODE_RUNTIME_DARWIN_X64_PATH: sources.x64.nodePath, + AGC_NODE_RUNTIME_DARWIN_X64_LICENSE_PATH: sources.x64.licensePath, + }, + installedLicense() { + throw new Error('no installed fixture license'); + }, + execute(file, args) { + if (file === '/usr/bin/otool') + return `\t/usr/lib/libSystem.B.dylib\n\t/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation\n`; + if (args[0] === '-p') { + const arch = archOf(file); + return JSON.stringify({ + platform: 'darwin', + arch, + version: sources[arch].nodeVersion, + }); + } + return '11.0.0'; + }, + ...overrides, + }; +} + +test('universal stages one runtime per architecture with architecture-correct manifests', () => + universalFixture((root, sources) => { + assert.deepEqual(targetRuntimes('universal-apple-darwin'), [ + 'aarch64-apple-darwin', + 'x86_64-apple-darwin', + ]); + const manifests = stageNodeRuntimeForTarget( + 'universal-apple-darwin', + optionsFor(root, sources), + ); + assert.deepEqual( + manifests.map((manifest) => `${manifest.platform}-${manifest.arch}`), + ['darwin-arm64', 'darwin-x64'], + ); + for (const manifest of manifests) { + const directory = path.join( + root, + 'resources/node-runtime', + `${manifest.platform}-${manifest.arch}`, + ); + assert.equal( + JSON.parse( + fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'), + ).arch, + manifest.arch, + ); + assert.ok(fs.existsSync(path.join(directory, 'node'))); + assert.ok( + fs.existsSync(path.join(directory, 'node_modules/npm/LICENSE')), + ); + } + // 单架构目标仍写扁平目录(与既有发布一致),不产生分架构子目录。 + const flat = stageNodeRuntimeForTarget( + 'aarch64-apple-darwin', + optionsFor(root, sources, { destination: path.join(root, 'flat') }), + ); + assert.equal(flat.length, 1); + assert.ok(fs.existsSync(path.join(root, 'flat/manifest.json'))); + assert.ok(!fs.existsSync(path.join(root, 'flat/darwin-arm64'))); + })); + +test('universal fails closed when the non-host architecture runtime is absent', () => + universalFixture((root, sources) => { + const options = optionsFor(root, sources, { env: {} }); + assert.throws( + () => stageNodeRuntimeForTarget('universal-apple-darwin', options), + /AGC_NODE_RUNTIME_DARWIN_X64_PATH/u, + ); + // 缺失时不得留下半成品目录。 + assert.ok(!fs.existsSync(path.join(root, 'resources'))); + })); + +test('universal rejects mismatched Node versions between the two architectures', () => + universalFixture((root, sources) => { + const options = optionsFor(root, sources); + options.execute = (file, args) => { + if (file === '/usr/bin/otool') return '\t/usr/lib/libSystem.B.dylib\n'; + if (args[0] === '-p') + return JSON.stringify({ + platform: 'darwin', + arch: file.includes('source-x64') ? 'x64' : 'arm64', + version: file.includes('source-x64') ? 'v22.23.2' : 'v24.0.0', + }); + return '11.0.0'; + }; + assert.throws( + () => stageNodeRuntimeForTarget('universal-apple-darwin', options), + /版本不一致/u, + ); + // 版本核对在写出任何架构之前完成,失败时不留下半套运行时。 + assert.ok(!fs.existsSync(path.join(root, 'resources'))); + })); diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index facfc30f6..c8fdeb0e3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -290,15 +290,37 @@ fn resolve_at( let root = root .canonicalize() .map_err(|_| "project-root-unavailable")?; - if let Some(bundle) = bundle.filter(|bundle| bundle.exists()) { - validate_bundle(bundle)?; - return runtime_from_paths( - &root, - bundle.join(executable_name()), - bundle.join("node_modules/npm/bin/npm-cli.js"), - "bundled", - path, - ); + // 候选顺序:既有扁平布局(单架构构建),其后是按**当前运行架构**命名的子目录 + // (universal 构建把两套运行时并列放进去)。两个候选都要过 manifest 的平台/架构 + // 校验,因此顺序不会让另一架构的运行时被采用;存在但校验失败则失败关闭, + // 绝不因此退回系统 Node。 + let mut bundle_error: Option = None; + if let Some(base) = bundle { + let candidates = [ + base.to_path_buf(), + base.join(format!("{}-{}", native_platform(), native_arch())), + ]; + for candidate in candidates.iter().filter(|candidate| candidate.exists()) { + match validate_bundle(candidate) { + Ok(()) => { + return runtime_from_paths( + &root, + candidate.join(executable_name()), + candidate.join("node_modules/npm/bin/npm-cli.js"), + "bundled", + path, + ); + } + Err(error) => { + if bundle_error.is_none() { + bundle_error = Some(error); + } + } + } + } + } + if let Some(error) = bundle_error { + return Err(error); } if !development { return Err("node-runtime-bundle-missing".into()); @@ -595,6 +617,52 @@ mod tests { ); } + /// universal 构建把两套架构运行时并列放在 `/-/`: + /// 解析必须按当前运行架构选中自己那一份,且另一架构的存在与否不影响结果。 + #[test] + fn universal_bundle_resolves_the_directory_matching_the_running_architecture() { + let project = tempfile::tempdir().unwrap(); + let bundle = tempfile::tempdir().unwrap(); + let current = bundle + .path() + .join(format!("{}-{}", native_platform(), native_arch())); + fs::create_dir_all(¤t).unwrap(); + bundle_fixture(¤t); + + let runtime = resolve_at(project.path(), Some(bundle.path()), false, OsStr::new("")) + .expect("universal bundle must resolve the running architecture"); + assert_eq!(runtime.source, "bundled"); + assert_eq!( + runtime.node.canonicalize().unwrap(), + current.join(executable_name()).canonicalize().unwrap() + ); + + // 另一架构的目录即使损坏也不能影响本架构选择。 + let other = if native_arch() == "arm64" { + "x86_64" + } else { + "arm64" + }; + let other_dir = bundle + .path() + .join(format!("{}-{}", native_platform(), other)); + fs::create_dir_all(&other_dir).unwrap(); + fs::write(other_dir.join("manifest.json"), b"not-json").unwrap(); + assert_eq!( + resolve_at(project.path(), Some(bundle.path()), false, OsStr::new("")) + .unwrap() + .node + .canonicalize() + .unwrap(), + current.join(executable_name()).canonicalize().unwrap() + ); + + // 本架构目录缺失而另一架构完整时,绝不采用另一架构的运行时。 + fs::remove_dir_all(¤t).unwrap(); + bundle_fixture(&other_dir); + assert!(resolve_at(project.path(), Some(bundle.path()), false, OsStr::new("")).is_err()); + } + #[test] fn development_runtime_rejects_relative_and_project_path_entries() { let root = tempfile::tempdir().unwrap();