From e3682fd06f93e7db4f58e64374c6ac9b4c1ed649 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:18:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E5=AE=A2=E6=88=B7=E7=AB=AFde?= =?UTF-8?q?v=E6=9C=8D=E5=8A=A1=E5=B9=B6=E6=94=AF=E6=8C=81=E5=AE=98?= =?UTF-8?q?=E7=BD=91=E5=A4=9A=E5=B9=B3=E5=8F=B0=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除服务器选择并按来源隔离登录凭据 新增官网客户端下载入口和匿名平台聚合接口 根据发布清单自动展示Windows与macOS首装包 补齐Mac首装元数据和上传顺序校验 同步定向测试与下载发布规范 --- .../scripts/build-release.mjs | 61 +- .../scripts/build-release.test.mjs | 157 +++- .../scripts/release-oss.mjs | 89 +++ .../scripts/release-oss.test.mjs | 173 +++- .../scripts/release-upload.mjs | 87 +- .../src/app/AuthenticatedClient.tsx | 103 +-- .../src/services/clientApi.ts | 24 +- .../src/services/clientAuth.ts | 45 +- .../src/services/clientHttp.ts | 151 +--- .../src/services/platformSession.ts | 20 +- .../tests/appSurface/auth.suite.ts | 249 +++--- .../tests/clientApi.test.ts | 13 +- .../tests/clientAuthStorage.test.ts | 121 +++ .../tests/clientHttp.test.ts | 178 ++--- docs/README.md | 2 +- .../shared-memory/team-conventions.md | 2 + ...方案】AGC客户端更新检查与下载-2026-08-31.md | 32 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 + .../shared/src/contracts/clientDownload.ts | 14 + packages/shared/src/index.ts | 1 + server-rs/crates/api-server/src/app.rs | 1 + server-rs/crates/api-server/src/modules.rs | 1 + .../src/modules/client_downloads.rs | 173 ++++ server-rs/crates/platform-oss/Cargo.toml | 2 +- .../platform-oss/src/client_downloads.rs | 747 ++++++++++++++++++ server-rs/crates/platform-oss/src/lib.rs | 2 + .../shared-contracts/src/client_downloads.rs | 31 + server-rs/crates/shared-contracts/src/lib.rs | 1 + .../ClientDownloadEntry.test.tsx | 223 ++++++ .../creation-home/ClientDownloadEntry.tsx | 174 ++++ .../PlatformEntryActiveFlowShell.test.tsx | 2 + .../PlatformEntryActiveFlowShell.tsx | 6 +- vite.config.ts | 5 + 34 files changed, 2245 insertions(+), 653 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/clientAuthStorage.test.ts create mode 100644 packages/shared/src/contracts/clientDownload.ts create mode 100644 server-rs/crates/api-server/src/modules/client_downloads.rs create mode 100644 server-rs/crates/platform-oss/src/client_downloads.rs create mode 100644 server-rs/crates/shared-contracts/src/client_downloads.rs create mode 100644 src/components/creation-home/ClientDownloadEntry.test.tsx create mode 100644 src/components/creation-home/ClientDownloadEntry.tsx diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index ace684157..ecee04d4c 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -501,6 +501,41 @@ export function selectReleaseArtifact(files, target = defaultTarget()) { ); } +export function selectFirstInstallArtifact( + files, + { target, version, artifact }, +) { + validateReleaseTarget(target); + let selected; + if (target.includes('windows')) { + selected = artifact; + if (!selected?.endsWith('.exe')) { + throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包'); + } + } else { + // Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。 + const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64'; + const suffix = `_${version}_${architecture}.dmg`; + const candidates = files.filter((file) => + path.basename(file).endsWith(suffix), + ); + if (candidates.length !== 1) { + throw new Error( + `首装 DMG 必须唯一匹配本次版本 ${version} 和架构 ${architecture},找到 ${candidates.length} 个`, + ); + } + selected = candidates[0]; + } + if ( + !fs.existsSync(selected) || + !fs.statSync(selected).isFile() || + fs.statSync(selected).size === 0 + ) { + throw new Error(`首装包不存在或为空:${selected}`); + } + return selected; +} + function readUpdaterSignature(artifactPath) { const signaturePath = `${artifactPath}.sig`; if (!fs.existsSync(signaturePath)) { @@ -521,23 +556,32 @@ export function createUpdateManifest( publishedAt = new Date().toISOString(), notes = readReleaseNotes(), commit = readHeadCommit(), + downloadArtifact, } = {}, ) { validateReleaseTarget(target); resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target); const signature = readUpdaterSignature(artifactPath); const version = readPackageJson().version; + const firstInstallArtifact = selectFirstInstallArtifact( + downloadArtifact ? [downloadArtifact] : [], + { target, version, artifact: artifactPath }, + ); const fileName = path.basename(artifactPath); const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`; + const downloadUrl = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`; const platforms = {}; + const downloads = {}; for (const key of resolveManifestPlatformKeys(target)) { platforms[key] = { signature, url }; + downloads[key] = { url: downloadUrl }; } return { version, ...(notes ? { notes } : {}), pub_date: publishedAt, platforms, + downloads, // 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。 ...(commit ? { commit } : {}), }; @@ -676,10 +720,16 @@ export async function generateUpdateManifest( context = resolveReleaseContext(), ) { const { channel, target, bundleRoot } = context; - const artifact = selectReleaseArtifact(listFiles(bundleRoot), target); + const files = listFiles(bundleRoot); + const artifact = selectReleaseArtifact(files, target); if (!artifact) { throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); } + const downloadArtifact = selectFirstInstallArtifact(files, { + target, + version: readPackageJson().version, + artifact, + }); const manualNotes = readReleaseNotes(); const previousCommit = await resolvePreviousReleaseCommit(channel); const commits = collectReleaseCommits(previousCommit); @@ -693,7 +743,12 @@ export async function generateUpdateManifest( `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`, ); } - const manifest = createUpdateManifest(artifact, { channel, target, notes }); + const manifest = createUpdateManifest(artifact, { + channel, + target, + notes, + downloadArtifact, + }); const manifestPath = path.join(bundleRoot, 'latest.json'); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); const notesPath = path.join(bundleRoot, 'release-notes.txt'); @@ -718,6 +773,7 @@ export async function generateUpdateManifest( `[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`, ); console.log(`[ai-game-creator-shell] 安装包:${artifact}`); + console.log(`[ai-game-creator-shell] 首装包:${downloadArtifact}`); console.log( manualNotes ? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案' @@ -734,6 +790,7 @@ export async function generateUpdateManifest( return { channel, artifact, + downloadArtifact, manifest, manifestPath, notes, 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 274f8892e..cd0001741 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -32,12 +32,23 @@ import { resolveReleaseContext, resolveRemoteHighWaterVersion, runTauriBuild, + selectFirstInstallArtifact, selectReleaseArtifact, updateManifestUrl, } from './build-release.mjs'; const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; +const packageVersion = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +).version; + +function createDmgFixture(root, target, version = packageVersion) { + const architecture = target.startsWith('aarch64') ? 'aarch64' : '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), /单架构/); @@ -254,7 +265,13 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and ), artifact, ); - const manifest = createUpdateManifest(artifact, context); + const manifest = createUpdateManifest(artifact, { + ...context, + downloadArtifact: createDmgFixture( + path.dirname(artifact), + context.target, + ), + }); assert.deepEqual(Object.keys(manifest.platforms), [ 'darwin-aarch64', ]); @@ -272,29 +289,118 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and assert.ok(seenContexts.every((context) => context === seenContexts[0])); }); -test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => { - const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-')); +for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) { + test(`real manifest writer publishes the ${target} updater and first installer separately`, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-')); + try { + const artifact = path.join(root, '陶泥儿.app.tar.gz'); + writeFileSync(artifact, 'mac package'); + writeFileSync(`${artifact}.sig`, 'mac signature'); + writeFileSync(path.join(root, 'windows.exe'), 'wrong platform'); + const downloadArtifact = createDmgFixture(root, target); + const context = { + ...resolveReleaseContext([`--target=${target}`], {}), + bundleRoot: root, + }; + const result = await withStubbedFetch( + (url) => { + assert.match(url, /\/dev-mac\/latest\.json$/); + return jsonResponse({}, 404); + }, + () => generateUpdateManifest(context), + ); + assert.equal(result.artifact, artifact); + assert.equal(result.downloadArtifact, downloadArtifact); + assert.equal(result.manifestPath, path.join(root, 'latest.json')); + assert.equal(result.legacyManifestPath, null); + const key = target.startsWith('aarch64') + ? 'darwin-aarch64' + : 'darwin-x86_64'; + assert.deepEqual(Object.keys(result.manifest.platforms), [key]); + assert.deepEqual(Object.keys(result.manifest.downloads), [key]); + assert.match( + result.manifest.platforms[key].url, + /\/dev-mac\/.*\.app\.tar\.gz$/, + ); + assert.equal( + decodeURIComponent( + new URL(result.manifest.downloads[key].url).pathname, + ), + `/agc/dev-mac/${packageVersion}/${path.basename(downloadArtifact)}`, + ); + assert.deepEqual( + JSON.parse(readFileSync(result.manifestPath, 'utf8')), + result.manifest, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +test('DMG selection ignores other versions and architectures but rejects missing, empty and ambiguous current packages', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-dmg-selection-')); + try { + const target = 'aarch64-apple-darwin'; + const options = { + target, + version: '2.3.4', + artifact: path.join(root, '陶泥儿.app.tar.gz'), + }; + const oldVersion = createDmgFixture(root, target, '2.3.3'); + const wrongArchitecture = createDmgFixture( + root, + 'x86_64-apple-darwin', + '2.3.4', + ); + assert.throws(() => selectFirstInstallArtifact([], options), /找到 0 个/u); + assert.throws( + () => + selectFirstInstallArtifact([oldVersion, wrongArchitecture], options), + /找到 0 个/u, + ); + const current = createDmgFixture(root, target, '2.3.4'); + assert.equal( + selectFirstInstallArtifact( + [oldVersion, wrongArchitecture, current], + options, + ), + current, + ); + writeFileSync(current, ''); + assert.throws( + () => selectFirstInstallArtifact([current], options), + /不存在或为空/u, + ); + writeFileSync(current, 'valid dmg'); + const second = path.join(root, '另一包_2.3.4_aarch64.dmg'); + writeFileSync(second, 'ambiguous dmg'); + assert.throws( + () => selectFirstInstallArtifact([current, second], options), + /找到 2 个/u, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('manifest writer refuses to create latest when the current Mac DMG is missing', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-missing-dmg-')); try { const artifact = path.join(root, '陶泥儿.app.tar.gz'); - writeFileSync(artifact, 'mac package'); - writeFileSync(`${artifact}.sig`, 'mac signature'); - writeFileSync(path.join(root, 'windows.exe'), 'wrong platform'); + writeFileSync(artifact, 'updater archive'); + writeFileSync(`${artifact}.sig`, 'signature'); const context = { - ...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}), + ...resolveReleaseContext(['--target=aarch64-apple-darwin'], {}), bundleRoot: root, }; - const result = await withStubbedFetch( - (url) => { - assert.match(url, /\/dev-mac\/latest\.json$/); - return jsonResponse({}, 404); - }, + await assert.rejects( () => generateUpdateManifest(context), + /首装 DMG 必须唯一匹配/u, ); - assert.equal(result.artifact, artifact); - assert.equal(result.manifestPath, path.join(root, 'latest.json')); - assert.equal(result.legacyManifestPath, null); - assert.deepEqual(Object.keys(result.manifest.platforms), ['darwin-x86_64']); - assert.match(result.manifest.platforms['darwin-x86_64'].url, /\/dev-mac\//); + assert.throws(() => readFileSync(path.join(root, 'latest.json')), { + code: 'ENOENT', + }); } finally { rmSync(root, { recursive: true, force: true }); } @@ -393,6 +499,9 @@ test('channel manifest carries version, platform keys and signature', () => { assert.equal(manifest.notes, '修复与改进'); assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z'); assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']); + assert.deepEqual(manifest.downloads, { + 'windows-x86_64': { url: manifest.platforms['windows-x86_64'].url }, + }); assert.equal( manifest.platforms['windows-x86_64'].signature, 'signature-content', @@ -573,18 +682,18 @@ test('recent commit fallback marks that entries may repeat the previous release' } }); -test('release upload forces overwrite for artifact, signature and channel pointers', () => { +test('release entry forwards the built artifacts and dry-run mode to the uploader', () => { const source = readFileSync( new URL('./release-upload.mjs', import.meta.url), 'utf8', ); - assert.equal( - (source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length, - 4, + assert.match( + source, + /const release = await buildRelease\(process\.argv\.slice\(2\)\)/u, ); - assert.match(source, /agc\/\$\{channel\}\/latest\.json/u); - assert.match(source, /agc\/latest\.json/u); - assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/u); + assert.match(source, /uploadReleaseArtifacts\(release, \{/u); + assert.match(source, /const dryRun = readReleaseDryRun\(\);/u); + assert.ok(source.includes('\n dryRun,\n')); }); test('release notes list client commits with short sha and bound their size', () => { diff --git a/apps/ai-game-creator-shell/scripts/release-oss.mjs b/apps/ai-game-creator-shell/scripts/release-oss.mjs index 0e19ee81b..b136145b8 100644 --- a/apps/ai-game-creator-shell/scripts/release-oss.mjs +++ b/apps/ai-game-creator-shell/scripts/release-oss.mjs @@ -1,3 +1,6 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + /** * 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式, * 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。 @@ -25,3 +28,89 @@ export function formatOssutilCommand({ binary, args, endpoint, credentials }) { } return parts.map(quoteArgument).join(' '); } + +export function createReleaseUploadPlan( + { + artifact, + downloadArtifact, + channel, + manifest, + manifestPath, + legacyManifestPath, + }, + bucket, +) { + if (!artifact || !downloadArtifact || !manifestPath || !manifest?.version) { + throw new Error('发布结果缺少更新包、首装包或清单'); + } + const prefix = `oss://${bucket}/agc/${channel}`; + const artifacts = [ + ...new Set( + [artifact, `${artifact}.sig`, downloadArtifact].map((file) => + path.resolve(file), + ), + ), + ]; + const plan = artifacts.map((source) => ({ + source, + destination: `${prefix}/${manifest.version}/${path.basename(source)}`, + })); + plan.push({ source: manifestPath, destination: `${prefix}/latest.json` }); + if (legacyManifestPath) { + plan.push({ + source: legacyManifestPath, + destination: `oss://${bucket}/agc/latest.json`, + }); + } + return plan; +} + +export function uploadReleaseArtifacts( + release, + { + bucket, + endpoint, + binary = 'ossutil', + accessKeyId, + accessKeySecret, + dryRun = false, + spawn = spawnSync, + log = console.log, + }, +) { + if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) { + throw new Error('OSS AccessKey ID 和 Secret 必须同时提供'); + } + const plan = createReleaseUploadPlan(release, bucket); + for (const { source, destination } of plan) { + // 全部安装对象成功后才执行 latest 指针;失败立即终止,不发布悬空链接。 + const args = ['cp', '--force', source, destination]; + if (dryRun) { + log( + `[dry-run] ${formatOssutilCommand({ binary, args, endpoint, credentials: Boolean(accessKeyId) })}`, + ); + continue; + } + const credentials = accessKeyId + ? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret] + : []; + const result = spawn( + binary, + [...args, '--endpoint', endpoint, ...credentials], + { + stdio: 'inherit', + shell: false, + }, + ); + if (result.error) + throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`); + if (result.status !== 0) { + throw new Error( + `OSS 上传失败(退出码 ${result.status ?? 1}):${destination}`, + ); + } + log(`[ai-game-creator-shell] 已上传 ${destination}`); + } + if (dryRun) log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象'); + return plan; +} diff --git a/apps/ai-game-creator-shell/scripts/release-oss.test.mjs b/apps/ai-game-creator-shell/scripts/release-oss.test.mjs index 0e38efc9d..813ff5910 100644 --- a/apps/ai-game-creator-shell/scripts/release-oss.test.mjs +++ b/apps/ai-game-creator-shell/scripts/release-oss.test.mjs @@ -1,8 +1,15 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { test } from 'node:test'; -import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs'; +import { + createReleaseUploadPlan, + formatOssutilCommand, + readReleaseDryRun, + uploadReleaseArtifacts, +} from './release-oss.mjs'; test('dry run only accepts explicit truthy values', () => { assert.equal(readReleaseDryRun({}), false); @@ -33,12 +40,158 @@ test('printed upload command keeps arguments and hides credentials', () => { ); }); -test('uploader gates every ossutil call behind the dry run switch', () => { - const source = readFileSync( - new URL('./release-upload.mjs', import.meta.url), - 'utf8', - ); - assert.match(source, /const dryRun = readReleaseDryRun\(\);/u); - assert.match(source, /if \(dryRun\) \{/u); - assert.match(source, /dry-run:未写入任何 OSS 对象/u); +function withReleaseFixture(channel, architecture, run) { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-upload-plan-')); + try { + const artifact = path.join( + root, + channel === 'dev-win' + ? '陶泥儿_1.2.3_x64-setup.exe' + : '陶泥儿.app.tar.gz', + ); + const downloadArtifact = + channel === 'dev-win' + ? artifact + : path.join(root, `陶泥儿_1.2.3_${architecture}.dmg`); + const manifestPath = path.join(root, 'latest.json'); + const legacyManifestPath = + channel === 'dev-win' ? path.join(root, 'legacy-latest.json') : null; + for (const file of [ + artifact, + `${artifact}.sig`, + downloadArtifact, + manifestPath, + legacyManifestPath, + ].filter(Boolean)) { + writeFileSync(file, 'fixture'); + } + return run({ + artifact, + downloadArtifact, + channel, + manifest: { version: '1.2.3' }, + manifestPath, + legacyManifestPath, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const uploadOptions = { + bucket: 'agc-dev', + endpoint: 'oss-rg-china-mainland.aliyuncs.com', + log: () => {}, +}; + +for (const architecture of ['aarch64', 'x64']) { + test(`uploads every ${architecture} Mac object before the channel pointer`, () => { + withReleaseFixture('dev-mac', architecture, (release) => { + const calls = []; + uploadReleaseArtifacts(release, { + ...uploadOptions, + spawn: (binary, args, options) => { + assert.equal(binary, 'ossutil'); + assert.equal(options.shell, false); + assert.deepEqual(args.slice(0, 2), ['cp', '--force']); + calls.push({ source: args[2], destination: args[3] }); + return { status: 0 }; + }, + }); + assert.deepEqual( + calls.map(({ source }) => source), + [ + release.artifact, + `${release.artifact}.sig`, + release.downloadArtifact, + release.manifestPath, + ], + ); + assert.equal( + calls[2].destination, + `oss://agc-dev/agc/dev-mac/1.2.3/陶泥儿_1.2.3_${architecture}.dmg`, + ); + assert.equal( + calls[3].destination, + 'oss://agc-dev/agc/dev-mac/latest.json', + ); + }); + }); +} + +test('Windows uploads the shared installer once and publishes migration metadata last', () => { + withReleaseFixture('dev-win', 'x64', (release) => { + const plan = createReleaseUploadPlan(release, 'agc-dev'); + assert.deepEqual( + plan.map(({ source }) => source), + [ + release.artifact, + `${release.artifact}.sig`, + release.manifestPath, + release.legacyManifestPath, + ], + ); + assert.equal(plan.at(-1).destination, 'oss://agc-dev/agc/latest.json'); + const calls = []; + uploadReleaseArtifacts(release, { + ...uploadOptions, + spawn: (_binary, args) => { + assert.deepEqual(args.slice(0, 2), ['cp', '--force']); + calls.push(args[3]); + return { status: 0 }; + }, + }); + assert.deepEqual( + calls, + plan.map(({ destination }) => destination), + ); + }); +}); + +for (const failedArtifactIndex of [0, 1, 2]) { + test(`failed Mac object ${failedArtifactIndex} prevents both later objects and latest publication`, () => { + withReleaseFixture('dev-mac', 'aarch64', (release) => { + const destinations = []; + assert.throws( + () => + uploadReleaseArtifacts(release, { + ...uploadOptions, + spawn: (_binary, args) => { + destinations.push(args[3]); + return { + status: destinations.length - 1 === failedArtifactIndex ? 1 : 0, + }; + }, + }), + /OSS 上传失败/u, + ); + assert.equal(destinations.length, failedArtifactIndex + 1); + assert.ok( + destinations.every( + (destination) => !destination.endsWith('/latest.json'), + ), + ); + }); + }); +} + +test('dry run prints the complete plan without spawning uploads or exposing credentials', () => { + withReleaseFixture('dev-mac', 'aarch64', (release) => { + const output = []; + uploadReleaseArtifacts(release, { + ...uploadOptions, + dryRun: true, + accessKeyId: 'fixture-id', + accessKeySecret: 'fixture-secret', + spawn: () => assert.fail('dry run must never execute ossutil'), + log: (line) => output.push(line), + }); + assert.equal( + output.filter((line) => line.startsWith('[dry-run]')).length, + 4, + ); + assert.match(output.join('\n'), /\.dmg/u); + assert.match(output.at(-1), /未写入任何 OSS 对象/u); + assert.doesNotMatch(output.join('\n'), /fixture-id|fixture-secret|已上传/u); + }); }); diff --git a/apps/ai-game-creator-shell/scripts/release-upload.mjs b/apps/ai-game-creator-shell/scripts/release-upload.mjs index d39b2fa9f..1d86fb276 100644 --- a/apps/ai-game-creator-shell/scripts/release-upload.mjs +++ b/apps/ai-game-creator-shell/scripts/release-upload.mjs @@ -1,7 +1,4 @@ -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs'; +import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs'; const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev'; const endpoint = @@ -14,76 +11,12 @@ const dryRun = readReleaseDryRun(); const { buildRelease } = await import('./build-release.mjs'); -function runOssutil(args) { - const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil'; - const accessKeyId = process.env.AGC_OSS_ACCESS_KEY_ID?.trim(); - const accessKeySecret = process.env.AGC_OSS_ACCESS_KEY_SECRET; - if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) { - throw new Error('OSS AccessKey ID 和 Secret 必须同时提供'); - } - if (dryRun) { - // 演练:只打印将要执行的上传,凭据以占位符呈现,不写入 OSS。 - console.log( - `[dry-run] ${formatOssutilCommand({ - binary, - args, - endpoint, - credentials: Boolean(accessKeyId), - })}`, - ); - return; - } - const credentialArgs = accessKeyId - ? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret] - : []; - const result = spawnSync( - binary, - [...args, '--endpoint', endpoint, ...credentialArgs], - { - stdio: 'inherit', - shell: false, - }, - ); - if (result.error) { - throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`); - } - if (result.status !== 0) process.exit(result.status ?? 1); -} - -const { artifact, channel, legacyManifestPath, manifest, manifestPath } = - await buildRelease(process.argv.slice(2)); -const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`; -// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过; -// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。 -runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]); -runOssutil([ - 'cp', - '--force', - `${artifact}.sig`, - `oss://${bucket}/${artifactKey}.sig`, -]); -runOssutil([ - 'cp', - '--force', - manifestPath, - `oss://${bucket}/agc/${channel}/latest.json`, -]); -console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`); -console.log( - `[ai-game-creator-shell] 已上传 oss://${bucket}/agc/${channel}/latest.json`, -); -if (legacyManifestPath) { - // 迁移桥:让仍走旧 sha256 清单的已发布客户端升级到新协议,一个版本周期后删除。 - runOssutil([ - 'cp', - '--force', - legacyManifestPath, - `oss://${bucket}/agc/latest.json`, - ]); - console.log( - `[ai-game-creator-shell] 已上传迁移指针 oss://${bucket}/agc/latest.json`, - ); -} -if (dryRun) { - console.log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象'); -} +const release = await buildRelease(process.argv.slice(2)); +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, +}); diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index fdef7af6f..2e9aeeda0 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -23,14 +23,7 @@ import { normalizeAuthPhoneInput, sendClientPhoneLoginCode, } from '../services/clientAuth'; -import { - type ClientServerPreset, - type ClientServerSelection, - getClientServerBaseUrl, - getClientServerSelection, - normalizeClientServerBaseUrl, - setClientServerSelection, -} from '../services/clientHttp'; +import { getClientServerBaseUrl } from '../services/clientHttp'; import { captureClientError, installWebviewLogBridge, @@ -158,13 +151,6 @@ export function AuthenticatedClient({ const [loginBusy, setLoginBusy] = useState(false); const [codeBusy, setCodeBusy] = useState(false); const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0); - const initialServerSelection = getClientServerSelection(); - const [serverSelection, setServerSelection] = useState( - initialServerSelection, - ); - const [customServerUrl, setCustomServerUrl] = useState( - initialServerSelection.customBaseUrl, - ); useEffect(() => { const uninstallWebviewLogBridge = installWebviewLogBridge(); const handleError = (event: ErrorEvent) => { @@ -184,37 +170,6 @@ export function AuthenticatedClient({ }; }, []); - function persistServerSelection() { - try { - const next = setClientServerSelection({ - preset: serverSelection.preset, - customBaseUrl: customServerUrl, - }); - setServerSelection(next); - return next; - } catch (error) { - void captureClientError(error, { - source: 'auth-hydrate', - action: 'restore-session', - }); - setLoginStatus(error instanceof Error ? error.message : String(error)); - return null; - } - } - - function handleServerPresetChange(preset: ClientServerPreset) { - if (preset === 'custom') { - setServerSelection((current) => ({ ...current, preset })); - return; - } - const next = setClientServerSelection({ - preset, - customBaseUrl: customServerUrl, - }); - setServerSelection(next); - setLoginStatus(`已选择 ${preset} 服务器`); - } - useEffect(() => { let disposed = false; async function hydrateAuth() { @@ -430,11 +385,7 @@ export function AuthenticatedClient({ if (codeBusy || codeCooldownSeconds > 0) { return; } - const persistedSelection = persistServerSelection(); - if (!persistedSelection) { - return; - } - const apiBaseUrl = getClientServerBaseUrl(persistedSelection); + const apiBaseUrl = getClientServerBaseUrl(); const normalizedPhone = normalizeAuthPhoneInput(phone); if (!normalizedPhone) { setLoginStatus('请输入手机号'); @@ -479,11 +430,7 @@ export function AuthenticatedClient({ setLoginStatus('请输入密码'); return; } - const persistedSelection = persistServerSelection(); - if (!persistedSelection) { - return; - } - const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection); + const loginApiBaseUrl = getClientServerBaseUrl(); const loginAttempt = (loginAttemptRef.current += 1); setLoginBusy(true); setLoginStatus('正在登录'); @@ -635,50 +582,6 @@ export function AuthenticatedClient({ ) : null} - - {serverSelection.preset === 'custom' ? ( - - ) : null}