diff --git a/.env.example b/.env.example index f11a17f8f..e7dbc59ac 100644 --- a/.env.example +++ b/.env.example @@ -235,6 +235,10 @@ VITE_DEBUG_MODE="" # This is read by api-server and exposed through /api/runtime/frontend-config. GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false" +# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。 +# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。 +GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev" + # Optional: official VikingDB credentials for regenerating build-tag similarities # with the Python embedding script. The script auto-loads `.env.local` and uses # the fixed `bge-large-zh` embedding model. diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx index a48273fae..ac4bb8e80 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx @@ -154,6 +154,27 @@ test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async () ); }); +test('灰度发布页可选择模板库并默认启用零比例灰度', async () => { + const user = userEvent.setup(); + render( + , + ); + await screen.findByRole('button', { name: 'editor.new-toolbar' }); + await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), ['agc']); + expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe( + 'agc:template-library', + ); + expect( + (screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value, + ).toBe('template-library'); + expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe( + true, + ); + expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe( + '0', + ); +}); + test('灰度发布页保存时转换数组和百分比', async () => { const user = userEvent.setup(); vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({ diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx index 00d6d742f..814f2ac34 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx @@ -27,9 +27,17 @@ interface GateTargetOption { const GATE_PREFIX_LABELS: Record = { 'image-editor': '画布', + agc: '客户端', }; const FIXED_GATE_TARGETS: GateTargetOption[] = [ + { + prefix: 'agc', + suffix: 'template-library', + key: 'agc:template-library', + label: '模板库', + description: '客户端模板库灰度', + }, { prefix: 'image-editor', suffix: 'agent-sidebar', @@ -180,7 +188,7 @@ export function AdminGrayReleaseConfigPage({ setSelectedGateKey(''); setGatePrefix(option.prefix); setGateKey(option.key); - setEnabled(false); + setEnabled(option.key === 'agc:template-library'); setRolloutPercent('0'); setAllowUserIds(''); setAllowUserTags(''); diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index ecee04d4c..3917f6f9f 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -68,7 +68,7 @@ export function resolveReleaseContext(args = [], env = process.env) { ); return Object.freeze({ target, - channel: resolveReleaseChannel(env, target), + channel: resolveReleaseChannel(env), bundleRoot: path.join( appRoot, 'src-tauri', @@ -87,14 +87,14 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock'); const defaultOssBaseUrl = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; -/** - * 发布渠道 → 目标平台。渠道名会进入 OSS 路径并烘焙进客户端端点, - * 一旦发布就不能改名(改名等于已发布客户端再也找不到更新)。 - */ -const releaseChannels = { - 'dev-win': 'windows', - 'dev-mac': 'darwin', -}; +const reservedChannelNames = new Set([ + 'win', + 'mac', + 'windows', + 'macos', + 'darwin', + 'linux', +]); /** * 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须 @@ -162,39 +162,36 @@ export function resolveReleasePlatform(target = defaultTarget()) { throw new Error(`不支持的发布目标:${target}`); } -export function resolveReleaseChannel( - env = process.env, - target = defaultTarget(), -) { - const platform = resolveReleasePlatform(target); - const requested = env.AGC_UPDATE_CHANNEL?.trim(); - if (requested) { - const channelPlatform = releaseChannels[requested]; - if (!channelPlatform) { - throw new Error( - `未知发布渠道 ${requested};当前支持:${Object.keys(releaseChannels).join('、')}`, - ); - } - if (channelPlatform !== platform) { - throw new Error( - `渠道 ${requested} 只能用于 ${channelPlatform} 目标,当前构建目标为 ${target}`, - ); - } - return requested; - } - const defaultChannel = Object.entries(releaseChannels).find( - ([, channelPlatform]) => channelPlatform === platform, - )?.[0]; - if (!defaultChannel) { +export function resolveReleaseChannel(env = process.env) { + const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev'; + if ( + !/^[a-z][a-z0-9-]{0,31}$/u.test(channel) || + channel.endsWith('-') || + reservedChannelNames.has(channel) || + /-(win|mac)$/u.test(channel) + ) { throw new Error( - `目标 ${target} 没有默认发布渠道,请显式设置 AGC_UPDATE_CHANNEL`, + '发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道', ); } - return defaultChannel; + return channel; } -export function updateManifestUrl(channel = resolveReleaseChannel()) { - return `${ossBaseUrl()}/${channel}/latest.json`; +/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */ +export function resolveReleasePartition( + channel = resolveReleaseChannel(), + target = defaultTarget(), +) { + channel = resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }); + validateReleaseTarget(target); + return `${channel}-${resolveReleasePlatform(target) === 'windows' ? 'win' : 'mac'}`; +} + +export function updateManifestUrl( + channel = resolveReleaseChannel(), + target = defaultTarget(), +) { + return `${ossBaseUrl()}/${resolveReleasePartition(channel, target)}/latest.json`; } /** @@ -245,8 +242,8 @@ async function readManifestVersion(manifestUrl, label) { } /** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */ -async function readRemoteChannelManifest(channel = resolveReleaseChannel()) { - return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单'); +async function readRemoteChannelManifest(channel, target) { + return fetchManifest(updateManifestUrl(channel, target), 'OSS 渠道清单'); } /** @@ -258,14 +255,17 @@ async function readRemoteChannelManifest(channel = resolveReleaseChannel()) { */ export async function resolvePreviousReleaseCommit( channel = resolveReleaseChannel(), - { override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {}, + { + override = process.env.AGC_UPDATE_PREVIOUS_COMMIT, + target = defaultTarget(), + } = {}, ) { const explicit = override?.trim(); if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) { return explicit; } try { - const manifest = await readRemoteChannelManifest(channel); + const manifest = await readRemoteChannelManifest(channel, target); const commit = typeof manifest?.commit === 'string' ? manifest.commit.trim() : ''; return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null; @@ -287,12 +287,14 @@ export async function resolvePreviousReleaseCommit( */ export async function resolveRemoteHighWaterVersion( channel = resolveReleaseChannel(), + target = defaultTarget(), ) { const channelVersion = await readManifestVersion( - updateManifestUrl(channel), + updateManifestUrl(channel, target), 'OSS 渠道清单', ); - if (channel !== 'dev-win') return channelVersion; + if (channel !== 'dev' || resolveReleasePlatform(target) !== 'windows') + return channelVersion; const legacyVersion = await readManifestVersion( legacyBridgeManifestUrl(), 'OSS 迁移指针', @@ -310,9 +312,9 @@ function replaceVersionLine(source, version, pattern, label) { } export async function prepareReleaseVersion(context = resolveReleaseContext()) { - const { channel } = context; + const { channel, target } = context; const localVersion = parseVersion(readPackageJson().version, '本地版本'); - const remoteVersion = await resolveRemoteHighWaterVersion(channel); + const remoteVersion = await resolveRemoteHighWaterVersion(channel, target); const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); const nextVersion = requestedVersion ? parseVersion(requestedVersion, '指定版本') @@ -401,24 +403,27 @@ export function buildTauriBuildArguments( } /** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */ -export function createChannelConfig(channel = resolveReleaseChannel()) { +export function createChannelConfig( + channel = resolveReleaseChannel(), + target = defaultTarget(), +) { return { plugins: { updater: { - endpoints: [updateManifestUrl(channel)], + endpoints: [updateManifestUrl(channel, target)], }, }, }; } -function writeChannelConfigFile(channel) { +function writeChannelConfigFile(channel, target) { const configPath = path.join( os.tmpdir(), - `agc-tauri-channel-${channel}.json`, + `agc-tauri-channel-${channel}-${target}.json`, ); fs.writeFileSync( configPath, - `${JSON.stringify(createChannelConfig(channel), null, 2)}\n`, + `${JSON.stringify(createChannelConfig(channel, target), null, 2)}\n`, ); return configPath; } @@ -435,8 +440,8 @@ export function runTauriBuild( throw new Error('构建参数与发布上下文目标不一致'); } const tauriArguments = buildTauriBuildArguments(args, context.target); - const { channel } = context; - const configPath = writeChannelConfigFile(channel); + const { channel, target } = context; + const configPath = writeChannelConfigFile(channel, target); console.log( `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`, ); @@ -552,7 +557,7 @@ export function createUpdateManifest( artifactPath, { target = defaultTarget(), - channel = resolveReleaseChannel(process.env, target), + channel = resolveReleaseChannel(), publishedAt = new Date().toISOString(), notes = readReleaseNotes(), commit = readHeadCommit(), @@ -560,7 +565,7 @@ export function createUpdateManifest( } = {}, ) { validateReleaseTarget(target); - resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target); + const partition = resolveReleasePartition(channel, target); const signature = readUpdaterSignature(artifactPath); const version = readPackageJson().version; const firstInstallArtifact = selectFirstInstallArtifact( @@ -568,8 +573,8 @@ export function createUpdateManifest( { 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 url = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`; + const downloadUrl = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`; const platforms = {}; const downloads = {}; for (const key of resolveManifestPlatformKeys(target)) { @@ -702,14 +707,22 @@ export function formatRecentReleaseNotes(commits) { /** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */ export function createLegacyUpdateManifest( artifactPath, - { channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {}, + { + channel = resolveReleaseChannel(), + target = defaultTarget(), + notes = readReleaseNotes(), + } = {}, ) { + const partition = resolveReleasePartition(channel, target); + if (partition !== 'dev-win') { + throw new Error('旧协议迁移清单只属于 dev 渠道的 Windows 系统'); + } const bytes = fs.readFileSync(artifactPath); const version = readPackageJson().version; const fileName = path.basename(artifactPath); return { version, - downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`, + downloadUrl: `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`, sha256: createHash('sha256').update(bytes).digest('hex'), size: bytes.length, ...(notes ? { releaseNotes: notes } : {}), @@ -731,7 +744,9 @@ export async function generateUpdateManifest( artifact, }); const manualNotes = readReleaseNotes(); - const previousCommit = await resolvePreviousReleaseCommit(channel); + const previousCommit = await resolvePreviousReleaseCommit(channel, { + target, + }); const commits = collectReleaseCommits(previousCommit); const recentCommits = previousCommit ? null : collectRecentReleaseCommits(); const notes = @@ -757,8 +772,8 @@ export async function generateUpdateManifest( notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n', ); const legacyManifest = - channel === 'dev-win' - ? createLegacyUpdateManifest(artifact, { channel, notes }) + channel === 'dev' && resolveReleasePlatform(target) === 'windows' + ? createLegacyUpdateManifest(artifact, { channel, target, notes }) : null; const legacyManifestPath = legacyManifest ? path.join(bundleRoot, 'legacy-latest.json') @@ -789,6 +804,7 @@ export async function generateUpdateManifest( } return { channel, + target, artifact, downloadArtifact, manifest, 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 cd0001741..83f96a0a0 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -30,6 +30,7 @@ import { resolvePreviousReleaseCommit, resolveReleaseChannel, resolveReleaseContext, + resolveReleasePartition, resolveRemoteHighWaterVersion, runTauriBuild, selectFirstInstallArtifact, @@ -126,32 +127,60 @@ test('does not select unsupported files', () => { ); }); -test('resolves the channel from the target platform and rejects mismatches', () => { - assert.equal(resolveReleaseChannel({}, windowsTarget), 'dev-win'); - assert.equal(resolveReleaseChannel({}, universalTarget), 'dev-mac'); +test('channels are independent of platform and accept release and custom names', () => { + assert.equal(resolveReleaseChannel({}), 'dev'); + for (const channel of ['dev', 'release', 'beta-2', 'a'.repeat(32)]) { + assert.equal( + resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }), + channel, + ); + assert.equal( + resolveReleasePartition(channel, windowsTarget), + `${channel}-win`, + ); + assert.equal( + resolveReleasePartition(channel, 'aarch64-apple-darwin'), + `${channel}-mac`, + ); + } assert.equal( - resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, universalTarget), + resolveReleaseChannel({ AGC_UPDATE_CHANNEL: ' release ' }), + 'release', + ); + for (const channel of [ + '', + ' ', + 'win', + 'mac', + 'windows', + 'macos', + 'darwin', + 'linux', + 'dev-win', 'dev-mac', - ); - assert.throws( - () => - resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, windowsTarget), - /只能用于 darwin 目标/u, - ); - assert.throws( - () => - resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'beta-win' }, windowsTarget), - /未知发布渠道/u, - ); + 'Release', + '../dev', + 'a/b', + 'a_b', + '-beta', + 'beta-', + '1beta', + 'a'.repeat(33), + ]) { + assert.throws( + () => resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }), + /发布渠道无效/u, + ); + } }); test('channel manifest URL and build-time endpoint follow the channel', () => { withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { assert.equal( - updateManifestUrl('dev-win'), + updateManifestUrl('dev', windowsTarget), 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json', ); - assert.deepEqual(createChannelConfig('dev-mac'), { + assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), { plugins: { updater: { endpoints: [ @@ -160,6 +189,15 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { }, }, }); + assert.equal( + updateManifestUrl('release', windowsTarget), + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/release-win/latest.json', + ); + assert.equal( + createChannelConfig('beta-2', 'x86_64-apple-darwin').plugins.updater + .endpoints[0], + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/beta-2-mac/latest.json', + ); }); }); @@ -185,7 +223,7 @@ test('release context resolves explicit targets before environment/default and f for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) { const context = resolveReleaseContext(args, env); assert.equal(context.target, 'aarch64-apple-darwin'); - assert.equal(context.channel, 'dev-mac'); + assert.equal(context.channel, 'dev'); assert.match( context.bundleRoot.replaceAll('\\', '/'), /target\/aarch64-apple-darwin\/release\/bundle$/, @@ -194,14 +232,14 @@ test('release context resolves explicit targets before environment/default and f } assert.throws( () => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }), - /只能用于 windows/, + /发布渠道无效/, ); } assert.equal(resolveReleaseContext([], {}).target, windowsTarget); assert.equal( resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' }) .channel, - 'dev-mac', + 'dev', ); for (const args of [ ['--target'], @@ -231,7 +269,10 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and prepareVersion: async (context) => { seenContexts.push(context); assert.equal( - await resolveRemoteHighWaterVersion(context.channel), + await resolveRemoteHighWaterVersion( + context.channel, + context.target, + ), '0.1.67', ); }, @@ -406,7 +447,7 @@ test('manifest writer refuses to create latest when the current Mac DMG is missi } }); -test('invalid target or mismatched channel fails before any release side effect', async () => { +test('invalid target or platform used as channel fails before any release side effect', async () => { let touched = false; const sideEffects = { prepareVersion: () => { @@ -426,7 +467,7 @@ test('invalid target or mismatched channel fails before any release side effect' await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () => assert.rejects( () => buildRelease(['--target=aarch64-apple-darwin'], sideEffects), - /只能用于 windows/, + /发布渠道无效/, ), ); assert.equal(touched, false); @@ -440,7 +481,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme AGC_BUILD_TARGET: 'aarch64-apple-darwin', }), ]) { - assert.equal(context.channel, 'dev-win'); + assert.equal(context.channel, 'dev'); assert.equal( selectReleaseArtifact(files, context.target), '/tmp/windows.exe', @@ -484,14 +525,14 @@ test('no-bundle smoke skips version writes and manifest generation', async () => steps.push('manifest'); }, }); - assert.deepEqual(steps, ['dev-mac']); + assert.deepEqual(steps, ['dev']); }); test('channel manifest carries version, platform keys and signature', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => { const manifest = createUpdateManifest(artifact, { - channel: 'dev-win', + channel: 'dev', target: windowsTarget, publishedAt: '2026-09-17T00:00:00.000Z', }); @@ -522,7 +563,7 @@ test('missing signature fails the channel manifest closed', () => { assert.throws( () => createUpdateManifest(artifact, { - channel: 'dev-win', + channel: 'dev', target: windowsTarget, }), /缺少更新包签名/u, @@ -535,7 +576,7 @@ test('missing signature fails the channel manifest closed', () => { test('legacy manifest keeps the sha256 contract of published clients', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { const legacy = createLegacyUpdateManifest(artifact, { - channel: 'dev-win', + channel: 'dev', }); assert.match(legacy.version, /^\d+\.\d+\.\d+$/u); assert.equal(legacy.sha256.length, 64); @@ -551,6 +592,86 @@ test('next release version follows the higher local or channel version', () => { assert.equal(nextPatchVersion('0.1.12', null), '0.1.13'); }); +for (const channel of ['release', 'beta-2']) { + for (const target of [windowsTarget, 'aarch64-apple-darwin']) { + test(`${channel} ${target} freezes its endpoint, version source and published objects`, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-manifest-')); + try { + const windows = target === windowsTarget; + const partition = `${channel}-${windows ? 'win' : 'mac'}`; + const artifact = path.join( + root, + windows ? '陶泥儿_x64-setup.exe' : '陶泥儿.app.tar.gz', + ); + writeFileSync(artifact, 'updater package'); + writeFileSync(`${artifact}.sig`, 'updater signature'); + if (!windows) createDmgFixture(root, target); + const context = { + ...resolveReleaseContext([`--target=${target}`], { + AGC_UPDATE_CHANNEL: channel, + }), + bundleRoot: root, + }; + const requests = []; + const result = await withStubbedFetch( + (url) => { + requests.push(url); + assert.ok(url.endsWith(`/agc/${partition}/latest.json`)); + return jsonResponse({ + version: '2.3.4', + commit: 'abcdef1234567890', + }); + }, + async () => { + assert.equal( + await resolveRemoteHighWaterVersion( + context.channel, + context.target, + ), + '2.3.4', + ); + runTauriBuild([`--target=${target}`], context, { + spawn: (_binary, command) => { + const config = JSON.parse( + readFileSync( + command[command.lastIndexOf('--config') + 1], + 'utf8', + ), + ); + assert.ok( + config.plugins.updater.endpoints[0].endsWith( + `/agc/${partition}/latest.json`, + ), + ); + return { status: 0 }; + }, + }); + return generateUpdateManifest(context); + }, + ); + assert.equal(result.channel, channel); + assert.equal(result.target, target); + assert.equal(result.manifest.version, packageVersion); + assert.equal(result.legacyManifestPath, null); + assert.equal(result.legacyManifest, null); + assert.equal(requests.length, 2); + for (const entry of [ + ...Object.values(result.manifest.platforms), + ...Object.values(result.manifest.downloads), + ]) { + assert.ok(entry.url.includes(`/agc/${partition}/${packageVersion}/`)); + } + assert.throws( + () => createLegacyUpdateManifest(artifact, { channel, target }), + /只属于 dev 渠道/u, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + } +} + test('version high water keeps the legacy pointer during the migration window', async () => { await withStubbedFetch( (url) => @@ -558,7 +679,10 @@ test('version high water keeps the legacy pointer during the migration window', ? jsonResponse({}, 404) : jsonResponse({ version: '0.1.57' }), async () => { - assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57'); + assert.equal( + await resolveRemoteHighWaterVersion('dev', windowsTarget), + '0.1.57', + ); // 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。 assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58'); }, @@ -572,7 +696,10 @@ test('version high water takes the higher of channel and legacy pointer', async ? jsonResponse({ version: '0.1.60' }) : jsonResponse({ version: '0.1.57' }), async () => { - assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60'); + assert.equal( + await resolveRemoteHighWaterVersion('dev', windowsTarget), + '0.1.60', + ); }, ); }); @@ -587,7 +714,16 @@ test('version high water ignores the windows migration pointer for other channel return jsonResponse({ version: '0.1.12' }); }, async () => { - assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12'); + for (const [channel, target] of [ + ['dev', 'aarch64-apple-darwin'], + ['release', windowsTarget], + ['beta-2', windowsTarget], + ]) { + assert.equal( + await resolveRemoteHighWaterVersion(channel, target), + '0.1.12', + ); + } }, ); }); @@ -597,20 +733,20 @@ test('release notes anchor prefers the explicit commit and falls back to the man () => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }), async () => { assert.equal( - await resolvePreviousReleaseCommit('dev-win', { + await resolvePreviousReleaseCommit('dev', { override: '6017d46088c04199e99cf89f347b12d67591475e', }), '6017d46088c04199e99cf89f347b12d67591475e', ); // 覆盖值非法时忽略,继续用清单里的 commit。 assert.equal( - await resolvePreviousReleaseCommit('dev-win', { + await resolvePreviousReleaseCommit('dev', { override: 'not-a-sha', }), 'abcdef1234567890', ); assert.equal( - await resolvePreviousReleaseCommit('dev-win', { override: ' ' }), + await resolvePreviousReleaseCommit('dev', { override: ' ' }), 'abcdef1234567890', ); }, @@ -620,7 +756,7 @@ test('release notes anchor prefers the explicit commit and falls back to the man () => jsonResponse({ version: '0.1.61' }), async () => { assert.equal( - await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + await resolvePreviousReleaseCommit('dev', { override: undefined }), null, ); }, @@ -634,7 +770,7 @@ test('release notes anchor degrades to null when the manifest cannot be read', a }; try { assert.equal( - await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + await resolvePreviousReleaseCommit('dev', { override: undefined }), null, ); } finally { diff --git a/apps/ai-game-creator-shell/scripts/release-oss.mjs b/apps/ai-game-creator-shell/scripts/release-oss.mjs index b136145b8..7f0caac10 100644 --- a/apps/ai-game-creator-shell/scripts/release-oss.mjs +++ b/apps/ai-game-creator-shell/scripts/release-oss.mjs @@ -1,6 +1,8 @@ import { spawnSync } from 'node:child_process'; import path from 'node:path'; +import { resolveReleasePartition } from './build-release.mjs'; + /** * 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式, * 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。 @@ -34,16 +36,28 @@ export function createReleaseUploadPlan( artifact, downloadArtifact, channel, + target, manifest, manifestPath, legacyManifestPath, }, bucket, ) { - if (!artifact || !downloadArtifact || !manifestPath || !manifest?.version) { - throw new Error('发布结果缺少更新包、首装包或清单'); + if ( + !artifact || + !downloadArtifact || + !manifestPath || + !manifest?.version || + !channel || + !target + ) { + throw new Error('发布结果缺少渠道、构建目标、更新包、首装包或清单'); } - const prefix = `oss://${bucket}/agc/${channel}`; + const partition = resolveReleasePartition(channel, target); + if (legacyManifestPath && partition !== 'dev-win') { + throw new Error('旧协议迁移清单只属于 dev 渠道的 Windows 系统'); + } + const prefix = `oss://${bucket}/agc/${partition}`; const artifacts = [ ...new Set( [artifact, `${artifact}.sig`, downloadArtifact].map((file) => 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 813ff5910..0f6a70f13 100644 --- a/apps/ai-game-creator-shell/scripts/release-oss.test.mjs +++ b/apps/ai-game-creator-shell/scripts/release-oss.test.mjs @@ -40,22 +40,24 @@ test('printed upload command keeps arguments and hides credentials', () => { ); }); -function withReleaseFixture(channel, architecture, run) { +function withReleaseFixture(channel, architecture, run, platform = 'macos') { const root = mkdtempSync(path.join(os.tmpdir(), 'agc-upload-plan-')); try { const artifact = path.join( root, - channel === 'dev-win' + platform === 'windows' ? '陶泥儿_1.2.3_x64-setup.exe' : '陶泥儿.app.tar.gz', ); const downloadArtifact = - channel === 'dev-win' + platform === 'windows' ? 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; + channel === 'dev' && platform === 'windows' + ? path.join(root, 'legacy-latest.json') + : null; for (const file of [ artifact, `${artifact}.sig`, @@ -69,6 +71,10 @@ function withReleaseFixture(channel, architecture, run) { artifact, downloadArtifact, channel, + target: + platform === 'windows' + ? 'x86_64-pc-windows-msvc' + : `${architecture === 'aarch64' ? 'aarch64' : 'x86_64'}-apple-darwin`, manifest: { version: '1.2.3' }, manifestPath, legacyManifestPath, @@ -86,7 +92,7 @@ const uploadOptions = { for (const architecture of ['aarch64', 'x64']) { test(`uploads every ${architecture} Mac object before the channel pointer`, () => { - withReleaseFixture('dev-mac', architecture, (release) => { + withReleaseFixture('dev', architecture, (release) => { const calls = []; uploadReleaseArtifacts(release, { ...uploadOptions, @@ -120,37 +126,42 @@ for (const architecture of ['aarch64', 'x64']) { } 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), - ); - }); + withReleaseFixture( + 'dev', + '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), + ); + }, + 'windows', + ); }); for (const failedArtifactIndex of [0, 1, 2]) { test(`failed Mac object ${failedArtifactIndex} prevents both later objects and latest publication`, () => { - withReleaseFixture('dev-mac', 'aarch64', (release) => { + withReleaseFixture('dev', 'aarch64', (release) => { const destinations = []; assert.throws( () => @@ -176,7 +187,7 @@ for (const failedArtifactIndex of [0, 1, 2]) { } test('dry run prints the complete plan without spawning uploads or exposing credentials', () => { - withReleaseFixture('dev-mac', 'aarch64', (release) => { + withReleaseFixture('dev', 'aarch64', (release) => { const output = []; uploadReleaseArtifacts(release, { ...uploadOptions, @@ -195,3 +206,32 @@ test('dry run prints the complete plan without spawning uploads or exposing cred assert.doesNotMatch(output.join('\n'), /fixture-id|fixture-secret|已上传/u); }); }); + +for (const channel of ['release', 'beta-2']) { + for (const platform of ['windows', 'macos']) { + test(`${channel} ${platform} uploads only its own partition and cannot write the dev bridge`, () => { + withReleaseFixture( + channel, + 'x64', + (release) => { + const plan = createReleaseUploadPlan(release, 'agc-dev'); + const suffix = platform === 'windows' ? 'win' : 'mac'; + const prefix = `oss://agc-dev/agc/${channel}-${suffix}/`; + assert.ok( + plan.every(({ destination }) => destination.startsWith(prefix)), + ); + assert.equal(plan.at(-1).destination, `${prefix}latest.json`); + assert.throws( + () => + createReleaseUploadPlan( + { ...release, legacyManifestPath: release.manifestPath }, + 'agc-dev', + ), + /只属于 dev 渠道/u, + ); + }, + platform, + ); + }); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 5b806ed6e..a850b381c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2538,6 +2538,7 @@ fn main() { create_automatic_local_game_project_from_template, init_local_game_project, fetch_game_template_library, + get_game_template_library_access, download_game_template, import_local_godot_project, import_local_cocos_project, diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index 6b1e7b8b9..da12b43c6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -5,6 +5,10 @@ //! 清单、zip 与封面一律先校验再落盘,zip 解压只接受普通文件与目录。 use super::*; +use crate::platform_session::{ + current_platform_session, validate_platform_session_identity, + with_validated_platform_session_identity, PlatformSessionIdentity, PlatformSessionSnapshot, +}; use serde::{Deserialize, Serialize}; const TEMPLATE_LIBRARY_SCHEMA_VERSION: &str = "agc-template-library.v1"; @@ -24,6 +28,74 @@ const TEMPLATE_ARCHIVE_MAX_FILES: usize = 4_096; const TEMPLATE_ARCHIVE_MAX_FILE_BYTES: u64 = 256 * 1024 * 1024; const TEMPLATE_ID_MAX_CHARS: usize = 64; const TEMPLATE_VERSION_MAX_CHARS: usize = 32; +const TEMPLATE_ACCESS_ERROR: &str = "template-library-unavailable: 模板库暂未向当前账号开放"; + +async fn template_library_access_for_session( + session: &PlatformSessionSnapshot, +) -> Result { + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(15)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "template-library-unavailable: 无法检查模板库权限".to_string())?; + let response = client + .get(format!( + "{}/api/runtime/frontend-config", + session.api_base_url.trim_end_matches('/') + )) + .bearer_auth(&session.access_token) + .send() + .await + .map_err(|_| "template-library-unavailable: 检查模板库权限失败,请重试".to_string())?; + validate_platform_session_identity(&session.identity())?; + if !response.status().is_success() { + return Err(format!( + "template-library-unavailable: 检查模板库权限返回 HTTP {}", + response.status().as_u16() + )); + } + const MAX_BYTES: usize = 64 * 1024; + if response + .content_length() + .is_some_and(|length| length > MAX_BYTES as u64) + { + return Err("template-library-unavailable: 模板库权限响应无效".to_string()); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = + chunk.map_err(|_| "template-library-unavailable: 读取模板库权限失败".to_string())?; + if bytes.len() + chunk.len() > MAX_BYTES { + return Err("template-library-unavailable: 模板库权限响应无效".to_string()); + } + bytes.extend_from_slice(&chunk); + } + validate_platform_session_identity(&session.identity())?; + let payload: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|_| "template-library-unavailable: 模板库权限响应无效".to_string())?; + Ok(payload + .get("agcTemplateLibraryEnabled") + .and_then(|value| value.as_bool()) + == Some(true)) +} + +async fn require_template_library_access() -> Result { + let session = current_platform_session().ok_or_else(|| TEMPLATE_ACCESS_ERROR.to_string())?; + if !template_library_access_for_session(&session).await? { + return Err(TEMPLATE_ACCESS_ERROR.to_string()); + } + Ok(session.identity()) +} + +#[tauri::command] +pub(crate) async fn get_game_template_library_access() -> Result { + let Some(session) = current_platform_session() else { + return Ok(false); + }; + template_library_access_for_session(&session).await +} /// 远端清单里的单个模板条目(`templates/index.json` 中的 `templates[]`)。 #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] @@ -719,7 +791,9 @@ async fn ensure_template_installed( cache_root: &Path, template_id: &str, template_version: &str, + identity: &PlatformSessionIdentity, ) -> Result { + validate_platform_session_identity(identity)?; let installed_directory = installed_template_dir(cache_root, template_id, template_version)?; if let Some(record) = read_installed_record(&installed_directory) { return Ok(record); @@ -728,13 +802,16 @@ async fn ensure_template_installed( let client = build_template_library_client(); let url = template_object_url(&summary.zip_key)?; let bytes = fetch_limited_bytes(&client, &url, TEMPLATE_ARCHIVE_MAX_BYTES).await?; - install_template_archive(cache_root, &summary, &bytes) + with_validated_platform_session_identity(identity, || { + install_template_archive(cache_root, &summary, &bytes) + }) } #[tauri::command] pub(crate) async fn fetch_game_template_library( app: tauri::AppHandle, ) -> Result { + let identity = require_template_library_access().await?; let cache_root = template_cache_root(&app)?; ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; let index_url = format!( @@ -749,7 +826,10 @@ pub(crate) async fn fetch_game_template_library( let body = String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?; parse_game_template_library_index(&body)?; - write_cached_index(&cache_root, &body); + with_validated_platform_session_identity(&identity, || { + write_cached_index(&cache_root, &body); + Ok(()) + })?; (body, "network") } Err(error) => match read_cached_index(&cache_root) { @@ -760,6 +840,7 @@ pub(crate) async fn fetch_game_template_library( None => return Err(error), }, }; + validate_platform_session_identity(&identity)?; let (header, templates) = parse_game_template_library_index(&body)?; let installed = collect_installed_records(&cache_root); let entries = templates @@ -789,10 +870,17 @@ pub(crate) async fn download_game_template( template_id: String, template_version: String, ) -> Result { + let identity = require_template_library_access().await?; let cache_root = template_cache_root(&app)?; ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; - let record = - ensure_template_installed(&cache_root, template_id.trim(), template_version.trim()).await?; + let record = ensure_template_installed( + &cache_root, + template_id.trim(), + template_version.trim(), + &identity, + ) + .await?; + validate_platform_session_identity(&identity)?; Ok(InstalledGameTemplate { template_id: record.template_id, template_version: record.template_version, @@ -882,23 +970,137 @@ pub(crate) async fn create_automatic_local_game_project_from_template( planning: Option, projects_root: Option, ) -> Result { + let identity = require_template_library_access().await?; let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; let cache_root = template_cache_root(&app)?; ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; - let record = - ensure_template_installed(&cache_root, template_id.trim(), template_version.trim()).await?; - create_project_from_installed_template_at( - &projects_root, - Path::new(&record.project_dir), - name.as_deref(), - planning.unwrap_or(false), + let record = ensure_template_installed( + &cache_root, + template_id.trim(), + template_version.trim(), + &identity, ) + .await?; + with_validated_platform_session_identity(&identity, || { + create_project_from_installed_template_at( + &projects_root, + Path::new(&record.project_dir), + name.as_deref(), + planning.unwrap_or(false), + ) + }) } #[cfg(test)] mod tests { use super::*; + fn access_server( + status: u16, + body: &str, + change_identity: bool, + ) -> (String, std::thread::JoinHandle) { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let body = body.to_string(); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + while !request.windows(4).any(|part| part == b"\r\n\r\n") { + let size = socket.read(&mut buffer).unwrap(); + assert!(size > 0); + request.extend_from_slice(&buffer[..size]); + } + if change_identity { + crate::platform_session::clear_platform_session(2, 2); + } + write!(socket, "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + String::from_utf8(request).unwrap() + }); + (url, server) + } + + #[tokio::test] + async fn template_access_requires_current_account_and_explicit_server_grant() { + for (status, body, allowed) in [ + (200, r#"{"agcTemplateLibraryEnabled":true}"#, true), + (200, r#"{"agcTemplateLibraryEnabled":false}"#, false), + (200, r#"{"imageEditorAgentSidebarEnabled":true}"#, false), + (200, r#"{"agcTemplateLibraryEnabled":"true"}"#, false), + (503, r#"{"agcTemplateLibraryEnabled":true}"#, false), + (200, "invalid JSON", false), + ] { + let (origin, server) = access_server(status, body, false); + let _session = crate::platform_session::install_test_platform_session( + "template-user", + "template-test-token", + &origin, + ); + assert_eq!(require_template_library_access().await.is_ok(), allowed); + let request = server.join().unwrap().to_lowercase(); + assert!(request.starts_with("get /api/runtime/frontend-config ")); + assert!(request.contains("authorization: bearer template-test-token")); + } + let _session = crate::platform_session::clear_test_platform_session(); + assert!(!get_game_template_library_access().await.unwrap()); + assert!(require_template_library_access().await.is_err()); + } + + #[tokio::test] + async fn template_access_preserves_identity_during_token_rotation() { + let (origin, server) = access_server(200, r#"{"agcTemplateLibraryEnabled":true}"#, false); + let _session = crate::platform_session::install_test_platform_session( + "template-user", + "old-token", + &origin, + ); + let frozen = current_platform_session().unwrap(); + crate::platform_session::install_platform_session( + "template-user", + "new-token", + &origin, + 1, + 2, + ) + .unwrap(); + assert!(template_library_access_for_session(&frozen).await.unwrap()); + server.join().unwrap(); + } + + #[tokio::test] + async fn template_access_rejects_old_account_response_and_cached_install() { + let (origin, server) = access_server(200, r#"{"agcTemplateLibraryEnabled":true}"#, true); + let _session = crate::platform_session::install_test_platform_session( + "template-user", + "template-test-token", + &origin, + ); + let identity = current_platform_session().unwrap().identity(); + let error = require_template_library_access().await.unwrap_err(); + assert!(error.contains("authentication-required")); + server.join().unwrap(); + let error = ensure_template_installed( + Path::new("unused-cache"), + "demo-template", + "0.1.0", + &identity, + ) + .await + .unwrap_err(); + assert!(error.contains("authentication-required")); + assert!( + with_validated_platform_session_identity::<()>(&identity, || panic!( + "旧会话不得写入项目" + )) + .is_err() + ); + } + fn sample_index_body() -> String { serde_json::json!({ "schemaVersion": TEMPLATE_LIBRARY_SCHEMA_VERSION, diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index a58eb294e..ed96b3dda 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -94,10 +94,16 @@ export function WorkspaceLauncherShell({ rememberRecentWorkspace, }); const templateLibrary = useTemplateLibrary({ - onProjectCreated: async (result) => { - await homeProject.enterCreatedTemplateProject(result); + userId: currentUser.id, + onProjectCreated: async (result, isCurrent) => { + await homeProject.enterCreatedTemplateProject(result, isCurrent); }, }); + useEffect(() => { + if (!templateLibrary.enabled && launcherView === 'template-library') { + setLauncherView('home'); + } + }, [templateLibrary.enabled, launcherView]); const { projectPath, setProjectPath, @@ -560,6 +566,7 @@ export function WorkspaceLauncherShell({ > { resetLauncherHomeDraft(); @@ -606,6 +613,7 @@ export function WorkspaceLauncherShell({ }} onProjectPick={() => void homeProject.pickAndOpenProject()} templateRecommendations={templateLibrary.templates} + templateLibraryEnabled={templateLibrary.enabled} templateLibraryLoading={ templateLibrary.status === 'loading' || templateLibrary.status === 'idle' @@ -619,7 +627,7 @@ export function WorkspaceLauncherShell({ homeProject={homeProject} recentProjects={recentProjects} /> - ) : launcherView === 'template-library' ? ( + ) : launcherView === 'template-library' && templateLibrary.enabled ? ( setLauncherView('home')} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 6f501bf1b..6da9f8a7f 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -303,7 +303,10 @@ export function useHomeProjectCreation({ } } - async function enterProjectDevelopment(context: LauncherProjectContext) { + async function enterProjectDevelopment( + context: LauncherProjectContext, + isCurrent: () => boolean = () => true, + ) { const entryToken = (projectEntryTokenRef.current += 1); /** * 会话预览只认"内存 registry 里真的还在跑"的那一个(见 @@ -316,7 +319,7 @@ export function useHomeProjectCreation({ projectPath: context.projectPath, recordedPreview: context.manifest.preview ?? null, }); - if (entryToken !== projectEntryTokenRef.current) { + if (entryToken !== projectEntryTokenRef.current || !isCurrent()) { // 更晚的一次进项目已经接管工作区:这一次的结果(预览与项目上下文)全部丢弃, // 否则慢请求后到会把新项目覆盖回旧项目。 return; @@ -560,29 +563,37 @@ export function useHomeProjectCreation({ * 模板库建出的项目:模板文件与项目脚手架已在 Rust 侧一次落盘, * 这里只负责登记最近项目并走标准进项目通道(含会话预览核验与代次闸门)。 */ - async function enterCreatedTemplateProject(result: InitLocalProjectResult) { + async function enterCreatedTemplateProject( + result: InitLocalProjectResult, + isCurrent: () => boolean = () => true, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); } - await enterProjectDevelopment({ - projectPath: result.projectPath, - projectName: - result.manifest.name || projectNameFromPath(result.projectPath), - projectKind: 'web', - manifest: result.manifest, - projectRevision: await readCurrentProjectRevision( - invoke, - result.projectPath, - ), - creationType: null, - startMode: null, - initialPrompt: '', - attachments: [], - recentRunStatus: null, - recentRunStopReason: null, - createdAt: Date.now(), - }); + const projectRevision = await readCurrentProjectRevision( + invoke, + result.projectPath, + ); + if (!isCurrent()) return; + await enterProjectDevelopment( + { + projectPath: result.projectPath, + projectName: + result.manifest.name || projectNameFromPath(result.projectPath), + projectKind: 'web', + manifest: result.manifest, + projectRevision, + creationType: null, + startMode: null, + initialPrompt: '', + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + createdAt: Date.now(), + }, + isCurrent, + ); } async function openProject(nextProjectPath: string, mode: 'open' | 'create') { diff --git a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts index bcad3c06b..f2f835f09 100644 --- a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts +++ b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts @@ -10,6 +10,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; import type { InitLocalProjectResult } from '../../app/types'; +import { + currentPlatformSessionGeneration, + subscribePlatformSessionGeneration, +} from '../../services/platformSession'; import { readProjectCreationDirectory } from '../app-shell/model'; import { collectGameTemplateRuntimes, @@ -29,8 +33,12 @@ export type TemplateLibraryStatus = 'idle' | 'loading' | 'ready' | 'error'; export type TemplateLibraryBusyKind = 'download' | 'create'; type UseTemplateLibraryOptions = { + userId: string; /** 项目已建好:由调用方负责进入项目工作区(模板库不碰工作区状态)。 */ - onProjectCreated: (result: InitLocalProjectResult) => Promise | void; + onProjectCreated: ( + result: InitLocalProjectResult, + isCurrent: () => boolean, + ) => Promise | void; }; function errorMessage(error: unknown): string { @@ -38,8 +46,25 @@ function errorMessage(error: unknown): string { } export function useTemplateLibrary({ + userId, onProjectCreated, }: UseTemplateLibraryOptions) { + const scopeRef = useRef({ + userId, + generation: 0, + authorityGeneration: currentPlatformSessionGeneration(), + allowed: false, + }); + if (scopeRef.current.userId !== userId) { + scopeRef.current = { + userId, + generation: 0, + authorityGeneration: currentPlatformSessionGeneration(), + allowed: false, + }; + } + const [access, setAccess] = useState({ userId: '', enabled: false }); + const enabled = access.userId === userId && access.enabled; const [snapshot, setSnapshot] = useState( null, ); @@ -53,88 +78,171 @@ export function useTemplateLibrary({ const [busyKind, setBusyKind] = useState( null, ); - const loadingRef = useRef(false); + const refreshSequence = useRef(0); + + const revokeAccess = useCallback(() => { + scopeRef.current.generation += 1; + scopeRef.current.allowed = false; + setAccess({ userId: scopeRef.current.userId, enabled: false }); + setSnapshot(null); + setStatus('idle'); + setError(''); + setNotice(''); + setFilters(EMPTY_TEMPLATE_LIBRARY_FILTERS); + setBusyTemplateId(null); + setBusyKind(null); + }, []); + + const handleOperationError = useCallback( + (nextError: unknown) => { + const message = errorMessage(nextError); + if ( + message.includes('template-library-unavailable:') || + message.includes('authentication-required:') + ) { + revokeAccess(); + } else { + setError(message); + } + }, + [revokeAccess], + ); const refresh = useCallback(async () => { - if (loadingRef.current) { + const scope = scopeRef.current; + if (scope.authorityGeneration !== currentPlatformSessionGeneration()) { + revokeAccess(); return; } + const generation = scope.generation; + const sequence = ++refreshSequence.current; + const isCurrent = () => + scopeRef.current === scope && + scope.authorityGeneration === currentPlatformSessionGeneration() && + scope.generation === generation && + refreshSequence.current === sequence; const invoke = resolveTauriInvoke(); if (!invoke) { - setStatus('error'); - setError('需要在陶泥儿客户端内运行'); + revokeAccess(); return; } - loadingRef.current = true; + let allowed: boolean; + try { + allowed = await invoke('get_game_template_library_access'); + } catch { + if (isCurrent()) revokeAccess(); + return; + } + if (!isCurrent()) return; + if (allowed !== true) { + revokeAccess(); + return; + } + scope.allowed = true; + setAccess({ userId: scope.userId, enabled: true }); setStatus('loading'); setError(''); try { const next = await invoke( 'fetch_game_template_library', ); + if (!isCurrent()) return; setSnapshot(next); setStatus('ready'); setNotice( next.source === 'cache' ? '远端清单暂时读不到,当前展示本机缓存' : '', ); } catch (nextError) { + if (!isCurrent()) return; setStatus('error'); - setError(errorMessage(nextError)); - } finally { - loadingRef.current = false; + handleOperationError(nextError); } - }, []); + }, [handleOperationError, revokeAccess]); useEffect(() => { + revokeAccess(); void refresh(); - }, [refresh]); + const onFocus = () => void refresh(); + const unsubscribe = subscribePlatformSessionGeneration((generation) => { + if (scopeRef.current.authorityGeneration !== generation) revokeAccess(); + }); + window.addEventListener('focus', onFocus); + return () => { + scopeRef.current.generation += 1; + scopeRef.current.allowed = false; + window.removeEventListener('focus', onFocus); + unsubscribe(); + }; + }, [userId, refresh, revokeAccess]); - const downloadTemplate = useCallback(async (template: GameTemplateEntry) => { - const invoke = resolveTauriInvoke(); - if (!invoke) { - throw new Error('需要在陶泥儿客户端内运行'); - } - setBusyTemplateId(template.id); - setBusyKind('download'); - setError(''); - try { - const installed = await invoke( - 'download_game_template', - { - templateId: template.id, - templateVersion: template.templateVersion, - }, - ); - setSnapshot((current) => - current - ? { - ...current, - templates: current.templates.map((entry) => - entry.id === template.id - ? { - ...entry, - installed: true, - installedVersion: installed.templateVersion, - installedAtMillis: installed.installedAtMillis, - } - : entry, - ), - } - : current, - ); - setNotice(`已下载模板「${template.title}」`); - return installed; - } catch (nextError) { - setError(errorMessage(nextError)); - throw nextError; - } finally { - setBusyTemplateId(null); - setBusyKind(null); - } - }, []); + const downloadTemplate = useCallback( + async (template: GameTemplateEntry) => { + const scope = scopeRef.current; + const generation = scope.generation; + const isCurrent = () => + scopeRef.current === scope && + scope.authorityGeneration === currentPlatformSessionGeneration() && + scope.generation === generation && + scope.allowed; + if (!isCurrent()) throw new Error('模板库暂未向当前账号开放'); + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在陶泥儿客户端内运行'); + } + setBusyTemplateId(template.id); + setBusyKind('download'); + setError(''); + try { + const installed = await invoke( + 'download_game_template', + { + templateId: template.id, + templateVersion: template.templateVersion, + }, + ); + if (!isCurrent()) throw new Error('登录态已变化,模板操作已停止'); + setSnapshot((current) => + current + ? { + ...current, + templates: current.templates.map((entry) => + entry.id === template.id + ? { + ...entry, + installed: true, + installedVersion: installed.templateVersion, + installedAtMillis: installed.installedAtMillis, + } + : entry, + ), + } + : current, + ); + setNotice(`已下载模板「${template.title}」`); + return installed; + } catch (nextError) { + if (isCurrent()) handleOperationError(nextError); + throw nextError; + } finally { + if (isCurrent()) { + setBusyTemplateId(null); + setBusyKind(null); + } + } + }, + [handleOperationError], + ); const createProjectFromTemplate = useCallback( async (template: GameTemplateEntry) => { + const scope = scopeRef.current; + const generation = scope.generation; + const isCurrent = () => + scopeRef.current === scope && + scope.authorityGeneration === currentPlatformSessionGeneration() && + scope.generation === generation && + scope.allowed; + if (!isCurrent()) throw new Error('模板库暂未向当前账号开放'); const invoke = resolveTauriInvoke(); if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); @@ -143,6 +251,7 @@ export function useTemplateLibrary({ if (needsTemplateDownload(template)) { await downloadTemplate(template); } + if (!isCurrent()) throw new Error('登录态已变化,模板操作已停止'); setBusyTemplateId(template.id); setBusyKind('create'); setError(''); @@ -158,23 +267,26 @@ export function useTemplateLibrary({ projectsRoot: readProjectCreationDirectory() || null, }, ); - await onProjectCreated(result); - setNotice(`已用模板「${template.title}」创建项目`); + if (!isCurrent()) throw new Error('登录态已变化,模板操作已停止'); + await onProjectCreated(result, isCurrent); + if (isCurrent()) setNotice(`已用模板「${template.title}」创建项目`); return result; } catch (nextError) { - setError(errorMessage(nextError)); + if (isCurrent()) handleOperationError(nextError); throw nextError; } finally { - setBusyTemplateId(null); - setBusyKind(null); + if (isCurrent()) { + setBusyTemplateId(null); + setBusyKind(null); + } } }, - [downloadTemplate, onProjectCreated], + [downloadTemplate, onProjectCreated, handleOperationError], ); const templates = useMemo( - () => snapshot?.templates ?? [], - [snapshot?.templates], + () => (enabled ? (snapshot?.templates ?? []) : []), + [enabled, snapshot?.templates], ); const visibleTemplates = useMemo( () => filterGameTemplates(templates, filters), @@ -218,6 +330,7 @@ export function useTemplateLibrary({ }, []); return { + enabled, snapshot, status, error, diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index ed160d88f..f81db0cc9 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -116,6 +116,7 @@ type HomeViewProps = { onProjectPick: () => void; /** 模板库推荐位:清单来自 Rust 侧模板库,首页只负责展示与跳转。 */ templateRecommendations: readonly GameTemplateEntry[]; + templateLibraryEnabled: boolean; templateLibraryLoading: boolean; templateLibraryError: string; onTemplateLibraryOpen: () => void; @@ -133,6 +134,7 @@ export default function HomeView({ onProjectOpen, onProjectPick, templateRecommendations, + templateLibraryEnabled, templateLibraryLoading, templateLibraryError, onTemplateLibraryOpen, @@ -429,37 +431,39 @@ export default function HomeView({ )} -
-
- -

- 模板库 -

-
- -
- -
+ {templateLibraryEnabled ? ( +
+
+ +

+ 模板库 +

+
+ +
+ +
+ ) : null} ); } diff --git a/apps/ai-game-creator-shell/src/view/layout.tsx b/apps/ai-game-creator-shell/src/view/layout.tsx index 4464661ce..013e12e0c 100644 --- a/apps/ai-game-creator-shell/src/view/layout.tsx +++ b/apps/ai-game-creator-shell/src/view/layout.tsx @@ -42,6 +42,7 @@ type SidebarUserInfo = { type LauncherSidebarProps = { activeView: LauncherView; + templateLibraryEnabled: boolean; currentUser: SidebarUserInfo; onViewChange: (view: LauncherView) => void; onNoticeRequest: (title: string) => void; @@ -193,6 +194,7 @@ function SidebarAccountMenu({ export function Sidebar({ activeView, + templateLibraryEnabled, currentUser, onViewChange, onNoticeRequest, @@ -286,19 +288,21 @@ export function Sidebar({ >