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..e2fe1b8d1 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,118 @@ 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, + // 明令不使用宿主 npm(`npm_execpath`):另一架构的 npm 必须来自它自己那份发行版 + // 安装目录,否则两份切片的 npm 来源不同源,架构与来源对不上。null 表示"按 node 目录查找"。 + npmCli: null, + 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 +327,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, } = {}, @@ -253,6 +374,7 @@ export function stageNodeRuntime( ); } const nodeDirectory = path.dirname(node); + // `npmCli: null` 表示显式拒绝沿用宿主 npm,只按这份 node 自己的安装目录查找。 const npmCandidates = [ npmCli, path.join(nodeDirectory, 'node_modules/npm/bin/npm-cli.js'), @@ -309,6 +431,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 +524,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..c42ee8754 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,164 @@ 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', + ]); + // 模拟经 `npm run` 触发的构建:npm 把宿主的 npm-cli.js 放进环境变量。 + // 另一架构的切片必须无视它,只能使用自己发行版目录里的 npm。 + const previousNpmExecPath = process.env.npm_execpath; + process.env.npm_execpath = sources.arm64.npmCli; + let manifests; + try { + manifests = stageNodeRuntimeForTarget( + 'universal-apple-darwin', + optionsFor(root, sources), + ); + } finally { + if (previousNpmExecPath === undefined) delete process.env.npm_execpath; + else process.env.npm_execpath = previousNpmExecPath; + } + 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')), + ); + // 每份切片必须自带对应架构发行版的 npm,不能借用宿主那一份。 + assert.equal( + fs.readFileSync( + path.join(directory, 'node_modules/npm/bin/npm-cli.js'), + 'utf8', + ), + `// ${manifest.arch}`, + ); + } + // 单架构目标仍写扁平目录(与既有发布一致),不产生分架构子目录。 + 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(); diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 8fa50c4f1..f07d93a6e 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -102,7 +102,7 @@ - 对象布局:清单固定写成 `agc/-win|mac/latest.json`;安装包与签名写成同一分区的 `/` 与 `.sig`。 - macOS 正式交付使用 universal 主程序:两个平台键指向同一个 `.app.tar.gz` 与签名,一份产物同时服务 Apple Silicon 与 Intel。单架构目标(`aarch64-apple-darwin` / `x86_64-apple-darwin`)只用于本机诊断,不登记正式分区清单——单架构构建不可轮流覆盖同一个 `latest.json` 并宣称双架构均可更新。 -- universal 主程序同时携带分目录的 arm64/x64 原生 Codex 组件:每个组件保持上游单架构布局与独立 SHA-256 清单,运行中的主程序切片只选择同架构目录,不得把两套原生包的元数据或辅助程序混装。 +- universal 主程序同时携带分目录的 arm64/x64 原生 Codex 组件:每个组件保持上游单架构布局与独立 SHA-256 清单,运行中的主程序切片只选择同架构目录,不得把两套原生包的元数据或辅助程序混装。随包 Node 运行时同样按架构分目录(`game-runtime/node/darwin-arm64/`、`darwin-x64/`,各带 `agc-node-runtime.v1` 清单):两套都来自节点上同版本的官方发行版,构建时缺一份或版本不一致即在写出资源前失败,运行切片只采用与当前架构匹配的那一份,单架构构建保持扁平目录不变。 - 构建期要求:打开 `bundle.createUpdaterArtifacts` 以生成 `.sig`;构建环境提供签名私钥与密码(私钥内容不得入库);公钥写入客户端配置。公钥在首个带更新能力的版本发布后不可更换,更换等于放弃自动更新(只能手动重装)。 - 版本递增按渠道及系统分区独立进行:发布脚本读取该分区远端 `latest.json` 的 `version`,与本地版本取较高者递增 patch;不同分区的远端版本互不影响。 - 版本高水位:仅 dev 的 Windows 分区在迁移窗口内取「分区清单版本」与「旧协议迁移指针版本」较大值再递增,避免已发布旧客户端版本倒退。迁移窗口结束(旧指针 404)后只读分区清单;release、自定义渠道与所有 Mac 分区均不参与旧指针比较。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 0657da9a2..bb044b152 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -71,7 +71,7 @@ ### 环境与工作流 -- 客户端交付配套 Node/npm;发布包从本机已安装且与目标平台/架构一致的工具链制作受校验资源,保留许可并校验内容摘要。安装态不依赖系统 PATH 的 Node;开发态可使用已验证的宿主运行时。不得从项目或相对 PATH 加载伪造运行时。 +- 客户端交付配套 Node/npm;发布包从本机已安装且与目标平台/架构一致的工具链制作受校验资源,保留许可并校验内容摘要。安装态不依赖系统 PATH 的 Node;开发态可使用已验证的宿主运行时。不得从项目或相对 PATH 加载伪造运行时。universal 包携带**两套**架构运行时并按架构并列存放(`game-runtime/node/-/`,各自一份清单),运行切片只采用与当前架构一致的那一份;两套必须来自同一 Node 版本,缺一份或版本不一致时构建在写出任何资源之前失败,不能用「少带一份」或退回系统 Node 充数。 - 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。 - 预检不安装依赖、不修改项目 revision、不请求平台生成;构建仍执行项目自己的 npm 脚本。Codex 隔离 HOME 与平台凭据边界保持不变,客户端把已验证的运行时加入执行 PATH,不能把宿主凭据目录交给模型。 - 第一轮先明确本次必需玩法、素材和验收项。同批独立读取尽量合并,必需图片一次规划;已有且可用的资产复用。已有目标全部通过后给出交付结果,非阻塞的新点子列为后续工作,不在收尾时主动开启新的生产链。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 0596085fc..f72e3be9c 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -736,6 +736,23 @@ Job 名为 `Genarrative-Agc-MacOS-Build`,SCM 直接读取仓库内上述 Jenki 发布凭据全部走 Jenkins 全局凭据,并在 `withCredentials` 内注入当前进程:`AgcUpdaterSigningKey`(与 `AgcUpdaterSigningKeyPassword`)映射为 `TAURI_SIGNING_PRIVATE_KEY` / `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,`AliyunAccessKeyId` / `AliyunaccessKeySecret` 映射为 `AGC_OSS_ACCESS_KEY_ID` / `AGC_OSS_ACCESS_KEY_SECRET`;私钥与凭据不写入 workspace、日志或归档产物。上传顺序为更新包、签名、首装包,三者全部成功后才覆盖 `agc/-mac/latest.json` 指针;`AGC_RELEASE_DRY_RUN` 与 Windows 渠道对称:默认关闭即真发布,勾选后才退化为演练(只打印将上传的对象、不写任何 OSS 对象)。本 Job 是正式发布入口,调度器在发号后与 Windows 一起触发它,并额外传 `SKIP_IF_SUPERSEDED=true`——Mac 节点是日常办公机,离线期间排队的旧构建在节点回来后若已被源码分支推进,直接跳过而不发布过期版本。Mac 节点需要 `ossutil`(实测 1.7.19 原生 arm64 可用,装在 `~/.local/bin`,已在 Job 的 PATH 内),可用 `OSSUTIL_BIN` 指定命令名或绝对路径。首次发布建议显式指定 `AGC_RELEASE_VERSION`,避免按渠道高水位递增时出现版本链回退。 +随包 Node 运行时也是节点前置,且 universal 需要**两套架构各一份**:客户端发布包内嵌 Node + npm 作为受校验资源(`agc-node-runtime.v1` 清单 + 逐文件 SHA-256),构建时只从**本机已安装、且与目标平台/架构一致**的工具链取材,缺文件、版本不符或目标不匹配立即失败,禁止回退系统 Node、也不允许从项目或相对 PATH 里借用运行时。宿主架构那一份直接取构建 Node(Mac 节点为 arm64,即 PATH 上的 `node`);另一架构必须由节点显式提供:预装官方 darwin-x64 发行版到 `~/Library/Jenkins/node/darwin-x64`(位置可用 `AGC_NODE_RUNTIME_DARWIN_X64_HOME` 覆盖),保留 `LICENSE` 与 `lib/node_modules/npm`,Job 会把它们导出为 `AGC_NODE_RUNTIME_DARWIN_X64_PATH` / `AGC_NODE_RUNTIME_DARWIN_X64_LICENSE_PATH`。两套必须**同版本**(首次接入时均为 `v22.23.2`):缺一份、架构不符或版本不一致,staging 都会在写出任何运行时目录之前失败关闭,不会生成半套资源的更新包;版本核对同时是 Job 的编译前预检。预装与核对命令(官方 dist,校验 `SHASUMS256.txt`,再确认架构与版本): + +```sh +version=22.23.2 +curl -fsSLO "https://nodejs.org/dist/v${version}/node-v${version}-darwin-x64.tar.gz" +curl -fsSLO "https://nodejs.org/dist/v${version}/SHASUMS256.txt" +grep " node-v${version}-darwin-x64.tar.gz\$" SHASUMS256.txt | shasum -a 256 -c - +mkdir -p "$HOME/Library/Jenkins/node/darwin-x64" +tar -xzf "node-v${version}-darwin-x64.tar.gz" -C "$HOME/Library/Jenkins/node/darwin-x64" --strip-components=1 +/usr/bin/lipo -archs "$HOME/Library/Jenkins/node/darwin-x64/bin/node" # 期望 x86_64 +/usr/bin/arch -x86_64 "$HOME/Library/Jenkins/node/darwin-x64/bin/node" --version # 期望与构建 Node 相同 +``` + +universal 包的随包 Node 按架构并列存放:`Contents/Resources/game-runtime/node/darwin-arm64/` 与 `darwin-x64/`,各自带独立 `manifest.json`;Rust 侧按当前运行架构选择对应目录(两个候选都要过清单的平台/架构校验,存在但损坏则失败关闭,绝不退回系统 Node),架构校验脚本对两份都做完整性核对与 `lipo -archs` 架构核对,只执行与本次 smoke 架构一致的那一份,另一份由另一次架构的 smoke 覆盖。单架构目标仍写扁平目录 `game-runtime/node/`,行为不变。体积代价:官方 Node + npm 解包后约 130 MB/份(实测 arm64 `node` 112.9 MB + npm 16.3 MB,x64 `node` 115.4 MB + npm 15.6 MB),universal 两份合计约 260 MB,DMG 压缩后增量预计在 50 MB 量级——这是双架构必须付的代价,不通过「Mac 跳过随包 Node」来省。 + +同一条规则适用于本机跨架构诊断构建:宿主架构以外的目标(例如 Apple Silicon 上打 `x86_64-apple-darwin`)必须用 `AGC_NODE_RUNTIME___PATH` 指定来源,缺失时报「缺少 / 的 Node 运行时」并给出该变量名,不再退化成含义模糊的「平台/架构不一致」。 + 产物边界:macOS 代码签名与公证暂缺,构建通过剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,**不得使用 `--no-sign`**——该标志会连带跳过 updater 的 minisign 签名,产物缺少 `.sig` 会直接卡在验签门禁(首次实跑即命中该坑)。构建清单按 `codesign -dv` 实测记录 `appleSigned` / `appleSignatureKind`(如 `adhoc`),并固定记录 `notarized=false`。用户首次安装需要在 Gatekeeper 中手动放行;更新包校验本身只依赖 minisign 签名,因此未签名不阻断自动更新的校验环节,但「安装 → 重启接管新版本」的实机闭环仍未验证,不得以构建成功替代。 Job 描述与节点描述是 Jenkins 侧元数据,**不随仓库同步**:Jenkinsfile 只回写参数定义,描述必须手工维护,否则会停留在建 Job 时的口径(2026-09-20 就出现过描述还写着「只构建归档、不签名不上传」,而实际已经是含签名、分区清单、验签与 OSS 上传的发布管线)。当前口径:Job 描述说明「构建 universal → 双架构 smoke → DMG → `-mac` 分区清单 → 更新包验签 → 按 dry-run 决定上传,未做 Apple 签名与公证」;节点描述说明「`genarrative-agc-macos`、EXCLUSIVE 单 executor、仅手动触发、独立 workspace、sccache 在 HOME」。维护手法:该 Job 上 `POST job//config.xml` 会返回 500,节点侧同接口正常;因此改 Job 描述用脚本接口原地更新(保留构建历史),不要为了改描述删 Job 重建。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build index 91bcfdc97..511c0bd42 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -34,6 +34,10 @@ pipeline { CARGO_INCREMENTAL = '0' // 不把节点用户名写进仓库:PATH 在下面的 shell 步骤里按 $HOME 展开。 AGC_EXTRA_PATH = '/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin' + // universal 需要两份**同版本**的架构运行时:宿主(arm64)取自 PATH 里的构建 Node, + // x86_64 由节点预装的官方发行版提供。只写相对 HOME 的目录,绝对路径在 shell 步骤里拼。 + // 节点没有它时构建前即失败,绝不静默跳过或让更新包缺一份运行时。 + AGC_NODE_RUNTIME_DARWIN_X64_HOME = 'Library/Jenkins/node/darwin-x64' } stages { stage('Checkout') { @@ -116,6 +120,24 @@ pipeline { arch -x86_64 /usr/bin/uname -m if command -v sccache >/dev/null 2>&1; then sccache --version; else echo '[agc-macos] 未找到 sccache;本次回退到 rustc 直接构建。'; fi rustup target add aarch64-apple-darwin x86_64-apple-darwin + # 随包 Node 的两套架构运行时:宿主那一份就是 PATH 里的构建 Node, + # x86_64 那一份必须是节点上预装的官方发行版(含 LICENSE 与 lib/node_modules/npm)。 + # 两份版本必须一致,否则 staging 会在写出任何资源前失败关闭;这里提前到编译前暴露。 + x64_root="${AGC_NODE_RUNTIME_DARWIN_X64_HOME:-Library/Jenkins/node/darwin-x64}" + case "$x64_root" in /*) ;; *) x64_root="${HOME:?HOME 不能为空}/$x64_root" ;; esac + if [ ! -x "${x64_root}/bin/node" ]; then + echo "[agc-macos] 缺少 x86_64 Node 运行时:${x64_root}/bin/node" + echo '[agc-macos] 安装官方 darwin-x64 发行版到该目录(保留 LICENSE 与 lib/node_modules/npm)后重试;也可以覆盖 AGC_NODE_RUNTIME_DARWIN_X64_HOME' + exit 1 + fi + host_node_version="$(node --version)" + x64_node_version="$(/usr/bin/arch -x86_64 "${x64_root}/bin/node" --version)" + if [ "$host_node_version" != "$x64_node_version" ]; then + echo "[agc-macos] 两套 Node 运行时版本不一致:arm64=${host_node_version} x86_64=${x64_node_version}" + echo '[agc-macos] universal 更新包只接受同版本的两套运行时,请把节点上的 x86_64 发行版升到与构建 Node 相同版本' + exit 1 + fi + echo "[agc-macos] 随包 Node 运行时:arm64=${host_node_version} x86_64=${x64_node_version}(来源 ${x64_root})" npm ci --no-audit --no-fund node apps/ai-game-creator-shell/scripts/prepare-macos-codex.mjs ''' @@ -143,6 +165,12 @@ pipeline { sh ''' set -eu export PATH="$HOME/.local/bin:$HOME/.cargo/bin:${AGC_EXTRA_PATH}" + # universal staging 需要另一架构的 Node 来源;路径与 Toolchain 阶段同一口径, + # 缺失/版本不一致由 staging 在任何落盘前再次失败关闭。 + x64_root="${AGC_NODE_RUNTIME_DARWIN_X64_HOME:-Library/Jenkins/node/darwin-x64}" + case "$x64_root" in /*) ;; *) x64_root="${HOME:?HOME 不能为空}/$x64_root" ;; esac + export AGC_NODE_RUNTIME_DARWIN_X64_PATH="${x64_root}/bin/node" + export AGC_NODE_RUNTIME_DARWIN_X64_LICENSE_PATH="${x64_root}/LICENSE" echo "[agc-macos] 渠道=${AGC_UPDATE_CHANNEL} 分区=${AGC_UPDATE_CHANNEL}-mac 目标=universal-apple-darwin dry-run=${AGC_RELEASE_DRY_RUN}" ossutil_bin="${OSSUTIL_BIN:-ossutil}" if command -v "${ossutil_bin}" >/dev/null 2>&1; then