diff --git a/.gitignore b/.gitignore index f66e67ded..f34fd1bd3 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,8 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json /apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json /apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-arm64/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-x64/ /plugins/agc-cocos-editor/native/payload/ /plugins/agc-unity-editor/dotnet/**/bin/ /plugins/agc-unity-editor/dotnet/**/obj/ diff --git a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs new file mode 100644 index 000000000..442df54d1 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -0,0 +1,280 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + generateUpdateManifest, + prepareReleaseVersion, + resolveReleaseContext, + resolveReleasePartition, + runTauriBuild, +} from './build-release.mjs'; +import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs'; +import { + readUpdaterPubkey, + verifyUpdaterSignature, +} from './verify-updater-signature.mjs'; + +/** + * AGC macOS 分区(`-mac`)发布入口:构建 universal 包 → 双架构 smoke → 生成 universal DMG + * → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。 + * + * 边界: + * - Apple 签名与公证暂缺:本入口剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,但**不能传 + * `--no-sign`** —— 该标志同时会跳过 updater 的 minisign 签名,产物就没有 `.sig`; + * 未签名 + 未公证必须显式记录而非静默通过; + * - 更新包签名(TAURI_SIGNING_PRIVATE_KEY,minisign)是硬需求:缺了客户端一律拒绝安装, + * 因此构建前要求凭据存在,构建后用内置公钥复核 `.sig` 才允许继续上传; + * - 未通过验签绝不写 OSS:上传顺序为更新包、签名、首装包,全部成功后才覆盖渠道清单指针。 + */ +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = path.resolve(appRoot, '../..'); + +/** + * 产品名只从 Tauri 配置读取:它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。 + * 写死会在改名后让入口静默找错对象(清理、打包、归档三处一起失效)。 + */ +function readProductName() { + const read = (file) => + JSON.parse(fs.readFileSync(path.join(appRoot, 'src-tauri', file), 'utf8')); + const base = read('tauri.conf.json'); + const macosPath = path.join(appRoot, 'src-tauri', 'tauri.macos.conf.json'); + const productName = fs.existsSync(macosPath) + ? (read('tauri.macos.conf.json').productName ?? base.productName) + : base.productName; + assert.ok( + typeof productName === 'string' && productName.trim().length > 0, + 'Tauri 配置缺少 productName', + ); + return productName; +} + +const productName = readProductName(); +const appBundleName = `${productName}.app`; +const updaterArtifactName = `${productName}.app.tar.gz`; +assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行'); +assert.equal( + process.env.JENKINS_URL?.length > 0, + true, + '此入口仅用于 Jenkins 独立工作区', +); +assert.equal( + fs.realpathSync(process.env.WORKSPACE || '.'), + fs.realpathSync(repoRoot), + '必须在 Jenkins workspace 根目录执行', +); +const space = fs.statfsSync(repoRoot); +assert.ok( + space.bavail * space.bsize >= 8 * 1024 ** 3, + '构建前至少需要 8 GiB 可用空间;禁止自动清理开发缓存', +); + +// 仅剥离 Apple 签名/公证变量:本节点没有证书,误用只会让构建失败; +// 更新包签名与 OSS 凭据必须保留,它们是本入口发布能力的组成部分。 +for (const key of Object.keys(process.env)) { + if (/^APPLE_/u.test(key)) delete process.env[key]; +} +assert.ok( + process.env.TAURI_SIGNING_PRIVATE_KEY?.length > 0 || + process.env.TAURI_SIGNING_PRIVATE_KEY_PATH?.length > 0, + '缺少更新包签名私钥(TAURI_SIGNING_PRIVATE_KEY / _PATH):无签名的更新包会被客户端拒绝,禁止继续', +); + +const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev'; +const endpoint = + process.env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com'; +if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) { + throw new Error('OSS bucket 或 endpoint 配置无效'); +} +process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`; +const dryRun = readReleaseDryRun(); + +process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target'); +const context = resolveReleaseContext(['--target=universal-apple-darwin']); +const partition = resolveReleasePartition(context.channel, context.target); +const version = await prepareReleaseVersion(context); +// 首装包名必须保持 `<产品名>_<版本>_universal.dmg`:清单侧按该后缀唯一匹配本次产物。 +const firstInstallName = `${productName}_${version}_universal.dmg`; + +// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则 +// 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中); +// 2) 上一轮遗留的 `.sig` 会让验签门禁把「本轮其实没签」判成通过。 +// 只删本次要写出的确切路径,不动其它版本产物与编译缓存。 +const macosBundle = path.join(context.bundleRoot, 'macos'); +for (const stale of [ + path.join(macosBundle, updaterArtifactName), + path.join(macosBundle, `${updaterArtifactName}.sig`), + path.join(macosBundle, `${firstInstallName}`), + path.join(macosBundle, `${firstInstallName}.sha256`), + path.join(context.bundleRoot, 'latest.json'), + path.join(context.bundleRoot, 'release-notes.txt'), +]) { + fs.rmSync(stale, { force: true }); +} + +const args = [ + '--target=universal-apple-darwin', + '--bundles', + 'app', + '--ci', + // 刻意不传 `--no-sign`:它会连带跳过 updater 签名,而客户端强制校验更新包签名。 + // Apple 侧改为剥离 APPLE_* 凭据,未配置身份时 Tauri 不签名也不失败。 + // 基础配置已开启;这里显式声明,避免被其它配置来源关掉后静默失去更新能力。 + '--config', + '{"bundle":{"createUpdaterArtifacts":true}}', +]; +const command = (binary, argv, options = {}) => + execFileSync(binary, argv, { cwd: repoRoot, stdio: 'inherit', ...options }); +runTauriBuild(args, context); + +const app = path.join(context.bundleRoot, 'macos', appBundleName); +for (const architecture of ['arm64', 'x86_64']) { + command(process.execPath, [ + path.join(appRoot, 'scripts/check-macos-bundle.mjs'), + app, + architecture, + '--universal', + ]); +} + +// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `__universal.dmg`。 +const dmgDirectory = path.join(context.bundleRoot, 'macos'); +fs.mkdirSync(dmgDirectory, { recursive: true }); +const dmg = path.join(dmgDirectory, firstInstallName); +const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-ci-dmg-')); +try { + command('ditto', [app, path.join(stage, appBundleName)]); + fs.symlinkSync('/Applications', path.join(stage, 'Applications')); + command('hdiutil', [ + 'create', + // 前面已删除同名对象;这里再要求显式覆盖,避免残留文件让构建以「文件已存在」失败。 + '-ov', + '-volname', + productName, + '-srcfolder', + stage, + '-format', + 'UDZO', + dmg, + ]); + command('hdiutil', ['verify', dmg]); +} finally { + fs.rmSync(stage, { recursive: true, force: true }); +} + +const release = await generateUpdateManifest(context); +assert.equal( + path.resolve(release.downloadArtifact), + path.resolve(dmg), + '首装包必须锁定本次生成的 universal DMG', +); + +// 上传前门禁:用产物里烘焙的公钥复核更新包签名。验不过就停在这里,绝不写 OSS。 +const signature = verifyUpdaterSignature({ + artifactPath: release.artifact, + signaturePath: `${release.artifact}.sig`, + pubkey: readUpdaterPubkey(), +}); +console.log( + `[agc-macos] 更新包签名校验通过:alg=${signature.algorithm},keyId=${signature.keyId}`, +); + +const artifacts = path.join(repoRoot, 'artifacts'); +// 只清理本 Job 的归档输出,不能把上次 DMG 当成本次成功产物。 +fs.rmSync(artifacts, { recursive: true, force: true }); +fs.mkdirSync(artifacts, { recursive: true }); +const sha256 = (file) => { + const hash = createHash('sha256'); + hash.update(fs.readFileSync(file)); + return hash.digest('hex'); +}; +const dmgHash = sha256(dmg); +fs.writeFileSync(`${dmg}.sha256`, `${dmgHash} ${path.basename(dmg)}\n`); + +const uploadPlan = uploadReleaseArtifacts(release, { + bucket, + endpoint, + binary: process.env.OSSUTIL_BIN?.trim() || 'ossutil', + accessKeyId: process.env.AGC_OSS_ACCESS_KEY_ID?.trim(), + accessKeySecret: process.env.AGC_OSS_ACCESS_KEY_SECRET, + dryRun, +}); + +const archived = [ + dmg, + `${dmg}.sha256`, + release.manifestPath, + release.notesPath, + `${release.artifact}.sig`, +]; +for (const file of archived) { + fs.copyFileSync(file, path.join(artifacts, path.basename(file))); +} + +const commit = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', +}).trim(); +// Apple 签名状态必须实测:剥离 APPLE_* 后 Tauri 通常跳过签名,但节点若装了 Developer ID +// 证书仍可能签上,硬编码 appleSigned=false 会把「其实签了」写成假事实。 +const signatureProbe = spawnSync('codesign', ['-dv', '--verbose=2', app], { + encoding: 'utf8', +}); +const signatureText = `${signatureProbe.stdout ?? ''}${signatureProbe.stderr ?? ''}`; +const appleSigned = /Authority=Developer ID Application/u.test(signatureText); +const appleSignatureKind = appleSigned + ? 'developer-id' + : /Signature=adhoc/u.test(signatureText) + ? 'adhoc' + : 'unsigned'; +fs.writeFileSync( + path.join(artifacts, 'build-manifest.json'), + `${JSON.stringify( + { + version, + commit, + target: context.target, + channel: context.channel, + // Apple 签名与公证暂缺:显式记录为未验证项,不静默通过。 + appleSigned, + appleSignatureKind, + notarized: false, + dryRun, + uploaded: !dryRun, + updaterSignature: { + algorithm: signature.algorithm, + keyId: signature.keyId, + verified: true, + }, + oss: { + bucket, + endpoint, + partition, + latest: `oss://${bucket}/agc/${partition}/latest.json`, + objects: uploadPlan.map(({ destination }) => destination), + }, + artifacts: { + updater: path.basename(release.artifact), + updaterSha256: sha256(release.artifact), + updaterBytes: fs.statSync(release.artifact).size, + updaterSignature: path.basename(`${release.artifact}.sig`), + firstInstall: path.basename(dmg), + firstInstallSha256: dmgHash, + manifest: 'latest.json', + }, + smokes: ['arm64', 'x86_64'], + intelSmoke: process.arch === 'arm64' ? 'Rosetta' : 'native', + }, + null, + 2, + )}\n`, +); +console.log( + dryRun + ? `[agc-macos] dry-run 完成:${partition} 分区产物与清单已生成,未写入 OSS` + : `[agc-macos] ${partition} 分区更新包、签名、首装包与清单已上传 OSS`, +); diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 7c20bbbda..a73383973 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -46,16 +46,12 @@ function explicitBuildTarget(args) { } function validateReleaseTarget(target) { - if (target === 'universal-apple-darwin') { - throw new Error( - '内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin', - ); - } if ( ![ 'x86_64-pc-windows-msvc', 'aarch64-apple-darwin', 'x86_64-apple-darwin', + 'universal-apple-darwin', ].includes(target) ) { throw new Error(`不支持的发布目标:${target}`); @@ -200,10 +196,12 @@ export function updateManifestUrl( } /** - * 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。 + * universal 主程序与双目录原生资源共用一个更新包;单架构只登记实际目标。 */ export function resolveManifestPlatformKeys(target = defaultTarget()) { validateReleaseTarget(target); + if (target === 'universal-apple-darwin') + return ['darwin-aarch64', 'darwin-x86_64']; if (target === 'aarch64-apple-darwin') return ['darwin-aarch64']; if (target === 'x86_64-apple-darwin') return ['darwin-x86_64']; if (target.includes('windows')) { @@ -543,6 +541,18 @@ export function selectFirstInstallArtifact( if (!selected?.endsWith('.exe')) { throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包'); } + } else if (target === 'universal-apple-darwin') { + // universal 主程序只产出一个 DMG,aarch64 与 x86_64 首装共用它(命名见 build-macos-ci.mjs)。 + const suffix = `_${version}_universal.dmg`; + const candidates = files.filter((file) => + path.basename(file).endsWith(suffix), + ); + if (candidates.length !== 1) { + throw new Error( + `首装 DMG 必须唯一匹配本次版本 ${version} 的 universal 产物,找到 ${candidates.length} 个`, + ); + } + selected = candidates[0]; } else { // Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。 const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64'; 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 1dc02ca3d..3f457e0e0 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -45,19 +45,22 @@ const packageVersion = JSON.parse( ).version; function createDmgFixture(root, target, version = packageVersion) { - const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64'; + const architecture = target.startsWith('aarch64') + ? 'aarch64' + : target === universalTarget + ? 'universal' + : 'x64'; const dmg = path.join(root, `陶泥儿_${version}_${architecture}.dmg`); writeFileSync(dmg, 'first installation disk image'); return dmg; } -test('native sidecar builds reject universal targets and accept each macOS architecture', () => { - assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/); - assert.throws( - () => buildTauriBuildArguments(['--target=universal-apple-darwin']), - /单架构/, - ); - for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) { +test('native sidecar builds accept universal and each macOS architecture', () => { + for (const target of [ + universalTarget, + 'aarch64-apple-darwin', + 'x86_64-apple-darwin', + ]) { assert.deepEqual(buildTauriBuildArguments([], target), [ 'build', '--target', @@ -201,8 +204,11 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { }); }); -test('macOS manifests only advertise the architecture actually built', () => { - assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/); +test('macOS manifests advertise exactly the architectures actually built', () => { + assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [ + 'darwin-aarch64', + 'darwin-x86_64', + ]); assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [ 'darwin-aarch64', ]); @@ -246,7 +252,6 @@ test('release context resolves explicit targets before environment/default and f ['--target='], ['--target', '--no-bundle'], ['--target', windowsTarget, '--target=aarch64-apple-darwin'], - ['--target', universalTarget], ['--target', 'unknown'], ]) assert.throws(() => resolveReleaseContext(args, {})); @@ -461,8 +466,8 @@ test('invalid target or platform used as channel fails before any release side e }, }; await assert.rejects( - () => buildRelease(['--target', universalTarget], sideEffects), - /单架构/, + () => buildRelease(['--target', 'unknown'], sideEffects), + /不支持的发布目标/, ); await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () => assert.rejects( @@ -473,6 +478,43 @@ test('invalid target or platform used as channel fails before any release side e assert.equal(touched, false); }); +test('universal uses the Mac channel and the same signed artifact for both architectures', () => { + const context = resolveReleaseContext(['--target', universalTarget], { + AGC_BUILD_TARGET: windowsTarget, + }); + // 渠道本身不含系统:分区由渠道 + 目标推导,二者不能混为一谈。 + assert.equal(context.channel, 'dev'); + assert.equal( + resolveReleasePartition(context.channel, context.target), + 'dev-mac', + ); + assert.ok(context.bundleRoot.includes(universalTarget)); + withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => { + const manifest = createUpdateManifest(artifact, { + ...context, + downloadArtifact: createDmgFixture( + path.dirname(artifact), + universalTarget, + ), + }); + assert.deepEqual(Object.keys(manifest.platforms), [ + 'darwin-aarch64', + 'darwin-x86_64', + ]); + assert.deepEqual( + manifest.platforms['darwin-aarch64'], + manifest.platforms['darwin-x86_64'], + ); + assert.match(manifest.platforms['darwin-aarch64'].url, /\/dev-mac\//); + // 两个平台键共用同一个 universal 首装包,不能要求出两份架构 DMG。 + assert.deepEqual( + manifest.downloads['darwin-aarch64'].url, + manifest.downloads['darwin-x86_64'].url, + ); + assert.match(manifest.downloads['darwin-aarch64'].url, /_universal\.dmg$/u); + }); +}); + test('Windows remains the default and explicit Windows overrides macOS environment', () => { const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg']; for (const context of [ diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index cc77c7b23..98931b12a 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1365,18 +1365,20 @@ if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) { assert.deepEqual( macosTauriConfig.bundle?.resources, Object.fromEntries([ - ...[ - 'bin/codex', - 'bin/codex-code-mode-host', - 'codex-path/rg', - 'codex-resources/zsh/bin/zsh', - 'codex-package.json', - 'NOTICE.md', - 'manifest.json', - ].map((file) => [ - `resources/codex/mac-native/${file}`, - `coding-agent/mac-native/${file}`, - ]), + ...['darwin-arm64', 'darwin-x64'].flatMap((arch) => + [ + 'bin/codex', + 'bin/codex-code-mode-host', + 'codex-path/rg', + 'codex-resources/zsh/bin/zsh', + 'codex-package.json', + 'NOTICE.md', + 'manifest.json', + ].map((file) => [ + `resources/codex/mac-native/${arch}/${file}`, + `coding-agent/mac-native/${arch}/${file}`, + ]), + ), ['resources/plugins', 'plugins'], ]), 'macOS must bundle the complete native Codex layout and plugin workspace', 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 78e38fdd9..a3f60765e 100644 --- a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs +++ b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs @@ -8,6 +8,13 @@ import path from 'node:path'; // 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。 assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行'); const source = path.resolve(process.argv[2] || ''); +const architecture = + process.argv[3] || (process.arch === 'arm64' ? 'arm64' : 'x86_64'); +assert.ok( + ['arm64', 'x86_64'].includes(architecture), + '架构只接受 arm64 / x86_64', +); +const requireUniversal = process.argv.includes('--universal'); assert.ok( source.endsWith('.app') && fs.statSync(source).isDirectory(), '请传入 .app 绝对路径', @@ -15,7 +22,9 @@ assert.ok( const root = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'agc-macos-bundle-')), ); -const app = path.join(root, '陶泥儿 隔离测试.app'); +// 产品名从传入的 .app 推导,不在校验脚本里写死;改名后校验对象仍指向同一个包。 +const appBundleName = path.basename(source); +const app = path.join(root, `隔离-${appBundleName}`); const home = path.join(root, 'home'); const config = path.join(root, 'config'); const tmp = path.join(root, 'tmp'); @@ -31,17 +40,58 @@ const env = { }; function run(command, args) { - const result = spawnSync(command, args, { - cwd: root, - env, - encoding: 'utf8', - timeout: 30_000, - maxBuffer: 1024 * 1024, - }); + // 只强制被测应用切片;本机 Xcode 检查工具可能仅提供宿主架构。 + const useSlice = command.startsWith(`${app}${path.sep}`); + const result = spawnSync( + useSlice ? '/usr/bin/arch' : command, + useSlice ? [`-${architecture}`, command, ...args] : args, + { + cwd: root, + env, + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 1024 * 1024, + }, + ); assert.ifError(result.error); return result; } +/** + * APFS 上优先用 `ditto --clone`:整包按区块克隆,秒级完成且几乎不占额外空间。 + * 跨卷或非 APFS 时回退到真实复制;两种路径都必须产出可独立改动的副本, + * 因为「缺组件拒绝」用例会在副本里改名文件。 + */ +function copyBundle(from, to) { + const cloned = spawnSync('/usr/bin/ditto', ['--clone', from, to], { + encoding: 'utf8', + }); + if ( + cloned.status === 0 && + fs.existsSync(path.join(to, 'Contents/Info.plist')) + ) { + return 'clone'; + } + fs.cpSync(from, to, { recursive: true }); + return 'copy'; +} + +/** 可执行名以包内 Info.plist 为准:它是稳定契约,但没必要在校验脚本里重复硬编码。 */ +function readBundleExecutable(appPath) { + const plist = path.join(appPath, 'Contents/Info.plist'); + const result = spawnSync( + '/usr/libexec/PlistBuddy', + ['-c', 'Print :CFBundleExecutable', plist], + { encoding: 'utf8' }, + ); + const name = (result.stdout ?? '').trim(); + assert.ok( + name.length > 0, + `无法从 Info.plist 读取 CFBundleExecutable:${plist}`, + ); + return name; +} + async function hashFile(file) { const hash = createHash('sha256'); for await (const chunk of fs.createReadStream(file)) hash.update(chunk); @@ -60,7 +110,7 @@ async function handshake(executable) { await new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error('app-server 初始化超时')), - 15_000, + 120_000, ); const finish = (error) => { clearTimeout(timer); @@ -127,22 +177,38 @@ async function handshake(executable) { } try { - fs.cpSync(source, app, { recursive: true }); + const copiedWith = copyBundle(source, app); const resources = path.join(app, 'Contents/Resources'); - const bundle = path.join(resources, 'coding-agent/mac-native'); + const platform = architecture === 'arm64' ? 'darwin-arm64' : 'darwin-x64'; + const bundle = path.join(resources, 'coding-agent/mac-native', platform); const executable = path.join(bundle, 'bin/codex'); - const main = path.join( - app, - 'Contents/MacOS/genarrative-ai-game-creator-shell', - ); + const main = path.join(app, 'Contents/MacOS', readBundleExecutable(app)); + const mainArchitectures = run('/usr/bin/lipo', ['-archs', main]); + assert.equal(mainArchitectures.status, 0); + assert.ok(mainArchitectures.stdout.split(/\s+/).includes(architecture)); + if (requireUniversal) { + assert.deepEqual(mainArchitectures.stdout.trim().split(/\s+/).sort(), [ + 'arm64', + 'x86_64', + ]); + for (const platform of ['darwin-arm64', 'darwin-x64']) { + assert.ok( + fs.existsSync( + path.join( + resources, + 'coding-agent/mac-native', + platform, + 'manifest.json', + ), + ), + ); + } + } const manifest = JSON.parse( fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'), ); assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2'); - assert.equal( - manifest.platform, - process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64', - ); + assert.equal(manifest.platform, platform); assert.equal(manifest.version, 'codex-cli 0.147.0'); const components = [ 'bin/codex', @@ -159,11 +225,7 @@ try { fs.accessSync(file, fs.constants.X_OK); const arch = run('/usr/bin/lipo', ['-archs', file]); assert.equal(arch.status, 0, component); - assert.equal( - arch.stdout.trim(), - process.arch === 'arm64' ? 'arm64' : 'x86_64', - component, - ); + assert.equal(arch.stdout.trim(), architecture, component); } } assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md'))); @@ -212,7 +274,7 @@ try { assert.notEqual(broken.status, 0); assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/); console.log( - 'PASS: 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝', + `PASS (${architecture}, 副本=${copiedWith}): 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝`, ); console.log( '未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提', diff --git a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.mjs b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.mjs new file mode 100644 index 000000000..6335fda74 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = path.resolve(appRoot, '../..'); +const platforms = { + arm64: 'aarch64-apple-darwin', + x64: 'x86_64-apple-darwin', +}; + +export function lockedMacPackage(lock, arch, version) { + assert.ok(Object.hasOwn(platforms, arch), '未知 macOS 架构'); + const alias = `@openai/codex-darwin-${arch}`; + const entry = lock.packages?.[`node_modules/${alias}`]; + assert.equal( + entry?.version, + `${version}-darwin-${arch}`, + '原生依赖必须与应用锁定版本一致', + ); + assert.deepEqual(entry.os, ['darwin']); + assert.deepEqual(entry.cpu, [arch]); + const url = new URL(entry.resolved); + assert.equal(url.protocol, 'https:'); + assert.equal( + url.hostname, + 'registry.npmjs.org', + '只下载锁定的官方 npm 原生包', + ); + assert.equal(url.username + url.password + url.search + url.hash, ''); + assert.match(entry.integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/); + return { alias, target: platforms[arch], ...entry }; +} + +export function verifyPackageIntegrity(bytes, expected) { + const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; + assert.equal(actual, expected, 'Codex 下载包 lockfile integrity 不匹配'); +} + +export function validateArchiveListing(listing) { + const files = listing.trim().split(/\r?\n/u); + assert.ok(files.length > 0); + for (const file of files) { + assert.ok(file.startsWith('package/'), '原生包必须只有 package 根目录'); + assert.ok( + !file.split('/').includes('..') && !file.includes('\\'), + '压缩包路径不安全', + ); + } +} + +export async function prepareMacosCodex() { + assert.equal(process.platform, 'darwin', '该入口仅用于 macOS 构建机'); + const lock = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8'), + ); + const app = JSON.parse( + fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'), + ); + const version = app.devDependencies['@openai/codex']; + assert.match(version, /^\d+\.\d+\.\d+$/u, 'Codex 必须锁定精确版本'); + const cache = path.join(appRoot, 'src-tauri/target/.macos-native-cache'); + fs.mkdirSync(cache, { recursive: true }); + for (const arch of Object.keys(platforms)) { + const entry = lockedMacPackage(lock, arch, version); + const archive = path.join(cache, `codex-${entry.version}.tgz`); + if (!fs.existsSync(archive)) { + const response = await fetch(entry.resolved, { + signal: AbortSignal.timeout(300_000), + }); + assert.ok(response.ok, `原生包下载失败 HTTP ${response.status}`); + const bytes = Buffer.from(await response.arrayBuffer()); + verifyPackageIntegrity(bytes, entry.integrity); + const partial = `${archive}.${process.pid}.tmp`; + fs.writeFileSync(partial, bytes); + fs.renameSync(partial, archive); + } + verifyPackageIntegrity(fs.readFileSync(archive), entry.integrity); + validateArchiveListing( + execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' }), + ); + // 拒绝链接、设备及其它特殊条目,不能让 tar 在包目录之外写入。 + const entries = execFileSync('tar', ['-tvzf', archive], { + encoding: 'utf8', + }); + assert.ok( + entries + .trim() + .split(/\r?\n/u) + .every((line) => /^[-d]/u.test(line)), + '原生包禁止链接或特殊文件', + ); + const parent = path.join(repoRoot, 'node_modules/@openai'); + fs.mkdirSync(parent, { recursive: true }); + const stage = fs.mkdtempSync(path.join(parent, '.mac-native-')); + try { + execFileSync( + 'tar', + ['-xzf', archive, '-C', stage, '--strip-components=1'], + { stdio: 'pipe' }, + ); + const metadata = JSON.parse( + fs.readFileSync( + path.join(stage, 'vendor', entry.target, 'codex-package.json'), + 'utf8', + ), + ); + assert.equal(metadata.version, version); + assert.equal(metadata.target, entry.target); + assert.equal(metadata.entrypoint, 'bin/codex'); + const destination = path.join(repoRoot, 'node_modules', entry.alias); + assert.ok( + !fs.existsSync(destination) || + !fs.lstatSync(destination).isSymbolicLink(), + '拒绝覆盖链接依赖', + ); + fs.rmSync(destination, { recursive: true, force: true }); + fs.renameSync(stage, destination); + } finally { + fs.rmSync(stage, { recursive: true, force: true }); + } + console.log(`[macOS Codex] ${entry.version}: lockfile integrity 已验证`); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + await prepareMacosCodex(); +} diff --git a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs new file mode 100644 index 000000000..4caae3656 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import { test } from 'node:test'; + +import { + lockedMacPackage, + validateArchiveListing, + verifyPackageIntegrity, +} from './prepare-macos-codex.mjs'; + +const lock = JSON.parse( + fs.readFileSync(new URL('../../../package-lock.json', import.meta.url)), +); +const version = JSON.parse( + fs.readFileSync(new URL('../package.json', import.meta.url)), +).devDependencies['@openai/codex']; + +test('both macOS dependencies resolve from the lockfile without floating versions', () => { + assert.equal( + lockedMacPackage(lock, 'arm64', version).target, + 'aarch64-apple-darwin', + ); + assert.equal( + lockedMacPackage(lock, 'x64', version).target, + 'x86_64-apple-darwin', + ); + assert.throws(() => lockedMacPackage(lock, 'other', version)); + assert.throws(() => lockedMacPackage(lock, 'x64', '0.0.0')); +}); + +test('native package integrity rejects tampering', () => { + const bytes = Buffer.from('pinned package'); + const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; + verifyPackageIntegrity(bytes, integrity); + assert.throws(() => + verifyPackageIntegrity(Buffer.from('modified'), integrity), + ); +}); + +test('archive traversal and non-package entries fail closed', () => { + validateArchiveListing( + 'package/package.json\npackage/vendor/target/bin/codex\n', + ); + for (const listing of [ + '', + '/tmp/payload', + 'package/../private', + 'other/file', + 'package/..\\file', + ]) { + assert.throws(() => validateArchiveListing(listing)); + } +}); + +test('CI pipeline is manual, publishes the macOS partition and never reuses a developer workspace', () => { + const pipeline = fs.readFileSync( + new URL( + '../../../jenkins/Jenkinsfile.ai-game-creator-shell-macos-build', + import.meta.url, + ), + 'utf8', + ); + for (const required of [ + 'genarrative-agc-macos', + 'disableConcurrentBuilds()', + '$AGC_AGENT_ROOT', + 'StrictHostKeyChecking=yes', + 'git merge-base --is-ancestor', + 'allowEmptyArchive: false', + "string(name: 'AGC_UPDATE_CHANNEL', defaultValue: 'dev'", + 'AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}', + "string(credentialsId: 'AgcUpdaterSigningKey'", + "string(credentialsId: 'AgcUpdaterSigningKeyPassword'", + "string(credentialsId: 'AliyunAccessKeyId'", + "string(credentialsId: 'AliyunaccessKeySecret'", + 'AGC_RELEASE_VERSION', + 'OSSUTIL_BIN', + // 并行度必须可调:节点是共用机器,写死容易把整机压满或反过来浪费一半核心。 + "string(name: 'CARGO_BUILD_JOBS', defaultValue: '8'", + 'CARGO_BUILD_JOBS=${params.CARGO_BUILD_JOBS}', + // Agent 工作区按约定匹配,不写死节点名:节点改名(-local → -01)后守卫仍成立。 + '"$HOME"/Library/Jenkins/agents/*/workspace/*', + // 上一次发布的 commit 落在 master 上,取到它更新摘要才不会退化成「最近提交」。 + 'refs/heads/master:refs/remotes/origin/master', + ]) { + assert.ok(pipeline.includes(required), required); + } + assert.ok( + !pipeline.includes('genarrative-agc-macos-local'), + 'Jenkinsfile 不得写死具体节点名', + ); + // 这条管线是正式发布入口(与 Windows 对称):默认真发布,演练需显式勾选。 + assert.match( + pipeline, + /booleanParam\(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false/u, + 'Channel 发布默认必须是真发布,演练只能显式勾选', + ); + // 节点是办公机:离线期间排队的旧构建必须自行让位,且跳过要覆盖后续全部阶段。 + assert.match( + pipeline, + /booleanParam\(name: 'SKIP_IF_SUPERSEDED', defaultValue: false/u, + ); + // 仓库文件不得出现节点用户名/个人 Home 路径:换机或改名后必须仍然可用。 + assert.ok( + !pipeline.includes('/Users/'), + 'Jenkinsfile 不得写死个人 Home 路径,工具链位置应按 $HOME 展开', + ); + assert.ok( + pipeline.includes('export PATH="$HOME/'), + 'PATH 必须在 shell 步骤里按 $HOME 展开', + ); + // 超时必须高于实测最慢(78 分钟冷构建 + 共用机器),否则会被中断在链接阶段。 + assert.ok( + pipeline.includes('timeout(time: 150'), + '构建超时上限必须留出冷构建余量', + ); + for (const diagnostic of ['macOS 发布失败', '被中断']) { + assert.ok(pipeline.includes(diagnostic), diagnostic); + } + assert.ok( + pipeline.includes('.jenkins-superseded-by'), + '必须记录被推进的标记供后续阶段判定', + ); + assert.equal( + (pipeline.match(/env\.AGC_BUILD_SUPERSEDED != 'true'/gu) ?? []).length, + 3, + 'Toolchain / Package / Archive 三个阶段都必须按跳过标记收口', + ); + for (const forbidden of [ + 'triggers {', + 'cron(', + 'pollSCM(', + 'git clean -fdx', + // release:upload 会重新触发一次完整构建,既翻倍耗时也绕过本 Job 的验签门禁。 + 'release:upload', + ]) { + assert.ok(!pipeline.includes(forbidden), forbidden); + } +}); + +test('macOS release entry verifies the updater signature before uploading', () => { + const entry = fs.readFileSync( + new URL('./build-macos-ci.mjs', import.meta.url), + 'utf8', + ); + const verifyIndex = entry.indexOf('verifyUpdaterSignature({'); + const uploadIndex = entry.indexOf('uploadReleaseArtifacts(release'); + assert.ok(verifyIndex > 0, '必须调用更新包验签'); + assert.ok(uploadIndex > 0, '必须调用 OSS 上传'); + assert.ok(verifyIndex < uploadIndex, '必须先验签再上传,验不过不得写 OSS'); + // 无签名私钥时禁止构建:未签名的更新包会被客户端一律拒绝。 + assert.ok(entry.includes('TAURI_SIGNING_PRIVATE_KEY')); + // `--no-sign` 会连带跳过 updater 的 minisign 签名,产物将没有 .sig,入口不得传它。 + assert.ok( + !entry.includes("'--no-sign'"), + '--no-sign 会同时跳过 updater 签名,产物缺少 .sig', + ); + // workspace 会跨构建保留产物:必须先删本次要写的对象,否则会因同名 DMG 失败, + // 或让上一轮遗留的 .sig 让验签门禁误通过。 + for (const required of [ + // 清理对象用派生的产品名算出来,而不是写死某个名字。 + '${updaterArtifactName}.sig', + '${firstInstallName}.sha256', + 'fs.rmSync(stale, { force: true })', + "'-ov'", + ]) { + assert.ok(entry.includes(required), required); + } +}); + +test('macOS release entry and smoke script derive product names from config and the bundle', () => { + const entry = fs.readFileSync( + new URL('./build-macos-ci.mjs', import.meta.url), + 'utf8', + ); + // 产品名决定 *.app、updater 归档与 DMG 卷名:写死会在改名后静默找错对象。 + assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名'); + assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名'); + assert.ok( + entry.includes('_${version}_universal.dmg'), + '首装包名必须保留清单侧唯一匹配所需的后缀', + ); + + const smoke = fs.readFileSync( + new URL('./check-macos-bundle.mjs', import.meta.url), + 'utf8', + ); + assert.ok(!smoke.includes('陶泥儿'), '校验脚本不得写死产品名'); + for (const required of [ + 'path.basename(source)', + 'Print :CFBundleExecutable', + "'--clone'", + ]) { + assert.ok(smoke.includes(required), required); + } +}); diff --git a/apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs b/apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs new file mode 100644 index 000000000..c55671369 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs @@ -0,0 +1,177 @@ +import { + createHash, + createPublicKey, + verify as cryptoVerify, +} from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * 更新包签名门禁:用产物里烘焙的 updater 公钥校验 `.sig`, + * 防止「发布出去的更新包没人装得上」——客户端校验失败会直接拒绝安装, + * 而且公钥发布后不可更换,所以必须在构建期、上传前就失败关闭。 + * + * 格式说明(与 Tauri 2 的实际产出对齐,均为实测): + * - `tauri.conf.json` 的 `plugins.updater.pubkey` 是「minisign 公钥文本」的 base64; + * - 产物旁的 `.sig` 是「minisign 签名文本」的 base64; + * - 公钥 blob 42 字节(alg `Ed` + 8 字节 keyId + 32 字节 Ed25519 公钥); + * - 签名 blob 74 字节(alg `Ed` 或 `ED` + 8 字节 keyId + 64 字节签名); + * - Tauri 产出的是 `ED`:先对文件做 BLAKE2b-512,再对摘要做 Ed25519 签名。 + */ +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const defaultTauriConfigPath = path.join(appRoot, 'src-tauri/tauri.conf.json'); +const defaultMacosConfigPath = path.join( + appRoot, + 'src-tauri/tauri.macos.conf.json', +); + +const PUBLIC_KEY_ALGORITHM = 'Ed'; +const RAW_ALGORITHM = 'Ed'; +const PREHASHED_ALGORITHM = 'ED'; + +function unwrapMinisignText(value, label) { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`${label} 为空`); + } + const trimmed = value.trim(); + if (trimmed.startsWith('untrusted comment:')) return trimmed; + const decoded = Buffer.from(trimmed, 'base64').toString('utf8'); + if (!decoded.startsWith('untrusted comment:')) { + throw new Error(`${label} 不是 minisign 内容(缺少 untrusted comment 头)`); + } + return decoded; +} + +function contentLines(text) { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +/** 解析 updater 公钥(`tauri.conf.json` 里的 base64 值或 minisign 文本)。 */ +export function decodeUpdaterPublicKey(value, label = 'updater 公钥') { + const lines = contentLines(unwrapMinisignText(value, label)); + if (lines.length < 2) throw new Error(`${label} 缺少密钥内容行`); + const blob = Buffer.from(lines[1], 'base64'); + if (blob.length !== 42) { + throw new Error( + `${label} 长度异常:期望 42 字节,实际 ${blob.length} 字节`, + ); + } + const algorithm = blob.subarray(0, 2).toString('latin1'); + if (algorithm !== PUBLIC_KEY_ALGORITHM) { + throw new Error(`${label} 算法不受支持:${algorithm}`); + } + return { algorithm, keyId: blob.subarray(2, 10), key: blob.subarray(10) }; +} + +/** 解析 `.sig`(base64 值或 minisign 文本)。 */ +export function decodeUpdaterSignature(value, label = '更新包签名') { + const lines = contentLines(unwrapMinisignText(value, label)); + if (lines.length < 2) throw new Error(`${label} 缺少签名内容行`); + const blob = Buffer.from(lines[1], 'base64'); + if (blob.length !== 74) { + throw new Error( + `${label} 长度异常:期望 74 字节,实际 ${blob.length} 字节`, + ); + } + const algorithm = blob.subarray(0, 2).toString('latin1'); + if (algorithm !== RAW_ALGORITHM && algorithm !== PREHASHED_ALGORITHM) { + throw new Error(`${label} 算法不受支持:${algorithm}`); + } + return { + algorithm, + keyId: blob.subarray(2, 10), + signature: blob.subarray(10), + trustedComment: lines[2] ?? '', + }; +} + +function publicKeyObject(rawKey) { + return createPublicKey({ + key: { kty: 'OKP', crv: 'Ed25519', x: rawKey.toString('base64url') }, + format: 'jwk', + }); +} + +/** + * 校验更新包签名;任何不一致都抛错(调用方据此失败关闭)。 + */ +export function verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey, +}) { + const publicKey = decodeUpdaterPublicKey(pubkey); + const signature = decodeUpdaterSignature( + fs.readFileSync(signaturePath, 'utf8'), + ); + if (!publicKey.keyId.equals(signature.keyId)) { + throw new Error( + `更新包签名与内置公钥的 keyId 不一致:公钥 ${publicKey.keyId.toString('hex')},签名 ${signature.keyId.toString('hex')};` + + '签名私钥与产物内烘焙的公钥不是同一对,发布后客户端会拒绝安装', + ); + } + const payload = fs.readFileSync(artifactPath); + const message = + signature.algorithm === PREHASHED_ALGORITHM + ? createHash('blake2b512').update(payload).digest() + : payload; + if ( + !cryptoVerify( + null, + message, + publicKeyObject(publicKey.key), + signature.signature, + ) + ) { + throw new Error( + `更新包签名校验失败:${path.basename(artifactPath)};该产物无法被客户端接受`, + ); + } + return { + algorithm: signature.algorithm, + keyId: publicKey.keyId.toString('hex'), + trustedComment: signature.trustedComment, + }; +} + +/** + * 读取该平台生效的 updater 公钥:macOS 配置可覆盖基础配置,与构建期行为一致。 + */ +export function readUpdaterPubkey({ + configPath = defaultTauriConfigPath, + platformConfigPath = defaultMacosConfigPath, +} = {}) { + const readPubkey = (file) => { + if (!fs.existsSync(file)) return null; + const config = JSON.parse(fs.readFileSync(file, 'utf8')); + return config?.plugins?.updater?.pubkey ?? null; + }; + const pubkey = readPubkey(platformConfigPath) ?? readPubkey(configPath); + if (!pubkey) throw new Error('未在 Tauri 配置中找到 plugins.updater.pubkey'); + return pubkey; +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const [artifactPath, signaturePath = `${artifactPath}.sig`] = + process.argv.slice(2); + if (!artifactPath) { + throw new Error( + '用法:node verify-updater-signature.mjs <更新包> [<签名文件>]', + ); + } + const result = verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: readUpdaterPubkey(), + }); + console.log( + `[agc-macos] 更新包签名校验通过:${path.basename(artifactPath)}(alg=${result.algorithm},keyId=${result.keyId})`, + ); +} diff --git a/apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs b/apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs new file mode 100644 index 000000000..c30ecc710 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict'; +import { + createHash, + generateKeyPairSync, + randomBytes, + sign as cryptoSign, +} from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + decodeUpdaterPublicKey, + decodeUpdaterSignature, + readUpdaterPubkey, + verifyUpdaterSignature, +} from './verify-updater-signature.mjs'; + +/** + * 用进程内生成的 Ed25519 密钥自造 minisign 结构, + * 覆盖 Tauri 实际使用的 `ED`(BLAKE2b-512 预哈希)与 `Ed`(原文)两种模式。 + */ +function createKeyMaterial() { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const rawKey = Buffer.from( + publicKey.export({ format: 'jwk' }).x, + 'base64url', + ); + const keyId = randomBytes(8); + const pubkey = Buffer.from( + `untrusted comment: minisign public key: ${keyId.reverse().toString('hex').toUpperCase()}\n` + + `${Buffer.concat([Buffer.from('Ed'), keyId, rawKey]).toString('base64')}\n`, + ).toString('base64'); + return { privateKey, keyId, rawKey, pubkey }; +} + +function signFixture({ privateKey, keyId }, payload, algorithm) { + const message = + algorithm === 'ED' + ? createHash('blake2b512').update(payload).digest() + : payload; + const signature = cryptoSign(null, message, privateKey); + const blob = Buffer.concat([Buffer.from(algorithm), keyId, signature]); + const globalSignature = cryptoSign(null, blob, privateKey); + return Buffer.from( + 'untrusted comment: signature from tauri secret key\n' + + `${blob.toString('base64')}\n` + + 'trusted comment: timestamp:0\tfile:fixture\n' + + `${globalSignature.toString('base64')}\n`, + ).toString('base64'); +} + +function withFixture(run) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-sig-test-')); + try { + const artifactPath = path.join(directory, 'app.app.tar.gz'); + fs.writeFileSync(artifactPath, 'update payload'); + return run({ directory, artifactPath }); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +test('接受 Tauri 实际使用的 ED(BLAKE2b-512 预哈希)签名', () => { + withFixture(({ directory, artifactPath }) => { + const material = createKeyMaterial(); + const signaturePath = path.join(directory, 'app.app.tar.gz.sig'); + fs.writeFileSync( + signaturePath, + signFixture(material, fs.readFileSync(artifactPath), 'ED'), + ); + const result = verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: material.pubkey, + }); + assert.equal(result.algorithm, 'ED'); + assert.equal(result.keyId, material.keyId.toString('hex')); + }); +}); + +test('接受原文 Ed 签名,两种算法互不通用', () => { + withFixture(({ directory, artifactPath }) => { + const material = createKeyMaterial(); + const payload = fs.readFileSync(artifactPath); + const signaturePath = path.join(directory, 'app.app.tar.gz.sig'); + fs.writeFileSync(signaturePath, signFixture(material, payload, 'Ed')); + assert.equal( + verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: material.pubkey, + }).algorithm, + 'Ed', + ); + // 原文模式下签名的是别的载荷时必须失败:证明确实在校验内容而非只看结构。 + fs.writeFileSync( + signaturePath, + signFixture(material, Buffer.from('别的载荷'), 'Ed'), + ); + assert.throws( + () => + verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: material.pubkey, + }), + /签名校验失败/u, + ); + }); +}); + +test('产物被篡改时失败关闭', () => { + withFixture(({ directory, artifactPath }) => { + const material = createKeyMaterial(); + const signaturePath = path.join(directory, 'app.app.tar.gz.sig'); + fs.writeFileSync( + signaturePath, + signFixture(material, fs.readFileSync(artifactPath), 'ED'), + ); + fs.writeFileSync(artifactPath, 'tampered payload'); + assert.throws( + () => + verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: material.pubkey, + }), + /签名校验失败/u, + ); + }); +}); + +test('签名私钥与内置公钥不是同一对时给出明确错误', () => { + withFixture(({ directory, artifactPath }) => { + const signing = createKeyMaterial(); + const baked = createKeyMaterial(); + const signaturePath = path.join(directory, 'app.app.tar.gz.sig'); + fs.writeFileSync( + signaturePath, + signFixture(signing, fs.readFileSync(artifactPath), 'ED'), + ); + assert.throws( + () => + verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: baked.pubkey, + }), + /keyId 不一致/u, + ); + }); +}); + +test('公钥或签名格式非法时拒绝解析', () => { + assert.throws(() => decodeUpdaterPublicKey(''), /为空/u); + assert.throws( + () => decodeUpdaterPublicKey('bm90IGEgbWluaXNpZ24ga2V5'), + /不是 minisign 内容/u, + ); + assert.throws( + () => + decodeUpdaterPublicKey( + Buffer.from('untrusted comment: x\nAAAA\n').toString('base64'), + ), + /长度异常/u, + ); + assert.throws( + () => + decodeUpdaterSignature( + Buffer.from('untrusted comment: x\nAAAA\n').toString('base64'), + ), + /长度异常/u, + ); +}); + +test('仓库里配置的 updater 公钥可被解析(两平台共用)', () => { + const decoded = decodeUpdaterPublicKey(readUpdaterPubkey()); + assert.equal(decoded.algorithm, 'Ed'); + assert.equal(decoded.key.length, 32); +}); diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 2cc8e48ec..be4436c86 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -33,7 +33,23 @@ fn sha256_file(path: &std::path::Path) -> Result { fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { let target = env::var("TARGET").expect("Cargo TARGET"); println!("cargo:rustc-env=AGC_BUILD_TARGET={target}"); - let Some(layout) = codex_bundle::for_target(&target) else { + if target.contains("apple-darwin") { + // Tauri 的 universal 两次 Cargo 编译共用 resource staging, + // 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。 + let staging = manifest_dir.join("resources/codex/mac-native"); + if staging.exists() { + fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败"); + } + for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] { + stage_codex_target(manifest_dir, target); + } + } else { + stage_codex_target(manifest_dir, &target); + } +} + +fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { + let Some(layout) = codex_bundle::for_target(target) else { assert!( !target.contains("windows") && !target.contains("apple-darwin"), "不支持的 Codex 随包目标:{target}" @@ -83,7 +99,7 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"), ) .expect("Codex 原生包元数据无效"); - codex_bundle::validate_package_metadata(&metadata, &target, layout) + codex_bundle::validate_package_metadata(&metadata, target, layout) .unwrap_or_else(|error| panic!("{error}")); let target_dir = manifest_dir.join("resources/codex").join(layout.directory); let notice = target_dir.join("NOTICE.md"); diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs index 1811f6a25..8abb36294 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs @@ -49,7 +49,11 @@ pub fn for_target(target: &str) -> Option { } else { "codex-darwin-x64" }, - directory: "mac-native", + directory: if target.starts_with("aarch64") { + "mac-native/darwin-arm64" + } else { + "mac-native/darwin-x64" + }, executable: "bin/codex", files: MAC_FILES, }), @@ -90,6 +94,9 @@ mod tests { let intel = for_target("x86_64-apple-darwin").unwrap(); assert_eq!(intel.platform, "darwin-x64"); assert_eq!(intel.npm_package, "codex-darwin-x64"); + assert_eq!(mac.directory, "mac-native/darwin-arm64"); + assert_eq!(intel.directory, "mac-native/darwin-x64"); + assert_ne!(mac.directory, intel.directory); let windows = for_target("x86_64-pc-windows-msvc").unwrap(); assert_eq!(windows.directory, "win-x64"); assert_eq!(windows.files.len(), 6); diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index f7b1e897d..0fd3f96d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -29,7 +29,11 @@ use crate::agent::{ write_agent_runtime_json_sidecar_with_max_bytes, PlatformArtAssetGenerationOptions, }; use crate::commands::prepare_local_project_asset_generation; -use crate::project::{enforce_project_permission_policy, read_existing_manifest_for_project}; +use crate::project::{ + enforce_project_permission_policy, prepare_local_project_audio_generation, + read_existing_manifest_for_project, run_local_project_audio_generation_at, + LocalProjectAudioGenerationRequest, LocalProjectResourceEditKind, +}; use shared_contracts::game_creation_app::GameCreationAppAssetKind; pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; @@ -63,6 +67,8 @@ const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str = "应用退出时生成任务仍在进行,目标素材未登记"; const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str = "应用退出时生成任务仍在进行,未能在清单里确认结果"; +/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。 +const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材"; /// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。 #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] @@ -336,6 +342,21 @@ fn remove_live_task_id(task_id: &str) { } } +/// 登记一条「本进程正在推的任务」,返回 true 表示这次确实插入了新的 id。 +/// +/// 顺序是硬约束:**先登记 live 再落账本**。`list` 只把「非终态且不 live」的记录判为上次运行的 +/// 残留,反过来先落账本就会留出一个窗口——并发 `list` 会在窗口里把刚排队的任务收口成失败, +/// 前端随即看到一条本不存在的失败记录。 +/// +/// 返回值专给「落账失败要回滚」用:只有真插入过的一方才有资格回滚,否则会把**同 id 那个正在 +/// 运行的任务**的 live 登记一起删掉(随后 `list` 就会把它谎报成上次运行的中断残留)。 +fn register_live_task_id(task_id: &str) -> bool { + live_task_ids() + .lock() + .map(|mut ids| ids.insert(task_id.to_string())) + .unwrap_or(false) +} + /// 后台执行:状态与阶段文案的每一次流转都由这里写账本。 async fn run_local_project_asset_generation_task( root: PathBuf, @@ -376,10 +397,124 @@ async fn run_local_project_asset_generation_task( remove_live_task_id(&task_id); } +/// 走音频无源生成链路的 kind:音效与背景音乐。 +/// +/// 这份判据是「同一命令两种通道」的唯一分叉点:它在白名单里只放这两个成员,其余 kind +/// (含图片类与 `unknown`)一律继续走图片通道的既有收口,不在这一层做兜底猜测。 +fn is_audio_asset_generation_kind(kind: GameCreationAppAssetKind) -> bool { + matches!( + kind, + GameCreationAppAssetKind::SoundEffect | GameCreationAppAssetKind::BackgroundMusic + ) +} + +/// 音频(音效 / 背景音乐)提交:校验入参 → 落**同一份**排队记录 → 返回记录与派发所需的请求。 +/// +/// 与图片类分支的差异只有三处,且都不改变账本形状: +/// 1. 权限沿用既有无源生成链路的 `asset.register`(音频入口在后台化之前就是这条判据); +/// 2. 账本去掉精确落点(音频不指定 `outputPath`); +/// 3. 生成走 `run_local_project_audio_generation_task`(由调用方派发,本函数不 spawn——校验与 +/// 落账必须能在没有异步运行时的测试里单独断言)。 +fn begin_local_project_audio_generation_task( + project_path: &str, + project_id: &str, + task_id: &str, + kind: &str, + prompt: &str, + asset_name: &str, + idempotency_key: &str, +) -> Result< + ( + AssetGenerationTaskRecord, + LocalProjectAudioGenerationRequest, + ), + String, +> { + let request = + prepare_local_project_audio_generation(task_id, kind, prompt, asset_name, idempotency_key)?; + if project_path.trim().is_empty() { + return Err("项目路径不能为空".to_string()); + } + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + // 显式列全两个音频成员:这里**不做** `_ =>` 兜底——账本的 `kind` 是前端的唯一条目身份, + // 新增音频变体(或上游白名单被放宽)时必须在这里大声失败,而不是把它静默记成音效。 + let asset_kind = match request.edit_kind { + LocalProjectResourceEditKind::BackgroundMusic => GameCreationAppAssetKind::BackgroundMusic, + LocalProjectResourceEditKind::SoundEffect => GameCreationAppAssetKind::SoundEffect, + other => return Err(format!("音频生成不支持该素材类型:{other:?}")), + }; + let record = begin_local_project_asset_generation_task( + root, + project_id, + task_id, + asset_kind, + &request.asset_name, + None, + )?; + Ok((record, request)) +} + +/// 音频后台执行:状态与阶段文案的每一次流转都由这里写账本。 +/// +/// `run_local_project_audio_generation_at` 返回 `Ok(None)` 表示这次生成没有登记出素材:按失败 +/// 收口,不把一条没有 `assetId` 的记录标成「已完成」——那样前端既定位不到素材,也没有原因可看。 +async fn run_local_project_audio_generation_task( + project_path: String, + task_id: String, + request: LocalProjectAudioGenerationRequest, +) { + let root = PathBuf::from(project_path.trim()); + if update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); + task.started_at_millis = Some(now_millis()); + }) + .is_err() + { + remove_live_task_id(&task_id); + return; + } + let outcome = run_local_project_audio_generation_at(&project_path, &request).await; + match outcome { + Ok(Some(asset_id)) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); + task.asset_id = Some(asset_id); + task.finished_at_millis = Some(now_millis()); + task.error = None; + }); + } + Ok(None) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = + format!("生成失败:{ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR}"); + task.error = Some(ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR.to_string()); + task.finished_at_millis = Some(now_millis()); + }); + } + Err(error) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = format!("生成失败:{error}"); + task.error = Some(error.clone()); + task.finished_at_millis = Some(now_millis()); + }); + } + } + remove_live_task_id(&task_id); +} + /// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。 /// /// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界), /// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。 +/// +/// 音频 kind(`sound-effect` / `background-music`)走同一条命令的音频分支:账本、阶段文案、 +/// 中断收口与本地排队全部共用,**只**把「怎么生成」换成既有音频无源生成链路(见 +/// `start_local_project_audio_generation_task`)。图片类载荷口径逐字不变。 #[tauri::command] pub(crate) async fn start_local_project_asset_generation( project_path: String, @@ -397,8 +532,47 @@ pub(crate) async fn start_local_project_asset_generation( // 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本: // 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。 target_category: Option, + // 前端 IPC 字段 `idempotencyKey`:**音频**生成才带——音频请求身份是一对 operation / 幂等键, + // 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对 + // 图片 kind 不参与任何校验。 + idempotency_key: Option, ) -> Result { let task_id = asset_generation_task_id(&task_id)?; + if is_audio_asset_generation_kind(GameCreationAppAssetKind::parse_with_context( + &kind, + "canvas.asset_kind", + )) { + let idempotency_key = idempotency_key.unwrap_or_default(); + if idempotency_key.trim().is_empty() { + return Err("音频生成缺少 idempotencyKey".to_string()); + } + // 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。 + let live_registered = register_live_task_id(&task_id); + let (record, request) = match begin_local_project_audio_generation_task( + &project_path, + &project_id, + &task_id, + &kind, + &prompt, + asset_name.as_deref().unwrap_or_default(), + &idempotency_key, + ) { + Ok(pair) => pair, + Err(error) => { + // 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。 + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + }; + tauri::async_runtime::spawn(run_local_project_audio_generation_task( + project_path.trim().to_string(), + task_id, + request, + )); + return Ok(record); + } let request = prepare_local_project_asset_generation( &project_path, &kind, @@ -414,18 +588,24 @@ pub(crate) async fn start_local_project_asset_generation( enforce_project_permission_policy(&request.root, "asset.register")?; let asset_label = request.options.asset_label.clone(); let asset_kind = request.options.asset_kind.clone(); - let record = begin_local_project_asset_generation_task( + // 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。 + let live_registered = register_live_task_id(&task_id); + let record = match begin_local_project_asset_generation_task( &request.root, &project_id, &task_id, asset_kind, &asset_label, request.options.output_path.as_deref(), - )?; - // 先登记 live 再派发:`list` 只把「非终态且不 live」的记录判为上次运行的残留。 - if let Ok(mut ids) = live_task_ids().lock() { - ids.insert(task_id.clone()); - } + ) { + Ok(record) => record, + Err(error) => { + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + }; let root = request.root.clone(); tauri::async_runtime::spawn(run_local_project_asset_generation_task( root, @@ -690,6 +870,49 @@ mod asset_generation_task_tests { std::fs::remove_dir_all(&root).ok(); } + /// 落账失败要回滚的是「**本轮**插入的那条登记」,不是「这个 id」。 + /// + /// 同 id 已经在跑时,`start` 的第二轮不会插入新登记;这时如果按 id 回滚,就会把正在跑的 + /// 那条任务的 live 登记一起删掉,`list` 随后把它谎报成上次运行的中断残留。 + #[test] + fn a_failed_ledger_write_only_takes_back_the_live_registration_it_inserted() { + let root = temp_project_root("live-rollback"); + // 第一次提交:先登记 live,再落账(真实链路里紧接着 spawn)。 + assert!( + register_live_task_id("task-in-flight"), + "首次登记必须报告为「本轮插入」" + ); + begin(&root, "task-in-flight"); + + // 第二次提交(同 id):这一轮没有插入新登记,落账也会因「已在进行中」被拒。 + let live_registered = register_live_task_id("task-in-flight"); + assert!( + !live_registered, + "同 id 已在 live 集合里时,本轮不得报告为「本轮插入」" + ); + let error = begin_local_project_asset_generation_task( + &root, + "project-1", + "task-in-flight", + GameCreationAppAssetKind::Image, + "AI 图", + None, + ) + .expect_err("duplicate in-flight task"); + assert_eq!(error, "生成任务 id 已在进行中:task-in-flight"); + if live_registered { + remove_live_task_id("task-in-flight"); + } + + // 正在跑的任务仍是 live:`list` 不得把它收口成失败。 + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + assert_eq!(listed[0].phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); + + remove_live_task_id("task-in-flight"); + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn completing_a_task_records_the_manifest_asset_id_and_keeps_it() { let root = temp_project_root("completed"); @@ -761,4 +984,129 @@ mod asset_generation_task_tests { ); std::fs::remove_dir_all(&root).ok(); } + /// 音频提交的身份与边界:缺幂等键 / 非法 operation / 非法幂等键 / 超限提示词 / 非音频 kind + /// 一律在提交期拒绝,且**不**在账本里留下记录——「点击瞬间就失败」必须是零写入。 + #[test] + fn audio_submission_rejects_invalid_identity_and_prompt_without_touching_the_ledger() { + let root = initialized_project_root("audio-invalid"); + let project_path = root.to_string_lossy().into_owned(); + let operation_id = "0b6f2f9a-0f0f-4b3d-8d0a-5e0f5cef9b21"; + let idempotency_key = "9a1b2c3d-4e5f-4a1b-8c2d-3e4f5a6b7c8d"; + + let error = begin_local_project_audio_generation_task( + &project_path, + "project-1", + operation_id, + "background-music", + "一段平静的钢琴曲", + "新背景音乐", + "", + ) + .expect_err("missing idempotency key"); + assert!(error.contains("idempotencyKey"), "{error}"); + + let error = begin_local_project_audio_generation_task( + &project_path, + "project-1", + "not-a-uuid", + "background-music", + "一段平静的钢琴曲", + "新背景音乐", + idempotency_key, + ) + .expect_err("operation id must be a uuid"); + assert!(error.contains("operationId"), "{error}"); + + let error = begin_local_project_audio_generation_task( + &project_path, + "project-1", + operation_id, + "background-music", + "一段平静的钢琴曲", + "新背景音乐", + "不看幂等键", + ) + .expect_err("idempotency key must be a uuid"); + assert!(error.contains("idempotencyKey"), "{error}"); + + let error = begin_local_project_audio_generation_task( + &project_path, + "project-1", + operation_id, + "background-music", + &"曲".repeat(141), + "新背景音乐", + idempotency_key, + ) + .expect_err("background music prompt limit"); + assert!(error.contains("140"), "{error}"); + + let error = begin_local_project_audio_generation_task( + &project_path, + "project-1", + operation_id, + "audio", + "一段平静的钢琴曲", + "新背景音乐", + idempotency_key, + ) + .expect_err("音频 kind 不是可生成的音频类型"); + assert!(error.contains("音频生成不支持该素材类型"), "{error}"); + + assert!( + list_local_project_asset_generation_tasks(&root) + .expect("list") + .is_empty(), + "被拒绝的提交不得在账本里留下记录" + ); + std::fs::remove_dir_all(&root).ok(); + } + + /// 音频任务的账本记录与图片类共用同一份:kind 是音频 canonical kind,阶段文案由后端拥有, + /// 精确落点为空(音频不指定 outputPath),提示词在提交期就按同一口径归一化。 + #[test] + fn audio_submission_lands_in_the_shared_ledger_with_its_audio_kind() { + let root = initialized_project_root("audio-ledger"); + let project_path = root.to_string_lossy().into_owned(); + let (record, request) = begin_local_project_audio_generation_task( + &project_path, + "project-1", + "0b6f2f9a-0f0f-4b3d-8d0a-5e0f5cef9b21", + "background-music", + " 一段平静的钢琴曲 ", + "新背景音乐", + "9a1b2c3d-4e5f-4a1b-8c2d-3e4f5a6b7c8d", + ) + .expect("background music task"); + assert_eq!(record.kind, GameCreationAppAssetKind::BackgroundMusic); + assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_QUEUED); + assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); + assert!(record.output_path.is_none()); + assert_eq!(record.asset_name, "新背景音乐"); + assert_eq!(request.prompt, "一段平静的钢琴曲"); + assert_eq!( + request.edit_kind, + LocalProjectResourceEditKind::BackgroundMusic + ); + + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].kind, GameCreationAppAssetKind::BackgroundMusic); + assert_eq!(listed[0].task_id, record.task_id); + + // 音效走同一条账本,只是落到另一个 canonical kind。 + let (sound_effect, request) = begin_local_project_audio_generation_task( + &project_path, + "project-1", + "1c7a3b8e-2f31-4c6d-9e7a-6b8c0d1e2f34", + "sound-effect", + "木门缓慢推开的吱呀声", + "新音效", + "2d8b4c9f-3a42-4d7e-8f1b-7c9d1e2f3a45", + ) + .expect("sound effect task"); + assert_eq!(sound_effect.kind, GameCreationAppAssetKind::SoundEffect); + assert_eq!(request.edit_kind, LocalProjectResourceEditKind::SoundEffect); + std::fs::remove_dir_all(&root).ok(); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 0a4a7f772..89a57806f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -5399,6 +5399,94 @@ pub(crate) async fn resume_local_project_resource_edit_at( .await } +/// 音频(音效 / 背景音乐)无源生成的入参收口。 +/// +/// 与同步派生通道(`derive_local_project_resource`)共用同一份校验:提示词上限按 edit kind +/// 取(背景音乐 140、音效 1900),素材名同口径,`idempotencyKey` 必须是合法 UUID。区别只在 +/// **时机**:后台任务账本的提交必须「校验即返回」,所以这里只收口、不发起生成——生成由派发后 +/// 的后台任务跑(见 `run_local_project_audio_generation_at`)。 +#[derive(Clone, Debug)] +pub(crate) struct LocalProjectAudioGenerationRequest { + pub(crate) operation_id: String, + pub(crate) edit_kind: LocalProjectResourceEditKind, + pub(crate) prompt: String, + pub(crate) asset_name: String, + pub(crate) idempotency_key: String, +} + +/// 音频 kind 的提交期收口:operation 身份、kind、提示词、素材名与幂等键。 +/// +/// kind 只接受 `sound-effect` / `background-music`:其余成员(含图片类)在这里就被拒绝, +/// 不会落一条注定失败的账本记录,也不改图片类入口的载荷口径。 +pub(crate) fn prepare_local_project_audio_generation( + operation_id: &str, + kind: &str, + prompt: &str, + asset_name: &str, + idempotency_key: &str, +) -> Result { + validate_resource_edit_uuid(operation_id, "operationId")?; + let edit_kind = match GameCreationAppAssetKind::parse_with_context(kind, "canvas.asset_kind") { + GameCreationAppAssetKind::SoundEffect => LocalProjectResourceEditKind::SoundEffect, + GameCreationAppAssetKind::BackgroundMusic => LocalProjectResourceEditKind::BackgroundMusic, + _ => return Err(format!("音频生成不支持该素材类型:{}", kind.trim())), + }; + validate_resource_edit_uuid(idempotency_key, "idempotencyKey")?; + let prompt = normalize_resource_edit_prompt(&edit_kind, prompt)?; + let asset_name = normalize_resource_edit_name(asset_name)?; + Ok(LocalProjectAudioGenerationRequest { + operation_id: operation_id.trim().to_string(), + edit_kind, + prompt, + asset_name, + idempotency_key: idempotency_key.trim().to_string(), + }) +} + +/// 后台跑一次音频无源生成,返回产物素材 id。 +/// +/// 生成本身仍走 `derive_local_project_resource_at` 这一条通道(幂等账本、平台请求、下载与 +/// manifest 登记全部复用),这里只做两件账本侧的事:把「提交时刻」无法确定的项目 revision +/// 在派发时刻读成当前值(提交之后用户仍可能编辑项目),以及把产物素材 id 交回任务账本。 +/// 拿不到当前 revision 或 CAS 冲突时按失败返回,不静默重试——静默重试会把这次生成写到用户 +/// 没预期的基线上。 +/// +/// `generation_mode: Create` 下源快照的媒体类型由 `edit_kind` 推出(音频恒为 `audio/mpeg`), +/// 所以这里固定传 `None`:这条通道根本不读 `input.source_media_type`,填一个值只会让读者 +/// 以为它对生成有影响。 +pub(crate) async fn run_local_project_audio_generation_at( + project_path: &str, + request: &LocalProjectAudioGenerationRequest, +) -> Result, String> { + let project_path = project_path.trim(); + let root = Path::new(project_path); + let manifest = read_existing_manifest_for_project(root)?; + let expected_project_revision = + read_game_creator_agent_runtime_project_revision(root)?.revision; + let result = derive_local_project_resource_at(DeriveLocalProjectResourceInput { + project_path: project_path.to_string(), + expected_project_id: manifest.project_id, + expected_project_revision, + operation_id: request.operation_id.clone(), + idempotency_key: request.idempotency_key.clone(), + edit_kind: request.edit_kind, + generation_mode: LocalProjectResourceGenerationMode::Create, + source_resource_id: format!("create:{}", request.operation_id), + source_asset_id: None, + source_path: None, + source_media_type: None, + source_subtype: None, + producer_task_id: None, + source_version_id: None, + prompt: request.prompt.clone(), + asset_name: request.asset_name.clone(), + background_mode: None, + screen_color: None, + }) + .await?; + Ok(result.asset.map(|asset| asset.id)) +} + pub(crate) async fn derive_local_project_resource_at( input: DeriveLocalProjectResourceInput, ) -> Result { diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json index 67695ad22..019a0f248 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json @@ -5,13 +5,20 @@ "minimumSystemVersion": "15.0" }, "resources": { - "resources/codex/mac-native/bin/codex": "coding-agent/mac-native/bin/codex", - "resources/codex/mac-native/bin/codex-code-mode-host": "coding-agent/mac-native/bin/codex-code-mode-host", - "resources/codex/mac-native/codex-path/rg": "coding-agent/mac-native/codex-path/rg", - "resources/codex/mac-native/codex-resources/zsh/bin/zsh": "coding-agent/mac-native/codex-resources/zsh/bin/zsh", - "resources/codex/mac-native/codex-package.json": "coding-agent/mac-native/codex-package.json", - "resources/codex/mac-native/NOTICE.md": "coding-agent/mac-native/NOTICE.md", - "resources/codex/mac-native/manifest.json": "coding-agent/mac-native/manifest.json", + "resources/codex/mac-native/darwin-arm64/bin/codex": "coding-agent/mac-native/darwin-arm64/bin/codex", + "resources/codex/mac-native/darwin-arm64/bin/codex-code-mode-host": "coding-agent/mac-native/darwin-arm64/bin/codex-code-mode-host", + "resources/codex/mac-native/darwin-arm64/codex-path/rg": "coding-agent/mac-native/darwin-arm64/codex-path/rg", + "resources/codex/mac-native/darwin-arm64/codex-resources/zsh/bin/zsh": "coding-agent/mac-native/darwin-arm64/codex-resources/zsh/bin/zsh", + "resources/codex/mac-native/darwin-arm64/codex-package.json": "coding-agent/mac-native/darwin-arm64/codex-package.json", + "resources/codex/mac-native/darwin-arm64/NOTICE.md": "coding-agent/mac-native/darwin-arm64/NOTICE.md", + "resources/codex/mac-native/darwin-arm64/manifest.json": "coding-agent/mac-native/darwin-arm64/manifest.json", + "resources/codex/mac-native/darwin-x64/bin/codex": "coding-agent/mac-native/darwin-x64/bin/codex", + "resources/codex/mac-native/darwin-x64/bin/codex-code-mode-host": "coding-agent/mac-native/darwin-x64/bin/codex-code-mode-host", + "resources/codex/mac-native/darwin-x64/codex-path/rg": "coding-agent/mac-native/darwin-x64/codex-path/rg", + "resources/codex/mac-native/darwin-x64/codex-resources/zsh/bin/zsh": "coding-agent/mac-native/darwin-x64/codex-resources/zsh/bin/zsh", + "resources/codex/mac-native/darwin-x64/codex-package.json": "coding-agent/mac-native/darwin-x64/codex-package.json", + "resources/codex/mac-native/darwin-x64/NOTICE.md": "coding-agent/mac-native/darwin-x64/NOTICE.md", + "resources/codex/mac-native/darwin-x64/manifest.json": "coding-agent/mac-native/darwin-x64/manifest.json", "resources/plugins": "plugins" } }, diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index 8cba1107d..68886de45 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -368,6 +368,12 @@ export function ResourceCanvasAssetGenerationPanelView({ return; } setError(null); + /* + TODO(resource-generation-panel-contract): 与 `ResourceCanvasGenerationPanelView` 是同一份契约 + 问题——`onSubmit` 只返回 `void`,宿主同步拒绝时(今天只剩「队列未就绪」这条防御性分支)这里 + 已经置位 `released` 并关掉面板,草稿没人接走。修法同那一侧的 TODO:`onSubmit` 回报受理结果, + 只有受理成功才置位并 `onClose()`。 + */ // 这次输入被任务接走:卸载时不再写回草稿槽(失败重开由宿主的提交上下文负责)。 draftReleasedRef.current = true; // 点击即关闭:不等 IPC、不等排队、不等生成结束。失败要不要把面板带回来由宿主决定 diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx index de592086d..593f3cdff 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx @@ -87,7 +87,13 @@ export type ResourceCanvasGenerationPanelViewProps = { prompt: string; assetName: string; }) => void; - onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise; + /** + * 提交这次输入:**同步入队**,不等 IPC、不等排队、不等生成结束。 + * + * 生成任务由宿主交给项目内账本(音频与图片类同一份),进度只出现在画布上的「生成任务」 + * 侧栏;面板提交后立即关闭,不持有在途状态。 + */ + onSubmit: (input: ResourceCanvasGenerationSubmitInput) => void; /** * 收起浮层。 * @@ -107,12 +113,6 @@ const RESOURCE_GENERATION_ALL_KIND_ITEMS = label: option.label, })); -function resourceGenerationErrorMessage(error: unknown) { - if (typeof error === 'string' && error.trim()) return error; - if (error instanceof Error && error.message) return error.message; - return '生成素材失败'; -} - /** * 资源画布「生成入口」的浮层面板。 * @@ -120,9 +120,13 @@ function resourceGenerationErrorMessage(error: unknown) { * 不在任何现有面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责, * 面板只持有草稿、类型选择与失败重试状态。 * - * 提交期间**不锁关闭**:× / 遮罩 / Esc / 「后台运行并关闭」四条路径都通。关闭只是把这一份 - * view 卸下来,宿主那条请求继续跑(它是宿主的 `await onSubmit(...)`,不挂在面板生命周期上), - * 所以关闭**不等于**取消;失败时面板仍保留草稿与同一份请求身份可重试。 + * **点「生成」即同步关闭面板**:不等 IPC、不等排队、不等生成结束,用户立刻回到画布;面板里 + * 因此不存在「排队中。」「正在生成。」「提交中…」「后台运行并关闭」这些阶段文案与按钮—— + * 阶段文案的唯一去处是画布上的「生成任务」侧栏。关闭**不等于**取消:任务照常在后台跑完并把 + * 结果写回项目。 + * + * 只有「点击瞬间就失败」(校验 / 权限拒绝 / 提交 IPC 立即报错,即后端从未受理)时,宿主才会 + * 把面板连原草稿与原请求身份带回来,用户可以直接改后重试。 */ export function ResourceCanvasGenerationPanelView({ kinds = RESOURCE_CANVAS_GENERATION_OPTIONS.map((option) => option.kind), @@ -154,11 +158,11 @@ export function ResourceCanvasGenerationPanelView({ const [assetName, setAssetName] = useState( initialDraft?.assetName ?? option.assetName, ); - const [attempted, setAttempted] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - // 占位带来的失败原因优先展示;面板自己这次的失败(`error`)覆盖它。 - const shownError = error ?? initialError ?? null; + /* + 失败原因只有**占位**这一个来源:提交后面板已经关闭,面板实例不持有在途状态,也就不会 + 自己造一份 `error`。重开同一张占位时由宿主把账本里的原因灌回来。 + */ + const shownError = initialError ?? null; /** * 已绑定请求身份的那句提示词(只有带 `request` 重开的失败面板才有)。 * @@ -201,7 +205,6 @@ export function ResourceCanvasGenerationPanelView({ const requestRef = useRef( boundRequest ?? null, ); - const inputLocked = attempted || submitting; /** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */ const closeWithDraft = () => { draftReleasedRef.current = true; @@ -220,43 +223,41 @@ export function ResourceCanvasGenerationPanelView({ assetName: assetName.trim() || option.assetName, }; - async function submit(event: FormEvent) { + function submit(event: FormEvent) { event.preventDefault(); const normalizedPrompt = prompt.trim(); const normalizedAssetName = assetName.trim(); if ( !normalizedPrompt || !normalizedAssetName || - submitting || // 按钮禁用只是表现:改动原请求提示词的提交在这里也必须被挡住,不能悄悄变成新付费生成。 boundRequestPromptChanged ) { return; } - setAttempted(true); - setSubmitting(true); - setError(null); requestRef.current = resolveResourceEditRequestIdentity( requestRef.current, normalizedPrompt, ); - try { - await onSubmit({ - kind, - operationId: requestRef.current.operationId, - idempotencyKey: requestRef.current.idempotencyKey, - prompt: normalizedPrompt, - assetName: normalizedAssetName, - }); - // 只有**成功**交出这次输入才不再写回草稿;失败时草稿要留给用户切走再切回来的重试。 - draftReleasedRef.current = true; - } catch (submitError) { - setError(resourceGenerationErrorMessage(submitError)); - } finally { - // 成功路径也要收回在飞标记:宿主随后会卸载面板,但组件本身不该在 `onSubmit` 正常 - // resolve 后永久停在「生成中…」;失败时收回标记才能让用户用同一份输入重试。 - setSubmitting(false); - } + /* + TODO(resource-generation-panel-contract): `onSubmit` 现在只返回 `void`,所以这里只能「先置位 + `released`、再无脑关闭」;宿主那几条同步拒绝的分支(占位已不在画布 / 已在跑别的 operation) + 只给一条提示条,这次输入没人接走、草稿也跟着丢了。修法:让 `onSubmit` 回报受理结果 + (`accepted: boolean`),只有受理成功才置位并 `onClose()`。同一份契约落在 + `ResourceCanvasAssetGenerationPanelView` 与两个宿主调用点上——要改就一起改。 + */ + // 这次输入被任务接走:卸载时不再往草稿槽里写一份内存副本(失败重开由宿主的提交上下文负责)。 + draftReleasedRef.current = true; + // 点击即关闭:不等 IPC、不等排队、不等生成结束。入参已经带上这次生成的请求身份, + // 「从未被后端受理」的即时失败由宿主连原草稿与原身份把面板带回来。 + onSubmit({ + kind, + operationId: requestRef.current.operationId, + idempotencyKey: requestRef.current.idempotencyKey, + prompt: normalizedPrompt, + assetName: normalizedAssetName, + }); + onClose(); } const panelBody = ( @@ -281,7 +282,6 @@ export function ResourceCanvasGenerationPanelView({ columns="three" gap="sm" size="compact" - disabled={inputLocked} onChange={(nextKind) => { setKind(nextKind); setAssetName(resourceCanvasGenerationOption(nextKind).assetName); @@ -294,7 +294,6 @@ export function ResourceCanvasGenerationPanelView({ setAssetName(event.currentTarget.value)} /> @@ -306,7 +305,6 @@ export function ResourceCanvasGenerationPanelView({ aria-label="生成提示词" rows={6} autoFocus - disabled={inputLocked} maxLength={resourceEditPromptMaxLength(option.editKind)} placeholder={option.promptPlaceholder} value={prompt} @@ -317,7 +315,6 @@ export function ResourceCanvasGenerationPanelView({ subject={`素材生成提示词(${option.label})`} editKind={option.editKind} prompt={prompt} - disabled={inputLocked} applyPrompt={setPrompt} /> {boundRequestPromptChanged ? ( @@ -339,12 +336,12 @@ export function ResourceCanvasGenerationPanelView({ tone="secondary" onClick={closeWithDraft} > - {submitting ? '后台运行并关闭' : '取消'} + 取消 {shownError ? (