diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 8a4b2d0c6..6368883c8 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -213,6 +213,8 @@ module.exports = { '!src/services/clipboard.test.ts', '!src/services/frontendRuntimeConfigService.ts', '!src/services/frontendRuntimeConfigService.test.ts', + '!src/services/gameDistributionClient.ts', + '!src/services/gameDistributionClient.test.ts', '!src/services/sseStream.ts', '!src/services/sseStream.test.ts', 'src/AdventurePanel.tsx', diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 78f48200b..8338196cf 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -98,6 +98,12 @@ jobs: - name: Report isolated Rust compilation cache if: always() run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py ai-game-creator-shell-rust-lane-2: name: AI game creator shell Rust lane 2/2 @@ -129,12 +135,25 @@ jobs: sleep $((attempt * 2)) done + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run AI game creator shell Rust shard 3/4 run: npm run check:native-shells:agc-rust-shard-3 - name: Run AI game creator shell Rust shard 4/4 run: npm run check:native-shells:agc-rust-shard-4 + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py + # agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖, # 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。 ai-game-creator-shell-rust-smoke: @@ -167,9 +186,22 @@ jobs: sleep $((attempt * 2)) done + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run AI game creator shell agent-run smoke run: npm run check:native-shells:agc-rust-smoke + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py + # AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。 ai-game-creator-shell-rust-crates: name: AI game creator shell Rust crates @@ -268,9 +300,22 @@ jobs: sleep $((attempt * 2)) done + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run AI game creator shell shared crate gates run: npm run check:native-shells:agc-rust-crates + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py + backend-tests: name: Backend tests runs-on: genarrative-ci @@ -341,6 +386,9 @@ jobs: - name: Check server-rs boundaries run: npm run check:server-rs-ddd + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run server-rs workspace tests run: cargo test --locked --workspace --exclude spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml @@ -353,6 +401,16 @@ jobs: - name: Check SpacetimeDB module run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py + # 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁, # 以及依赖发布产物的构建 smoke。 native-shell-tests: @@ -395,15 +453,32 @@ jobs: - name: Run native shell contract gates run: npm run check:native-shells:contract + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run native shell gates run: npm run check:native-shells:shells - name: Run native shell release build smoke + # 发布构建有独立 profile/features 和资源 staging,不消费测试对象快照。 + env: + RUSTC_WRAPPER: '' + CARGO_BUILD_RUSTC_WRAPPER: '' run: npm run check:native-shells:release - name: Ensure native lockfiles are unchanged run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py + frontend-tests: name: Frontend tests runs-on: genarrative-ci @@ -485,6 +560,11 @@ jobs: - name: Install npm dependencies run: bash scripts/ci-npm-ci-with-retry.sh + - name: Validate CI cache maintenance behavior + run: | + python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py' + node --test scripts/export-ci-npm-download-cache.test.mjs + - name: Run repository checks run: npm run check:repository-ci diff --git a/CONTEXT.md b/CONTEXT.md index 724d95de3..59acab2ff 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -194,6 +194,34 @@ _Avoid_: 进度通知、快照轮询、第二套历史 把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer +**引用候选**: +输入区可以命中的对象集合(`@` 素材、`$` Skill),由宿主按种类注入;输入区不判断候选属于哪一类。 +_Avoid_: 输入区自己读项目清单或应用目录、把候选取值写死在组件里 + +**引用 provider**: +一种引用种类向输入区提供的全部能力:触发符、候选、身份解析与正文文本形态;每种引用各一份,宿主按需选择性注入。 +_Avoid_: 一个总装对象决定所有种类、输入区按种类分叉、provider 之间互相知道对方 + +**静默 provider**: +不提供候选、只负责已有引用身份与文本形态的 provider;附件与运行画面区域属于这一类,只能由外部插入或草稿回填进入正文。 +_Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内的专门分支 + +**引用文本语法**: +引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。 +_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段 + +**引用输入区**: +只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。 +_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部 + +**引用选择器**: +宿主渲染的独立面板,自己拿数据与筛选状态,确认后把选中的引用交给输入区的插入缝。 +_Avoid_: 输入区自带面板、每个宿主各画一个、绕开插入缝另开第二条通道 + +**附件芯片**: +聊天附件在正文里的唯一表示;附件导入成功即以芯片进入正文,正文之外不存在第二份附件状态。 +_Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附件 + ## Relationships - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。 @@ -209,6 +237,10 @@ _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer - **个人历史成绩** 由最近记录列表和个人最佳摘要组成,只允许本人查看;排行榜只公开入榜胜利成绩。 - **正式作品入口闭环** 必须覆盖创作入口、作品详情 CTA、广场/作品卡片、我的作品/个人作品架、稳定作品 ID runtime 路由和 `work_play_start` 埋点。 - **Phase 2 实施顺序** 固定为:契约与领域规则 → SpacetimeDB 表/reducer 与 api-server BFF → 最小前端纵切 → 投影与列表体验 → 收口验证。 +- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。 +- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。 +- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。 +- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。 ## Example dialogue diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index cf1cbc31e..05cfdc10e 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -8,9 +8,12 @@ import { getAdminUserDetail, importAdminAgcTemplates, listAdminAgcTrackingEvents, + listAdminGameDistributionReviews, listAdminRechargeOrders, reconcileAdminUserConsumption, resolveAdminRechargeRefundManualReview, + reviewAdminGameDistributionVersion, + suspendAdminGameDistributionGame, updateAdminAccount, updateAdminAgcTemplate, uploadAdminEditorShowcaseCampaignImage, @@ -479,3 +482,134 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => { }), ); }); + +test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ entries: [], nextCursor: null }), { + status: 200, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await listAdminGameDistributionReviews('admin-token'); + await reviewAdminGameDistributionVersion( + 'admin-token', + 'gamever/1', + 'game-review-key-1', + { + decision: 'approve', + expectedPublicationRevision: 3, + entryUrl: 'https://games.example.test/releases/game_1/index.html', + }, + ); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/game-distribution/reviews?limit=48', + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + '/admin/api/game-distribution/versions/gamever%2F1/review', + ); + expect(fetchMock.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + 'Idempotency-Key': 'game-review-key-1', + }), + body: JSON.stringify({ + decision: 'approve', + expectedPublicationRevision: 3, + entryUrl: 'https://games.example.test/releases/game_1/index.html', + }), + }), + ); +}); + +test('安全下架请求携带公开修订号、原因与幂等键', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ game: {}, replayed: false }), { + status: 200, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await suspendAdminGameDistributionGame( + 'admin-token', + 'game/1', + 'game-suspend-key-1', + { expectedPublicationRevision: 7, reason: '版权投诉' }, + ); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/game-distribution/games/game%2F1/suspend', + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + 'Idempotency-Key': 'game-suspend-key-1', + }), + body: JSON.stringify({ + expectedPublicationRevision: 7, + reason: '版权投诉', + }), + }), + ); + + expect(() => + suspendAdminGameDistributionGame('admin-token', ' ', 'key', { + expectedPublicationRevision: 1, + }), + ).toThrow('缺少游戏 ID'); + expect(() => + suspendAdminGameDistributionGame('admin-token', 'game-1', ' ', { + expectedPublicationRevision: 1, + }), + ).toThrow('下架幂等键必须是 1 到 128 个字符'); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); + +test('游戏审核拒绝请求携带理由,空幂等键在本地失败关闭', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ version: {}, replayed: false }), { + status: 200, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await reviewAdminGameDistributionVersion( + 'admin-token', + 'version-1', + 'game-review-key-2', + { + decision: 'reject', + expectedPublicationRevision: 0, + reviewReason: '运行时报错', + }, + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + body: JSON.stringify({ + decision: 'reject', + expectedPublicationRevision: 0, + reviewReason: '运行时报错', + }), + }), + ); + + expect(() => + reviewAdminGameDistributionVersion('admin-token', 'version-1', ' ', { + decision: 'reject', + expectedPublicationRevision: 0, + reviewReason: 'x', + }), + ).toThrow('审核幂等键必须是 1 到 128 个字符'); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index aedc6352c..da313c68b 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -32,6 +32,9 @@ import type { AdminExternalApiKeyListQuery, AdminExternalApiKeyListResponse, AdminFeatureGateConfigResponse, + AdminGameDistributionReviewListResponse, + AdminGameDistributionReviewRequest, + AdminGameDistributionReviewResponse, AdminImportAgcTemplatesResponse, AdminLoginResponse, AdminMeResponse, @@ -1227,6 +1230,75 @@ export function saveAgcModelCatalog( ); } +export function listAdminGameDistributionReviews(token: string, limit = 48) { + const normalizedLimit = Number.isFinite(limit) + ? Math.min(Math.max(Math.trunc(limit), 1), 48) + : 48; + return request( + `/admin/api/game-distribution/reviews?limit=${normalizedLimit}`, + { token }, + ); +} + +/** + * 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生 + * 两条审核结论。 + */ +/** + * 安全下架整个游戏。管理员下架同样要求 CAS 修订号与幂等键,避免并发审核互相覆盖。 + */ +export function suspendAdminGameDistributionGame( + token: string, + gameId: string, + idempotencyKey: string, + payload: import('./adminApiTypes').AdminGameDistributionSuspendRequest, +) { + const normalizedGameId = gameId.trim(); + const normalizedKey = idempotencyKey.trim(); + if (!normalizedGameId) { + throw new Error('缺少游戏 ID'); + } + if (!normalizedKey || normalizedKey.length > 128) { + throw new Error('下架幂等键必须是 1 到 128 个字符'); + } + return request< + import('./adminApiTypes').AdminGameDistributionSuspendResponse + >( + `/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/suspend`, + { + method: 'POST', + token, + headers: { 'Idempotency-Key': normalizedKey }, + body: payload, + }, + ); +} + +export function reviewAdminGameDistributionVersion( + token: string, + versionId: string, + idempotencyKey: string, + payload: AdminGameDistributionReviewRequest, +) { + const normalizedVersionId = versionId.trim(); + const normalizedKey = idempotencyKey.trim(); + if (!normalizedVersionId) { + throw new Error('缺少发行版本 ID'); + } + if (!normalizedKey || normalizedKey.length > 128) { + throw new Error('审核幂等键必须是 1 到 128 个字符'); + } + return request( + `/admin/api/game-distribution/versions/${encodeURIComponent(normalizedVersionId)}/review`, + { + method: 'POST', + token, + headers: { 'Idempotency-Key': normalizedKey }, + body: payload, + }, + ); +} + export function getAdminAgcTemplates(token: string, signal?: AbortSignal) { return request('/admin/api/agc-templates', { token, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index b7a0c6229..71159500c 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1081,6 +1081,51 @@ export interface AdminAgcModelCatalog { models: AdminAgcModel[]; } +export interface AdminGameDistributionReviewEntry { + versionId: string; + gameId: string; + versionNumber: number; + packageSha256: string; + packageBytes: number; + status: string; + publicationRevision: number; + reviewReason: string | null; + createdAt: string; + updatedAt: string; +} + +export interface AdminGameDistributionReviewListResponse { + entries: AdminGameDistributionReviewEntry[]; + nextCursor: string | null; +} + +export interface AdminGameDistributionReviewRequest { + decision: 'approve' | 'reject'; + expectedPublicationRevision: number; + reviewReason?: string; + entryUrl?: string; +} + +export interface AdminGameDistributionReviewResponse { + version: AdminGameDistributionReviewEntry; + replayed: boolean; +} + +export interface AdminGameDistributionSuspendRequest { + expectedPublicationRevision: number; + reason?: string; +} + +export interface AdminGameDistributionSuspendResponse { + game: { + id: string; + title: string; + status: string; + publicationRevision: number; + }; + replayed: boolean; +} + export interface AdminAgcTemplatePayload { id: string; title: string; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index ecc003727..73b48d326 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -28,6 +28,7 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage'; import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage'; import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage'; import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage'; +import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage'; import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; @@ -314,6 +315,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'game-distribution' ? ( + + ) : null} {activeRouteId === 'editor-assets' ? ( { + expect(adminRoutes).toContainEqual({ + id: 'game-distribution', + label: '游戏审核', + hash: '#game-distribution', + }); + expect(resolveAdminRoute('#game-distribution')).toBe('game-distribution'); + expect(routeHash('game-distribution')).toBe('#game-distribution'); +}); + +test('member 可单独获得游戏审核 Tab 权限', () => { + const routes = getAccessibleAdminRoutes({ + accountRole: 'member', + tabPermissions: ['game-distribution'], + }); + expect(routes.map((route) => route.id)).toEqual(['game-distribution']); + expect(resolveAccessibleAdminRoute('#game-distribution', routes)).toBe( + 'game-distribution', + ); +}); + test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => { expect(adminRoutes).toContainEqual({ id: 'agc-templates', diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index db1478310..46aa13750 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -16,6 +16,7 @@ export type AdminRouteId = | 'recharge-orders' | 'editor-generation-pricing' | 'editor-showcase' + | 'game-distribution' | 'editor-assets' | 'project-snapshots' | 'agc-models' @@ -58,6 +59,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ { id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true }, { id: 'agc-templates', label: '模板管理', hash: '#agc-templates' }, { id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' }, + { id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' }, { id: 'editor-assets', label: '素材查询', hash: '#editor-assets' }, { id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' }, { id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx new file mode 100644 index 000000000..ae00212d9 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -0,0 +1,186 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { + listAdminGameDistributionReviews, + reviewAdminGameDistributionVersion, + suspendAdminGameDistributionGame, +} from '../api/adminApiClient'; +import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes'; +import { + AdminGameDistributionReviewPage, + resolveGameReleaseEntryUrlError, +} from './AdminGameDistributionReviewPage'; + +vi.mock('../api/adminApiClient', () => ({ + isAdminApiError: vi.fn( + (error: unknown) => + typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number', + ), + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + listAdminGameDistributionReviews: vi.fn(), + reviewAdminGameDistributionVersion: vi.fn(), + suspendAdminGameDistributionGame: vi.fn(), +})); + +const entry: AdminGameDistributionReviewEntry = { + versionId: 'version-1', + gameId: 'game_1', + versionNumber: 2, + packageSha256: 'a'.repeat(64), + packageBytes: 2048, + status: 'pending_review', + publicationRevision: 4, + reviewReason: null, + createdAt: '2026-09-20T08:00:00Z', + updatedAt: '2026-09-20T08:00:00Z', +}; + +beforeEach(() => { + vi.mocked(listAdminGameDistributionReviews).mockReset(); + vi.mocked(reviewAdminGameDistributionVersion).mockReset(); + vi.mocked(suspendAdminGameDistributionGame).mockReset(); + vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({ + entries: [entry], + nextCursor: null, + }); +}); + +test('发行入口必须是带完整来源的 HTTPS 地址', () => { + expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口'); + expect( + resolveGameReleaseEntryUrlError('http://games.test/a/index.html'), + ).toBe('发行入口必须以 https:// 开头'); + expect( + resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'), + ).toBe('发行入口不能包含 query 或 fragment'); + expect( + resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'), + ).toBe('发行入口不能包含凭据'); + expect( + resolveGameReleaseEntryUrlError('https://games.test/a/index.html'), + ).toBe(''); +}); + +test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => { + vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({ + version: { ...entry, status: 'published' }, + replayed: false, + }); + + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.change(screen.getByLabelText('发行入口'), { + target: { value: 'https://games.test/releases/game_1/index.html' }, + }); + fireEvent.click(screen.getByRole('button', { name: '通过' })); + + await waitFor(() => + expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1), + ); + const [token, versionId, idempotencyKey, payload] = + vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? []; + expect(token).toBe('admin-token'); + expect(versionId).toBe('version-1'); + expect(String(idempotencyKey)).toContain('version-1'); + expect(payload).toEqual({ + decision: 'approve', + expectedPublicationRevision: 4, + entryUrl: 'https://games.test/releases/game_1/index.html', + }); + await waitFor(() => + expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes( + 2, + ), + ); +}); + +test('缺少拒绝理由时不调用审核接口', async () => { + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.click(screen.getByRole('button', { name: '拒绝' })); + + expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy(); + expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled(); +}); + +test('安全下架需要二次确认,并携带公开修订号与原因', async () => { + vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({ + game: { + id: 'game_1', + title: '测试游戏', + status: 'suspended', + publicationRevision: 5, + }, + replayed: false, + }); + + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.change(screen.getByLabelText('下架原因'), { + target: { value: '盗用素材' }, + }); + fireEvent.click(screen.getByRole('button', { name: '安全下架' })); + + // 第一次点击只弹出确认面板,不直接调用后端。 + expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); + expect(await screen.findByRole('dialog')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => + expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1), + ); + const [token, gameId, idempotencyKey, payload] = + vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? []; + expect(token).toBe('admin-token'); + expect(gameId).toBe('game_1'); + expect(String(idempotencyKey)).toContain('game_1'); + expect(payload).toEqual({ + expectedPublicationRevision: 4, + reason: '盗用素材', + }); + expect(await screen.findByText(/已安全下架/u)).toBeTruthy(); +}); + +test('取消确认时不下架', async () => { + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.click(screen.getByRole('button', { name: '安全下架' })); + await screen.findByRole('dialog'); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); +}); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx new file mode 100644 index 000000000..b1e993ed7 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -0,0 +1,365 @@ +import { RefreshCcw } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import { + listAdminGameDistributionReviews, + reviewAdminGameDistributionVersion, + suspendAdminGameDistributionGame, +} from '../api/adminApiClient'; +import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { handlePageError } from './pageUtils'; + +interface AdminGameDistributionReviewPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +function formatBytes(value: number) { + if (value >= 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(1)} MiB`; + } + if (value >= 1024) { + return `${(value / 1024).toFixed(1)} KiB`; + } + return `${value} B`; +} + +function formatTime(value: string) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString('zh-CN', { hour12: false }); +} + +function createSuspendIdempotencyKey(gameId: string) { + const random = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `game-suspend-${gameId}-${random}`.slice(0, 128); +} + +function createReviewIdempotencyKey(versionId: string) { + const random = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `game-review-${versionId}-${random}`.slice(0, 128); +} + +export function resolveGameReleaseEntryUrlError(value: string) { + const normalized = value.trim(); + if (!normalized) return '请填写发行入口'; + if (!normalized.startsWith('https://')) { + return '发行入口必须以 https:// 开头'; + } + if (normalized.includes('?') || normalized.includes('#')) { + return '发行入口不能包含 query 或 fragment'; + } + try { + const parsed = new URL(normalized); + if (parsed.username || parsed.password) { + return '发行入口不能包含凭据'; + } + } catch { + return '发行入口不是合法 URL'; + } + return ''; +} + +export function AdminGameDistributionReviewPage({ + token, + onUnauthorized, +}: AdminGameDistributionReviewPageProps) { + const [entries, setEntries] = useState( + [], + ); + const [isLoading, setIsLoading] = useState(false); + const [busyVersionId, setBusyVersionId] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [statusMessage, setStatusMessage] = useState(''); + const [entryUrlByVersion, setEntryUrlByVersion] = useState< + Record + >({}); + const [reasonByVersion, setReasonByVersion] = useState< + Record + >({}); + const [suspendReasonByGame, setSuspendReasonByGame] = useState< + Record + >({}); + const [busyGameId, setBusyGameId] = useState(''); + const writeConfirm = useAdminWriteConfirm(); + + const loadReviews = useCallback(async () => { + setIsLoading(true); + setErrorMessage(''); + try { + const response = await listAdminGameDistributionReviews(token); + setEntries(response.entries); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoading(false); + } + }, [token, onUnauthorized]); + + useEffect(() => { + void loadReviews(); + }, [loadReviews]); + + async function submitReview( + entry: AdminGameDistributionReviewEntry, + decision: 'approve' | 'reject', + ) { + const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim(); + const reason = (reasonByVersion[entry.versionId] ?? '').trim(); + if (decision === 'approve') { + const invalid = resolveGameReleaseEntryUrlError(entryUrl); + if (invalid) { + setErrorMessage(invalid); + return; + } + } else if (!reason) { + setErrorMessage('拒绝审核必须填写理由'); + return; + } + setBusyVersionId(entry.versionId); + setErrorMessage(''); + setStatusMessage(''); + try { + await reviewAdminGameDistributionVersion( + token, + entry.versionId, + createReviewIdempotencyKey(entry.versionId), + decision === 'approve' + ? { + decision, + expectedPublicationRevision: entry.publicationRevision, + entryUrl, + } + : { + decision, + expectedPublicationRevision: entry.publicationRevision, + reviewReason: reason, + }, + ); + setStatusMessage( + decision === 'approve' + ? `版本 v${entry.versionNumber} 已通过审核` + : `版本 v${entry.versionNumber} 已拒绝`, + ); + await loadReviews(); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setBusyVersionId(''); + } + } + + /** + * 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时 + * 由服务端返回冲突,前端只提示刷新,不静默重试。 + */ + async function suspendGame(entry: AdminGameDistributionReviewEntry) { + const reason = (suspendReasonByGame[entry.gameId] ?? '').trim(); + const confirmed = await writeConfirm.confirmWrite({ + action: '安全下架游戏', + target: `${entry.gameId}(版本 v${entry.versionNumber})`, + }); + if (!confirmed) return; + setBusyGameId(entry.gameId); + setErrorMessage(''); + setStatusMessage(''); + try { + await suspendAdminGameDistributionGame( + token, + entry.gameId, + createSuspendIdempotencyKey(entry.gameId), + { + expectedPublicationRevision: entry.publicationRevision, + ...(reason ? { reason } : {}), + }, + ); + setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`); + setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' })); + await loadReviews(); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setBusyGameId(''); + } + } + + return ( +
+
+

游戏审核

+ +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + {statusMessage ? ( +
+ {statusMessage} +
+ ) : null} + +
+
+

待审版本

+ 共 {entries.length} 条 +
+ + {isLoading ? ( +

正在加载待审版本…

+ ) : null} + + {!isLoading && entries.length === 0 ? ( +

当前没有待审核的游戏版本。

+ ) : null} + + {!isLoading && entries.length > 0 ? ( +
+ + + + + + + + + + + + {entries.map((entry) => { + const busy = busyVersionId === entry.versionId; + return ( + + + + + + + + ); + })} + +
游戏版本发行包提交时间审核
+ {entry.gameId} + + v{entry.versionNumber} +
{entry.status}
+ {entry.reviewReason ? ( +
+ {entry.reviewReason} +
+ ) : null} +
+ {formatBytes(entry.packageBytes)} +
+ {entry.packageSha256.slice(0, 12)} +
+
{formatTime(entry.createdAt)} +
+
+ + + setEntryUrlByVersion((current) => ({ + ...current, + [entry.versionId]: event.target.value, + })) + } + disabled={busy} + /> +
+ +
+ + + setReasonByVersion((current) => ({ + ...current, + [entry.versionId]: event.target.value, + })) + } + disabled={busy} + /> +
+ +
+ + + setSuspendReasonByGame((current) => ({ + ...current, + [entry.gameId]: event.target.value, + })) + } + disabled={busy} + /> +
+ +
+
+
+ ) : null} +
+ {writeConfirm.confirmDialog} +
+ ); +} diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index bd1d5907b..742e4f8d1 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -648,6 +648,32 @@ function readHeadCommit() { } } +/** + * Git 的 %s 会把「标题后紧接说明行、没有空行」的整个首段拼成一行; + * 更新摘要只允许展示原始提交消息的第一行,避免把说明暴露给用户。 + */ +function commitMessageTitle(message) { + return String(message ?? '') + .split(/\r?\n/u, 1)[0] + .trim(); +} + +/** 解析 `git log -z --format=%h%x09%B`,只保留每个 commit 的消息首行。 */ +function parseReleaseCommitLog(output) { + return output + .split('\0') + .map((record) => record.trimEnd()) + .filter(Boolean) + .map((record) => { + const separator = record.indexOf('\t'); + if (separator < 0) return null; + const sha = record.slice(0, separator); + const subject = commitMessageTitle(record.slice(separator + 1)); + return sha && subject ? { sha, subject } : null; + }) + .filter(Boolean); +} + /** * 上一次发布到本次之间的客户端相关提交。 * @@ -675,8 +701,9 @@ export function collectReleaseCommits( 'git', [ 'log', + '-z', '--no-merges', - '--format=%h%x09%s', + '--format=%h%x09%B', `${previousCommit}..${headCommit}`, '--', ...paths, @@ -686,28 +713,22 @@ export function collectReleaseCommits( } catch { return null; } - return output - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - const [sha = '', ...subject] = line.split('\t'); - return { sha, subject: subject.join('\t') }; - }); + return parseReleaseCommitLog(output); } -/** 自动更新摘要:逐条列客户端相关改动,超过上限时折叠并整体截断。 */ +/** 自动更新摘要:逐条列客户端相关改动标题,超过上限时折叠并整体截断。 */ export function formatReleaseNotes( commits, { limit = 12, subjectLength = 80, maxLength = 900 } = {}, ) { if (!commits || commits.length === 0) return ''; - const lines = commits.slice(0, limit).map(({ sha, subject }) => { + const lines = commits.slice(0, limit).map(({ subject }) => { + const title = commitMessageTitle(subject); const trimmed = - subject.length > subjectLength - ? `${subject.slice(0, subjectLength - 1)}…` - : subject; - return `- ${trimmed}(${sha})`; + title.length > subjectLength + ? `${title.slice(0, subjectLength - 1)}…` + : title; + return `- ${trimmed}`; }); if (commits.length > limit) { lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`); @@ -726,20 +747,21 @@ export function collectRecentReleaseCommits({ try { output = execFileSync( 'git', - ['log', '--no-merges', `-n${limit}`, '--format=%h%x09%s', '--', ...paths], + [ + 'log', + '-z', + '--no-merges', + `-n${limit}`, + '--format=%h%x09%B', + '--', + ...paths, + ], { cwd, encoding: 'utf8' }, ); } catch { return null; } - const commits = output - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - const [sha = '', ...subject] = line.split('\t'); - return { sha, subject: subject.join('\t') }; - }); + const commits = parseReleaseCommitLog(output); return commits.length > 0 ? commits : null; } 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 848b74692..427166456 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -645,17 +645,19 @@ test('Windows remains the default and explicit Windows overrides macOS environme test('no-bundle smoke skips version writes and manifest generation', async () => { const steps = []; - await buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], { - prepareVersion: () => { - steps.push('version'); - }, - build: (_args, context) => { - steps.push(context.channel); - }, - generateManifest: () => { - steps.push('manifest'); - }, - }); + await withEnv({ AGC_UPDATE_CHANNEL: undefined }, () => + buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], { + prepareVersion: () => { + steps.push('version'); + }, + build: (_args, context) => { + steps.push(context.channel); + }, + generateManifest: () => { + steps.push('manifest'); + }, + }), + ); assert.deepEqual(steps, ['dev']); }); @@ -1075,20 +1077,17 @@ test('release entry forwards the built artifacts and dry-run mode to the uploade assert.ok(source.includes('\n dryRun,\n')); }); -test('release notes list client commits with short sha and bound their size', () => { +test('release notes list client commit subjects only and bound their size', () => { const notes = formatReleaseNotes([ { sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' }, { sha: '55af6014', subject: '修'.repeat(120) }, ]); const lines = notes.split('\n'); assert.equal(lines.length, 2); - assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u); - const truncatedSubject = lines[1] - .replace(/^- /u, '') - .replace(/(55af6014)$/u, ''); + assert.equal(lines[0], '- 客户端更新切换到官方更新插件'); + const truncatedSubject = lines[1].replace(/^- /u, ''); assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`); assert.match(truncatedSubject, /…$/u); - assert.match(lines[1], /(55af6014)$/u); const many = formatReleaseNotes( Array.from({ length: 20 }, (_, index) => ({ @@ -1097,6 +1096,15 @@ test('release notes list client commits with short sha and bound their size', () })), ); assert.match(many, /- 其余 8 项客户端改动省略$/u); + assert.equal( + formatReleaseNotes([ + { + sha: 'ignored', + subject: '提交标题\n不应展示的说明一\n不应展示的说明二', + }, + ]), + '- 提交标题', + ); assert.equal(formatReleaseNotes([]), ''); assert.equal(formatReleaseNotes(null), ''); }); @@ -1120,8 +1128,17 @@ test('release commits cover only client paths and skip merge commits', () => { path.join(directory, 'apps/ai-game-creator-shell/main.rs'), 'fn main() {}\n', ); + const commitMessagePath = path.join( + directory, + '.git', + 'commit-message.txt', + ); + writeFileSync( + commitMessagePath, + '客户端:新增更新插件接入\n补充更新插件接入的详细说明\n', + ); git('add', '.'); - git('commit', '--quiet', '-m', '客户端:新增更新插件接入'); + git('commit', '--quiet', '-F', commitMessagePath); writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n'); git('add', '.'); @@ -1147,6 +1164,7 @@ test('release commits cover only client paths and skip merge commits', () => { const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory }); assert.ok(commits, '应能在临时仓库里收集提交'); const subjects = commits.map((entry) => entry.subject); + // 标题后没有空行时,git %s 会把说明行拼进标题;摘要必须取原始首行。 // 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。 assert.deepEqual(subjects, [ '客户端:侧分支改动', diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index bd2f20c68..40577bdcf 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -200,15 +200,16 @@ const allowedUncalledTauriCommands = [ 'read_agc_plugin_panel', 'set_agc_plugin_enabled', // 下面这些命令的调用方只有随 Project Supervisor 前端链路一起删除的旧命令聊天入口; - // 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / 导出包 / + // 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / // 画板同步 / 素材登记 / 权限策略 / 本地草案 / 平台美术),Rust 侧只剩注册与实现, // `*_at` helper 仍由 Rust 用例覆盖。接回新入口还是删除属于 native 能力取舍,先按 // native-only 登记,避免孤儿检查一直报错。 // 预览不在本清单:`activate_local_game_preview` 已按 ADR 回接到 App 的「运行」入口。 + // 导出试玩包同样不在本清单:发布链路(`requestGamePublish`)已把它接回 + // DirectProject 聊天头的「发布」入口。 'build_local_project_index', 'control_agent_run', 'create_local_project_checkpoint', - 'export_local_project_package', 'generate_local_game_draft', 'generate_platform_art_asset', 'import_canvas_asset', @@ -1946,6 +1947,7 @@ for (const snippet of [ '官方账号服务(固定)', 'runtime_config.save', "'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'", + '已载入客户端运行视图', 'async function executeRunLocal', 'function needsInitializedChatProject', "'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'", diff --git a/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs b/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs index d79c9f8a7..f52c82836 100644 --- a/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs +++ b/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs @@ -88,15 +88,21 @@ export function resolveNsisCacheDir( env = process.env, platform = process.platform, ) { + const pathImpl = platform === 'win32' ? path.win32 : path.posix; const explicit = env.AGC_TAURI_NSIS_CACHE_DIR?.trim(); - if (explicit) return path.resolve(explicit); + if (explicit) return pathImpl.resolve(explicit); // Jenkins Windows 节点以 SYSTEM 运行,ProgramData 稳定可写且不受工作区清理影响; // 缓存里只有待解压的原始归档,不会从该目录执行任何程序。 if (platform === 'win32') { const programData = env.ProgramData?.trim() || 'C:\\ProgramData'; - return path.join(programData, 'genarrative', 'tauri-nsis-cache'); + return pathImpl.join(programData, 'genarrative', 'tauri-nsis-cache'); } - return path.join(os.homedir(), '.cache', 'genarrative', 'tauri-nsis-cache'); + return pathImpl.join( + os.homedir(), + '.cache', + 'genarrative', + 'tauri-nsis-cache', + ); } /** 与 tauri-bundler 相同的镜像开关语义,便于构建机绕过不可达的 GitHub。 */ diff --git a/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs b/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs index 26faca27e..d5ce2fbf0 100644 --- a/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs +++ b/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs @@ -7,6 +7,7 @@ import { test } from 'node:test'; import JSZip from 'jszip'; import { + defaultAppRoot, ensureNsisToolset, extractNsisArchive, NSIS_ARCHIVE_ASSET_NAME, @@ -23,10 +24,7 @@ import { verifyNsisToolset, } from './nsis-toolset.mjs'; -const appRoot = path.resolve( - path.dirname(new URL(import.meta.url).pathname), - '..', -); +const appRoot = defaultAppRoot(); const silentLogger = { log() {}, warn() {} }; function createSandbox() { @@ -98,6 +96,14 @@ test('NSIS 工具链目录与 Tauri useLocalToolsDir 配置保持一致', () => }); test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => { + assert.equal( + resolveNsisCacheDir({ AGC_TAURI_NSIS_CACHE_DIR: 'D:\\agc-cache' }, 'win32'), + 'D:\\agc-cache', + ); + assert.equal( + resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'), + path.win32.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'), + ); assert.equal( resolveNsisCacheDir( { AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' }, @@ -105,13 +111,9 @@ test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => ), '/tmp/agc-cache', ); - assert.equal( - resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'), - path.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'), - ); assert.ok( resolveNsisCacheDir({}, 'linux').endsWith( - path.join('.cache', 'genarrative', 'tauri-nsis-cache'), + path.posix.join('.cache', 'genarrative', 'tauri-nsis-cache'), ), ); }); diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index b62ed9e70..753d89477 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -16,7 +16,8 @@ { "url": "https://www.genarrative.world/api/*" }, { "url": "https://*/api/*" }, { "url": "http://localhost:*/*" }, - { "url": "http://127.0.0.1:*/*" } + { "url": "http://127.0.0.1:*/*" }, + { "url": "https://*.aliyuncs.com/*" } ] }, "opener:default", diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser.rs b/apps/ai-game-creator-shell/src-tauri/src/browser.rs index 919fb7467..16b4ed4bd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser.rs @@ -6,6 +6,7 @@ mod model; mod network_policy; mod playtest; mod process; +mod sweep; pub use discovery::discover_chrome_or_edge; #[allow(unused_imports)] @@ -22,6 +23,7 @@ pub(crate) use process::validate_local_preview_in_browser_with_cancellation; pub use process::{ validate_local_preview_in_browser, validate_local_preview_in_browser_with_interaction, }; +pub(crate) use sweep::sweep_stale_browser_processes; pub(crate) use model::required_viewport_playtests_passed; pub(crate) use playtest::browser_playtest_scenario_fingerprint; diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 3330832ff..098f43841 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -1,9 +1,14 @@ use std::fs; use std::path::PathBuf; use std::time::Duration; +#[cfg(windows)] +use std::time::Instant; +use chromiumoxide::async_process::{Child as BrowserChild, ChildStderr as BrowserChildStderr}; use chromiumoxide::browser::{Browser, BrowserConfig}; -use futures::StreamExt; +use chromiumoxide::handler::viewport::Viewport; +use chromiumoxide::handler::HandlerConfig; +use futures::{AsyncBufReadExt, FutureExt, StreamExt}; use tempfile::{Builder as TempDirBuilder, TempDir}; use super::cdp::run_browser_validation; @@ -15,8 +20,12 @@ use super::model::{ BrowserIdentity, BrowserValidationInput, BrowserValidationResult, BROWSER_TIMEOUT, }; use super::network_policy::{preview_proxy_bypass_list, validate_input}; +#[cfg(windows)] +use crate::process_session::WindowsProcessJob; -fn browser_process_temp_root() -> PathBuf { +pub(super) const BROWSER_TEMP_PREFIX: &str = "ga-browser-"; + +pub(super) fn browser_process_temp_root() -> PathBuf { #[cfg(unix)] { PathBuf::from("/tmp") @@ -29,7 +38,7 @@ fn browser_process_temp_root() -> PathBuf { pub(super) fn create_browser_process_temp_dir() -> Result { TempDirBuilder::new() - .prefix("ga-browser-") + .prefix(BROWSER_TEMP_PREFIX) .tempdir_in(browser_process_temp_root()) .map_err(|error| format!("创建浏览器临时目录失败:{error}")) } @@ -63,21 +72,306 @@ fn browser_config( .map_err(|error| format!("构建浏览器配置失败:{error}")) } -/// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 -/// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 -pub(crate) async fn check_browser_health() -> Result { - let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; - let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?; - let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>") - .map_err(|_| "browser-config-invalid")?; - // 外层超时给 Chromiumoxide 内部启动超时后的 kill/wait 留出清理窗口。 - let (mut browser, mut handler) = tokio::time::timeout( - BROWSER_TIMEOUT + Duration::from_secs(5), - Browser::launch(config), +const BROWSER_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_LAUNCH_STDERR_BYTES: usize = 64 * 1024; +const MAX_WS_URL_LEN: usize = 512; + +/// 必须与 browser_config 的 builder 调用保持一致:request_timeout / +/// enable_request_intercept / disable_cache 在这里逐项镜像;viewport 镜像 +/// BrowserConfigBuilder 的默认值 Some(Viewport::default())(800x600,旧 +/// Browser::launch 会把它带入 HandlerConfig);其余字段 +/// (ignore_https_errors / ignore_invalid_messages)沿用 +/// HandlerConfig::default(),与 builder 默认值相同。 +fn handler_config() -> HandlerConfig { + let mut config = HandlerConfig::default(); + config.request_timeout = BROWSER_TIMEOUT; + config.request_intercept = true; + config.cache_enabled = false; + config.viewport = Some(Viewport::default()); + config +} + +/// 从浏览器 stderr 行提取 DevTools ws 地址;只接受 loopback 端点, +/// 避免伪造输出把 CDP 连接引导到任意地址。 +fn parse_devtools_ws_url(line: &str) -> Option { + let (_, ws) = line.rsplit_once("listening on ")?; + let ws = ws.trim(); + if ws.is_empty() || ws.len() > MAX_WS_URL_LEN || !ws.contains("devtools/browser") { + return None; + } + let authority = ws.strip_prefix("ws://")?.split('/').next()?; + let (host, port) = authority.rsplit_once(':')?; + if !matches!(host, "127.0.0.1" | "localhost" | "[::1]") + || port.is_empty() + || !port.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + Some(ws.to_string()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OwnedBrowserLaunchError { + SpawnFailed, + WsTimeout, + WsFailed, + ConnectTimeout, + ConnectFailed, +} + +impl OwnedBrowserLaunchError { + fn is_timeout(self) -> bool { + matches!(self, Self::WsTimeout | Self::ConnectTimeout) + } + + fn code(self) -> &'static str { + match self { + Self::SpawnFailed => "browser-spawn-failed", + Self::WsTimeout => "browser-ws-timeout", + Self::WsFailed => "browser-ws-failed", + Self::ConnectTimeout => "browser-cdp-connect-timeout", + Self::ConnectFailed => "browser-cdp-connect-failed", + } + } +} + +type BrowserStderrReader = futures::io::BufReader; + +/// 复刻 chromiumoxide 私有 ws_url_from_output 的语义(读 stderr 直到 +/// DevTools listening 行、提前退出即失败、整体有界),但 root 进程始终 +/// 留在调用方手里,并把 stderr reader 交还给调用方持续排空。 +async fn devtools_ws_url_from_stderr( + child: &mut BrowserChild, + timeout: Duration, +) -> Result<(String, BrowserStderrReader), OwnedBrowserLaunchError> { + let stderr = child + .stderr + .take() + .ok_or(OwnedBrowserLaunchError::SpawnFailed)?; + let mut reader = futures::io::BufReader::new(stderr); + let mut captured: Vec = Vec::new(); + let mut exited = Box::pin(child.wait()).fuse(); + let read = async { + loop { + let mut line = Vec::new(); + futures::select! { + _status = exited => return Err(OwnedBrowserLaunchError::WsFailed), + result = reader.read_until(b'\n', &mut line).fuse() => { + let count = result.map_err(|_| OwnedBrowserLaunchError::WsFailed)?; + if count == 0 { + return Err(OwnedBrowserLaunchError::WsFailed); + } + captured.extend_from_slice(&line); + if captured.len() > MAX_LAUNCH_STDERR_BYTES { + return Err(OwnedBrowserLaunchError::WsFailed); + } + let Ok(text) = std::str::from_utf8(&line) else { + return Err(OwnedBrowserLaunchError::WsFailed); + }; + if let Some(url) = parse_devtools_ws_url(text) { + return Ok(url); + } + } + } + } + }; + match tokio::time::timeout(timeout, read).await { + Ok(Ok(url)) => Ok((url, reader)), + Ok(Err(error)) => Err(error), + Err(_) => Err(OwnedBrowserLaunchError::WsTimeout), + } +} + +fn spawn_stderr_drain(mut reader: BrowserStderrReader) -> tokio::task::JoinHandle<()> { + // 持续排空 stderr:浏览器日志写满管道会整体 stall。内容有界、不留存。 + tokio::spawn(async move { + let mut buffer = Vec::with_capacity(4096); + loop { + buffer.clear(); + match reader.read_until(b'\n', &mut buffer).await { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + }) +} + +/// 自持浏览器进程:root 从 spawn 起归本结构所有;Windows 下整树挂进 +/// KILL_ON_JOB_CLOSE 的 Job——失败路径、遗忘 shutdown、甚至宿主进程 +/// 被杀,整棵树都会被回收,不再留下无父进程的无头浏览器。 +struct BrowserProcessGuard { + child: BrowserChild, + #[cfg(windows)] + job: Option, +} + +impl BrowserProcessGuard { + /// 整树收割:Windows 由 Job 终止全树,再 kill+wait root 兜底; + /// Unix 只终止 root,子进程依赖 root 死亡后的级联退出。 + /// 返回是否已确认 root 进程退出。 + async fn reap(&mut self) -> bool { + #[cfg(windows)] + if let Some(job) = &self.job { + let _ = job.terminate(); + } + let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await; + matches!( + tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await, + Ok(Ok(_)) + ) + } + + async fn confirm_reaped(&mut self) -> Result<(), String> { + #[cfg(windows)] + { + if let Some(job) = &self.job { + let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT; + loop { + if job + .is_empty() + .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? + { + return Ok(()); + } + if Instant::now() >= deadline { + return Err("browser-tree-reap-unconfirmed".into()); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + Ok(()) + } + #[cfg(not(windows))] + { + // 兜底确认 root 确已退出:否则调用方会在浏览器仍存活时删除 + // 配置目录,且 browser-cleanup-unconfirmed 永远不会暴露。 + match self.child.inner.try_wait() { + Ok(Some(_)) => Ok(()), + _ => Err("browser-tree-reap-unconfirmed".into()), + } + } + } +} + +impl Drop for BrowserProcessGuard { + fn drop(&mut self) { + // Windows:Job 句柄关闭触发 KILL_ON_JOB_CLOSE 收掉整树; + // Unix:tokio kill_on_drop 终止 root,子进程随 IPC 断开级联退出。 + } +} + +struct OwnedBrowser { + browser: Browser, + process: BrowserProcessGuard, + handler_task: tokio::task::JoinHandle<()>, + drain_task: tokio::task::JoinHandle<()>, + // Option 以便在收割未确认时取出并保留目录(OwnedBrowser 有 Drop, + // 不能直接移动字段)。 + temp: Option, +} + +impl OwnedBrowser { + fn browser(&self) -> &Browser { + &self.browser + } + + /// 优雅收束:CDP close → wait;失败则整树终止并确认收割。 + async fn shutdown(mut self) -> Result<(), String> { + let close = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.browser.close()).await; + let waited = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.process.child.wait()).await; + self.handler_task.abort(); + self.drain_task.abort(); + if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) { + return Ok(()); + } + if !self.process.reap().await { + // 进程退出未确认:保留目录与 owner.json,留待下次启动或 + // 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。 + if let Some(temp) = self.temp.take() { + std::mem::forget(temp); + } + return Err("browser-cleanup-unconfirmed".into()); + } + let confirmed = self.process.confirm_reaped().await; + if confirmed.is_err() { + if let Some(temp) = self.temp.take() { + std::mem::forget(temp); + } + } + confirmed + } +} + +impl Drop for OwnedBrowser { + fn drop(&mut self) { + self.handler_task.abort(); + self.drain_task.abort(); + // 进程侧由 BrowserProcessGuard 的 Drop 兜底。 + } +} + +/// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出 +/// 窗口:绑定前产生的子进程未入 Job——Unix 上随 root 死亡级联退出; +/// Windows 没有这种级联,但窗口只有毫秒级(Chrome 此时尚未拉起子 +/// 进程),真发生泄漏时临时目录因被占用而保留,留待系统或用户清理。 +/// 绑定之后的一切子进程由 Job 全覆盖。 +async fn launch_owned_browser( + config: BrowserConfig, + executable: &std::path::Path, + temp: TempDir, +) -> Result { + let child = config + .launch() + .map_err(|_| OwnedBrowserLaunchError::SpawnFailed)?; + // 无法证明整树可收割时拒绝放行浏览器;child 随 drop 由 kill_on_drop 收尾。 + #[cfg(windows)] + let job = match WindowsProcessJob::assign_tokio(&child.inner) { + Ok(job) => Some(job), + Err(_) => return Err(OwnedBrowserLaunchError::SpawnFailed), + }; + let mut process = BrowserProcessGuard { + child, + #[cfg(windows)] + job, + }; + // 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。 + if let Some(pid) = process.child.inner.id() { + super::sweep::write_browser_process_owner(temp.path(), pid, executable); + } + let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await + { + Ok(pair) => pair, + Err(error) => { + // 收割未确认时保留目录与 owner.json,交给跨会话清扫。 + if !process.reap().await { + std::mem::forget(temp); + } + return Err(error); + } + }; + let drain_task = spawn_stderr_drain(reader); + let connected = tokio::time::timeout( + BROWSER_TIMEOUT, + Browser::connect_with_config(url, handler_config()), ) - .await - .map_err(|_| "browser-start-timeout")? - .map_err(|_| "browser-start-failed")?; + .await; + let (browser, mut handler) = match connected { + Ok(Ok(pair)) => pair, + Ok(Err(_)) => { + drain_task.abort(); + if !process.reap().await { + std::mem::forget(temp); + } + return Err(OwnedBrowserLaunchError::ConnectFailed); + } + Err(_) => { + drain_task.abort(); + if !process.reap().await { + std::mem::forget(temp); + } + return Err(OwnedBrowserLaunchError::ConnectTimeout); + } + }; let handler_task = tokio::spawn(async move { while let Some(message) = handler.next().await { if message.is_err() { @@ -85,14 +379,33 @@ pub(crate) async fn check_browser_health() -> Result { } } }); - let version = tokio::time::timeout(Duration::from_secs(5), browser.version()).await; - let close = tokio::time::timeout(Duration::from_secs(5), browser.close()).await; - let waited = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await; - handler_task.abort(); - let _ = handler_task.await; - if !matches!(close, Ok(Ok(_))) || !matches!(waited, Ok(Ok(_))) { - let _ = tokio::time::timeout(Duration::from_secs(5), browser.kill()).await; - let _ = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await; + Ok(OwnedBrowser { + browser, + process, + handler_task, + drain_task, + temp: Some(temp), + }) +} + +/// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 +/// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 +pub(crate) async fn check_browser_health() -> Result { + let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; + let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?; + let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>") + .map_err(|_| "browser-config-invalid")?; + let owned = launch_owned_browser(config, &discovered.executable_path, temporary) + .await + .map_err(|error| { + if error.is_timeout() { + "browser-start-timeout" + } else { + "browser-start-failed" + } + })?; + let version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await; + if owned.shutdown().await.is_err() { return Err("browser-cleanup-failed".into()); } let version = version @@ -155,23 +468,18 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( &proxy_bypass_list, )?; - let (mut browser, mut handler) = tokio::time::timeout( - BROWSER_TIMEOUT + Duration::from_secs(5), - Browser::launch(config), - ) - .await - .map_err(|_| "启动浏览器超时".to_string())? - .map_err(|error| format!("启动浏览器失败:{error}"))?; - let handler_task = tokio::spawn(async move { - while let Some(message) = handler.next().await { - if message.is_err() { - break; + let owned = launch_owned_browser(config, &browser_executable.executable_path, browser_temp) + .await + .map_err(|error| { + if error.is_timeout() { + "启动浏览器超时".to_string() + } else { + format!("启动浏览器失败:{}", error.code()) } - } - }); + })?; let work = run_browser_validation( - &browser, + owned.browser(), &browser_executable, &preview_url, &input, @@ -191,22 +499,11 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( _=cancelled => Err("宿主已停止本轮浏览器验证".to_string()), }; - let close_result = tokio::time::timeout(Duration::from_secs(5), browser.close()).await; - let wait_result = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await; - handler_task.abort(); - let _ = handler_task.await; - if !matches!(close_result, Ok(Ok(_))) || !matches!(wait_result, Ok(Ok(_))) { - let _ = tokio::time::timeout(Duration::from_secs(5), browser.kill()).await; - if !matches!( - tokio::time::timeout(Duration::from_secs(5), browser.wait()).await, - Ok(Ok(_)) - ) { - return Err( - "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(), - ); - } + if owned.shutdown().await.is_err() { + return Err( + "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(), + ); } - drop(browser_temp); let mut result = validation?; result.completed_at_unix_ms = unix_time_ms(); let persisted_result = browser_validation_result_for_report(&result)?; @@ -231,6 +528,61 @@ mod health_tests { assert!(safe_version_label("HeadlessChrome/140.0.1.2")); } + #[test] + fn devtools_ws_url_only_accepts_loopback_browser_endpoints() { + assert_eq!( + parse_devtools_ws_url( + "DevTools listening on ws://127.0.0.1:53317/devtools/browser/abc-123\r\n" + ) + .as_deref(), + Some("ws://127.0.0.1:53317/devtools/browser/abc-123") + ); + assert!(parse_devtools_ws_url( + "DevTools listening on ws://localhost:9222/devtools/browser/x" + ) + .is_some()); + assert!( + parse_devtools_ws_url("DevTools listening on ws://[::1]:9222/devtools/browser/x") + .is_some() + ); + // 非 loopback、非 ws 协议、非 browser 端点、非法端口一律拒绝。 + assert!(parse_devtools_ws_url( + "DevTools listening on ws://evil.example.com:9222/devtools/browser/x" + ) + .is_none()); + assert!(parse_devtools_ws_url( + "DevTools listening on http://127.0.0.1:9222/devtools/browser/x" + ) + .is_none()); + assert!( + parse_devtools_ws_url("DevTools listening on ws://127.0.0.1:9222/devtools/page/x") + .is_none() + ); + assert!(parse_devtools_ws_url( + "DevTools listening on ws://127.0.0.1:notaport/devtools/browser/x" + ) + .is_none()); + assert!(parse_devtools_ws_url("random browser log line").is_none()); + let overlong = format!( + "DevTools listening on ws://127.0.0.1:9222/devtools/browser/{}", + "a".repeat(MAX_WS_URL_LEN) + ); + assert!(parse_devtools_ws_url(&overlong).is_none()); + } + + #[test] + fn handler_config_mirrors_browser_config_builder() { + let config = handler_config(); + assert_eq!(config.request_timeout, BROWSER_TIMEOUT); + assert!(config.request_intercept); + assert!(!config.cache_enabled); + // 其余字段必须与 BrowserConfigBuilder 默认值保持一致 + // (builder 默认 viewport 为 Some(Viewport::default()),即 800x600)。 + assert!(config.ignore_https_errors); + assert!(config.ignore_invalid_messages); + assert_eq!(config.viewport, Some(Viewport::default())); + } + #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge; local CDP only"] async fn real_browser_health_checks_can_run_concurrently() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs new file mode 100644 index 000000000..21c681161 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs @@ -0,0 +1,613 @@ +//! 跨会话清扫陈旧的无头浏览器进程与临时目录。 +//! +//! 运行期 launch 会把 owner.json 写进 ga-browser-* 目录;启动与每次预检前 +//! 按「进程身份 + 可信浏览器路径」双重校验收割遗留进程树并删除目录。 +//! 没有 owner.json 的旧版本残留只删目录,绝不按猜测杀进程。 + +use std::fs; +use std::path::Path; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use super::discovery::system_browser_candidates; +use super::process::{browser_process_temp_root, BROWSER_TEMP_PREFIX}; + +const OWNER_FILE: &str = "owner.json"; +const OWNER_SCHEMA: &str = "agc-browser-process.v1"; +/// 过新的目录可能属于正在进行中的预检(含 owner.json 写失败的极端情况),跳过。 +const SWEEP_MIN_AGE: Duration = Duration::from_secs(5 * 60); +const TREE_KILL_MAX_PASSES: u32 = 20; +const TREE_KILL_PASS_INTERVAL: Duration = Duration::from_millis(50); + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct BrowserProcessOwner { + schema_version: String, + browser_pid: u32, + browser_start_identity: String, + executable: String, + owner_pid: u32, + owner_start_identity: String, + created_unix_ms: u64, +} + +/// 运行期 launch 的身份锚点;任何一步拿不到身份证明都不写, +/// 该目录之后按旧残留只删不杀。 +pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, executable: &Path) { + let identity = crate::runner::external_agent_runner_process_start_identity(browser_pid); + let owner_identity = + crate::runner::external_agent_runner_process_start_identity(std::process::id()); + let (Ok(Some(identity)), Ok(Some(owner_identity))) = (identity, owner_identity) else { + return; + }; + let owner = BrowserProcessOwner { + schema_version: OWNER_SCHEMA.into(), + browser_pid, + browser_start_identity: identity, + executable: executable.to_string_lossy().into_owned(), + owner_pid: std::process::id(), + owner_start_identity: owner_identity, + created_unix_ms: super::evidence::unix_time_ms(), + }; + let Ok(bytes) = serde_json::to_vec_pretty(&owner) else { + return; + }; + let _ = fs::write(temp_root.join(OWNER_FILE), bytes); +} + +fn read_browser_process_owner(dir: &Path) -> Option { + let bytes = fs::read(dir.join(OWNER_FILE)).ok()?; + if bytes.len() > 16 * 1024 { + return None; + } + let owner: BrowserProcessOwner = serde_json::from_slice(&bytes).ok()?; + if owner.schema_version != OWNER_SCHEMA + || owner.browser_pid == 0 + || owner.owner_pid == 0 + || owner.browser_start_identity.is_empty() + || owner.browser_start_identity.len() > 128 + || owner.owner_start_identity.is_empty() + || owner.owner_start_identity.len() > 128 + || owner.executable.is_empty() + || owner.executable.len() > 1024 + { + return None; + } + Some(owner) +} + +/// PID 存活且启动身份一致(创建时间相同),排除 PID 复用。 +fn process_identity_matches(pid: u32, identity: &str) -> bool { + crate::runner::external_agent_runner_process_start_identity(pid) + .ok() + .flatten() + .is_some_and(|live| live == identity) +} + +/// 可执行文件路径等价性:双方规范化后比较,Windows 下忽略大小写。 +fn same_executable_path(actual: &Path, expected: &Path) -> bool { + let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let actual = canonical(actual); + let expected = canonical(expected); + #[cfg(windows)] + { + return actual + .to_string_lossy() + .eq_ignore_ascii_case(&expected.to_string_lossy()); + } + #[cfg(not(windows))] + { + actual == expected + } +} + +/// exe 必须仍是系统标准路径下的可信浏览器,防止伪造 owner.json 借刀杀进程。 +fn trusted_browser_executable(path: &Path) -> bool { + let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if !canonical.is_file() { + return false; + } + system_browser_candidates() + .into_iter() + .any(|(candidate, _)| same_executable_path(&candidate, path)) +} + +/// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行 +/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造 +/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。 +/// Linux 进一步要求命令行声明本目录的 profile,把 PID 绑到这份配置 +/// 目录,挡住同用户伪造 owner.json 指向正在使用的浏览器。 +#[cfg(windows)] +fn live_process_executable_matches(pid: u32, expected: &Path, _profile_dir: &Path) -> bool { + // 已知残余风险:Windows 读他进程命令行成本高(PEB/WMI),此处只核对 + // 镜像;同用户伪造 owner.json 指向正在使用的浏览器时可误杀它。 + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, + }; + // SAFETY: 句柄非空时由 CloseHandle 释放;打开失败按不匹配处理(fail-closed)。 + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() { + return false; + } + let mut buffer = [0u16; 1024]; + let mut length = buffer.len() as u32; + // SAFETY: buffer 可写,length 先传入容量、返回实际长度。 + let okay = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) }; + // SAFETY: handle 是本函数持有的合法句柄。 + unsafe { CloseHandle(handle) }; + if okay == 0 || length == 0 { + return false; + } + let actual = std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..length as usize])); + same_executable_path(&actual, expected) +} + +#[cfg(target_os = "linux")] +fn live_process_executable_matches(pid: u32, expected: &Path, profile_dir: &Path) -> bool { + let Ok(actual) = fs::read_link(format!("/proc/{pid}/exe")) else { + return false; + }; + if !same_executable_path(&actual, expected) { + return false; + } + // 命令行必须携带 --user-data-dir=<本目录>/profile:镜像一致只证明是 + // 浏览器,不能证明是“这轮预检的”浏览器。 + let Ok(cmdline) = fs::read(format!("/proc/{pid}/cmdline")) else { + return false; + }; + let marker = format!("--user-data-dir={}", profile_dir.to_string_lossy()); + cmdline + .split(|byte| *byte == 0) + .any(|arg| arg == marker.as_bytes()) +} + +// macOS 等无 /proc 的平台没有廉价的镜像与命令行核对手段,由目录属主检查 +// 兜底;已知残余风险:同用户伪造 owner.json 时可终止任意存活 PID。 +#[cfg(all(unix, not(target_os = "linux")))] +fn live_process_executable_matches(_pid: u32, expected: &Path, _profile_dir: &Path) -> bool { + expected.is_file() +} + +/// /tmp 是全局可写目录:属主目录必须由本用户创建,否则视为伪造并拒绝处置。 +#[cfg(unix)] +fn directory_owned_by_current_user(dir: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + dir.metadata() + // SAFETY: geteuid 无前置条件,总是安全的。 + .map(|metadata| metadata.uid() == unsafe { libc::geteuid() }) + .unwrap_or(false) +} + +fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> { + #[cfg(windows)] + { + // 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判 + // 成功——root 先死而子进程残留时不再误报收割完成。 + let mut tracked: Vec<(u32, String)> = Vec::new(); + for _ in 0..TREE_KILL_MAX_PASSES { + let snapshot = windows_process_snapshot()?; + let root_present = snapshot.iter().any(|(pid, _)| *pid == root_pid); + if root_present && process_identity_matches(root_pid, expected_identity) { + // root 仍是目标浏览器:发现并追踪当前整棵子树。 + for pid in windows_process_tree_pids_from(&snapshot, root_pid)? { + track_process(&mut tracked, pid); + } + } else if !root_present { + // root 已退出且 PID 未被复用:发现临终前才拉起、仍挂在旧父 + // PID 上的孤儿子进程。PID 已被复用时不做发现,避免误认 + // 复用者的子进程。 + for (pid, ppid) in &snapshot { + if *ppid == root_pid { + track_process(&mut tracked, *pid); + } + } + } + // 只统计仍存活且启动身份未变的成员(复核排除 PID 复用); + // 全部消失才算收割完成。 + tracked.retain(|(pid, identity)| { + snapshot.iter().any(|(live, _)| live == pid) + && process_identity_matches(*pid, identity) + }); + if tracked.is_empty() { + return Ok(()); + } + for (pid, _) in &tracked { + // 终止结果以“下一轮快照中是否消失”验证;对正在退出的进程 + // OpenProcess 的瞬时失败会在下一轮自然消解。 + windows_terminate_process(*pid); + } + std::thread::sleep(TREE_KILL_PASS_INTERVAL); + } + return Err("browser-sweep-tree-not-reaped".into()); + } + #[cfg(not(windows))] + { + // 只杀 root:子进程随 IPC 断开级联退出(与运行期收割语义一致)。 + if !process_identity_matches(root_pid, expected_identity) { + return Ok(()); + } + // SAFETY: 目标 PID 与启动身份均已复核。 + if unsafe { libc::kill(root_pid as i32, libc::SIGKILL) } != 0 { + return Err("browser-sweep-kill-failed".into()); + } + // SIGKILL 后必须确认进程真正消失再报成功。 + for _ in 0..TREE_KILL_MAX_PASSES { + // SAFETY: signal 0 仅做存活探测,无副作用。 + if unsafe { libc::kill(root_pid as i32, 0) } != 0 { + return Ok(()); + } + std::thread::sleep(TREE_KILL_PASS_INTERVAL); + } + Err("browser-sweep-tree-not-reaped".into()) + } +} + +#[cfg(windows)] +/// 记录待收割成员及其启动身份:之后每轮终止前复核身份,排除 PID 复用 +/// 后误杀无关进程。拿不到身份的进程不追踪、不触碰(fail-closed)。 +#[cfg(windows)] +fn track_process(tracked: &mut Vec<(u32, String)>, pid: u32) { + if tracked.iter().any(|(known, _)| *known == pid) { + return; + } + if let Ok(Some(identity)) = crate::runner::external_agent_runner_process_start_identity(pid) { + tracked.push((pid, identity)); + } +} + +#[cfg(windows)] +fn windows_process_snapshot() -> Result, String> { + use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, ERROR_NO_MORE_FILES, INVALID_HANDLE_VALUE, + }; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + // SAFETY: 快照句柄非 INVALID_HANDLE_VALUE 时由 CloseHandle 释放。 + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err("browser-sweep-snapshot-failed".into()); + } + let mut entry = PROCESSENTRY32W::default(); + entry.dwSize = std::mem::size_of::() as u32; + let mut processes = Vec::new(); + // SAFETY: entry 指向可写的 PROCESSENTRY32W,dwSize 已初始化。返回 0 + // 只在 ERROR_NO_MORE_FILES 时是正常结束,其余错误按截断处理 + // (fail-closed:残缺快照会导致误报收割完成)。 + let mut available = unsafe { Process32FirstW(snapshot, &mut entry) }; + loop { + if available == 0 { + // SAFETY: 紧跟失败的枚举调用读取错误码。 + let error = unsafe { GetLastError() }; + // SAFETY: snapshot 是本函数持有的合法句柄。 + unsafe { CloseHandle(snapshot) }; + if error == ERROR_NO_MORE_FILES { + return Ok(processes); + } + return Err("browser-sweep-snapshot-truncated".into()); + } + processes.push((entry.th32ProcessID, entry.th32ParentProcessID)); + // SAFETY: 同上。 + available = unsafe { Process32NextW(snapshot, &mut entry) }; + } +} + +#[cfg(windows)] +fn windows_process_tree_pids_from( + processes: &[(u32, u32)], + root_pid: u32, +) -> Result, String> { + if !processes.iter().any(|(pid, _)| *pid == root_pid) { + return Ok(Vec::new()); + } + let mut tree = vec![root_pid]; + let mut index = 0; + while index < tree.len() { + let parent = tree[index]; + for (pid, ppid) in processes { + if *ppid == parent && !tree.contains(pid) { + tree.push(*pid); + } + } + index += 1; + if tree.len() > 512 { + return Err("browser-sweep-tree-too-large".into()); + } + } + Ok(tree) +} + +#[cfg(windows)] +fn windows_terminate_process(pid: u32) { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess}; + const PROCESS_TERMINATE: u32 = 0x0001; + // SAFETY: 句柄非空时由 CloseHandle 释放;空句柄直接返回。 + let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) }; + if handle.is_null() { + return; + } + // SAFETY: handle 是刚打开的合法进程句柄,退出码仅作占位。 + unsafe { TerminateProcess(handle, 1) }; + // SAFETY: 同上。 + unsafe { CloseHandle(handle) }; +} + +/// 清扫陈旧的无头浏览器进程与临时目录,返回逐条处置记录供启动日志留痕。 +pub(crate) fn sweep_stale_browser_processes() -> Vec { + sweep_stale_browser_processes_at(&browser_process_temp_root(), SWEEP_MIN_AGE) +} + +fn sweep_stale_browser_processes_at(root: &Path, min_age: Duration) -> Vec { + let mut notes = Vec::new(); + let Ok(entries) = fs::read_dir(root) else { + return notes; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.starts_with(BROWSER_TEMP_PREFIX) { + continue; + } + let dir = entry.path(); + let Ok(metadata) = entry.metadata() else { + continue; + }; + if !metadata.is_dir() { + continue; + } + let too_young = metadata + .created() + .or_else(|_| metadata.modified()) + .ok() + .and_then(|time| time.elapsed().ok()) + .is_some_and(|age| age < min_age); + if too_young { + continue; + } + match read_browser_process_owner(&dir) { + Some(owner) => sweep_owned_directory(&dir, &name, &owner, &mut notes), + None => match fs::remove_dir_all(&dir) { + Ok(()) => notes.push(format!("removed-legacy-dir:{name}")), + Err(_) => notes.push(format!("kept-legacy-dir-in-use:{name}")), + }, + } + } + notes +} + +fn sweep_owned_directory( + dir: &Path, + name: &str, + owner: &BrowserProcessOwner, + notes: &mut Vec, +) { + // /tmp 全局可写:属主目录必须是本用户创建,否则视为伪造,拒绝处置。 + #[cfg(unix)] + if !directory_owned_by_current_user(dir) { + notes.push(format!("kept-foreign-dir:{name}")); + return; + } + // 另一个活着的 App 实例仍持有这轮浏览器:整目录跳过。 + if process_identity_matches(owner.owner_pid, &owner.owner_start_identity) { + return; + } + if !process_identity_matches(owner.browser_pid, &owner.browser_start_identity) { + // 进程已退出或 PID 已被复用:不动任何进程,仅删目录。 + if fs::remove_dir_all(dir).is_ok() { + notes.push(format!("removed-exited-dir:{name}")); + } + return; + } + if !trusted_browser_executable(Path::new(&owner.executable)) { + notes.push(format!("kept-untrusted-executable:{name}")); + return; + } + // 活进程镜像必须与声明的浏览器一致:防止伪造 owner.json 借清扫 + // 终止本用户的无关进程。profile 子目录名与 process.rs 的 + // browser_config 保持一致;Linux 上借此把 PID 绑到本目录。 + if !live_process_executable_matches( + owner.browser_pid, + Path::new(&owner.executable), + &dir.join("profile"), + ) { + notes.push(format!("kept-image-mismatch:{name}")); + return; + } + match kill_browser_process_tree(owner.browser_pid, &owner.browser_start_identity) { + Ok(()) => { + let _ = fs::remove_dir_all(dir); + notes.push(format!( + "killed-stale-tree:{name}:pid={}", + owner.browser_pid + )); + } + Err(_) => notes.push(format!("kill-unconfirmed:{name}:pid={}", owner.browser_pid)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn exited_child_pid() -> u32 { + #[cfg(windows)] + let mut command = std::process::Command::new("cmd.exe"); + #[cfg(windows)] + command.args(["/c", "exit", "0"]); + #[cfg(not(windows))] + let mut command = std::process::Command::new("/bin/true"); + let mut child = command.spawn().expect("spawn exit fixture"); + let pid = child.id(); + child.wait().expect("reap exit fixture"); + pid + } + + fn write_owner(dir: &Path, owner: &BrowserProcessOwner) { + fs::write( + dir.join(OWNER_FILE), + serde_json::to_vec_pretty(owner).unwrap(), + ) + .unwrap(); + } + + fn owner_fixture(browser_pid: u32, owner_pid: u32) -> BrowserProcessOwner { + BrowserProcessOwner { + schema_version: OWNER_SCHEMA.into(), + browser_pid, + browser_start_identity: "fixture-identity".into(), + executable: "C:\\fixture\\chrome.exe".into(), + owner_pid, + owner_start_identity: "fixture-owner-identity".into(), + created_unix_ms: super::super::evidence::unix_time_ms(), + } + } + + #[test] + fn legacy_directory_without_owner_file_is_removed_only_when_old_enough() { + let root = tempfile::tempdir().unwrap(); + let stale = root.path().join(format!("{BROWSER_TEMP_PREFIX}stale")); + fs::create_dir(&stale).unwrap(); + // 默认年龄门禁:新建的目录视为可能属于进行中的预检,跳过。 + assert!(sweep_stale_browser_processes_at(root.path(), SWEEP_MIN_AGE).is_empty()); + assert!(stale.is_dir()); + // 零门禁等价于“足够旧”:无 owner.json 的旧残留只删目录。 + let notes = sweep_stale_browser_processes_at(root.path(), Duration::ZERO); + assert!(notes + .iter() + .any(|note| note.starts_with("removed-legacy-dir:"))); + assert!(!stale.exists()); + } + + #[test] + fn directory_owned_by_a_live_app_instance_is_skipped() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join(format!("{BROWSER_TEMP_PREFIX}owned")); + fs::create_dir(&dir).unwrap(); + let owner_identity = + crate::runner::external_agent_runner_process_start_identity(std::process::id()) + .expect("self identity") + .expect("self identity present"); + let mut owner = owner_fixture(exited_child_pid(), std::process::id()); + owner.owner_start_identity = owner_identity; + write_owner(&dir, &owner); + assert!(sweep_stale_browser_processes_at(root.path(), Duration::ZERO).is_empty()); + assert!(dir.is_dir()); + } + + #[test] + fn exited_or_reused_browser_pid_deletes_directory_without_touching_processes() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join(format!("{BROWSER_TEMP_PREFIX}exited")); + fs::create_dir(&dir).unwrap(); + let owner = owner_fixture(exited_child_pid(), exited_child_pid()); + write_owner(&dir, &owner); + let notes = sweep_stale_browser_processes_at(root.path(), Duration::ZERO); + assert!(notes + .iter() + .any(|note| note.starts_with("removed-exited-dir:"))); + assert!(!dir.exists()); + } + + #[test] + fn owner_file_with_bad_schema_or_unknown_fields_is_treated_as_legacy() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join(format!("{BROWSER_TEMP_PREFIX}bad-owner")); + fs::create_dir(&dir).unwrap(); + fs::write( + dir.join(OWNER_FILE), + br#"{"schemaVersion":"other","extra":true}"#, + ) + .unwrap(); + assert!(read_browser_process_owner(&dir).is_none()); + let notes = sweep_stale_browser_processes_at(root.path(), Duration::ZERO); + assert!(notes + .iter() + .any(|note| note.starts_with("removed-legacy-dir:"))); + assert!(!dir.exists()); + } + + #[test] + fn trusted_browser_executable_matches_system_candidates_only() { + assert!(!trusted_browser_executable(Path::new( + "C:\\fixture\\chrome.exe" + ))); + let Some((installed, _)) = system_browser_candidates() + .into_iter() + .find(|(candidate, _)| candidate.is_file()) + else { + // 没有安装浏览器的机器上无可信目标可断言。 + return; + }; + assert!(trusted_browser_executable(&installed)); + } + + #[cfg(windows)] + #[test] + fn kill_browser_process_tree_reaps_fixture_tree() { + use std::process::{Command, Stdio}; + + // timeout.exe 在 stdio 被重定向时会立即退出,ping 才能在 null stdio 下存活。 + let mut child = Command::new("cmd.exe") + .args([ + "/c", + "ping", + "127.0.0.1", + "-n", + "60", + "&", + "ping", + "127.0.0.1", + "-n", + "60", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn fixture"); + let pid = child.id(); + std::thread::sleep(Duration::from_millis(500)); + let identity = crate::runner::external_agent_runner_process_start_identity(pid) + .expect("fixture identity") + .expect("fixture identity present"); + let before = + windows_process_tree_pids_from(&windows_process_snapshot().expect("snapshot"), pid) + .expect("tree snapshot"); + assert!(before.len() >= 2, "fixture 必须包含子进程: {before:?}"); + kill_browser_process_tree(pid, &identity).expect("kill tree"); + // 不仅 root 消失,快照里记录到的每个树成员(含 ping 子进程)都必须 + // 真正被收割,不能只凭 root 不在就判成功。 + let alive = windows_process_snapshot().expect("snapshot after kill"); + for member in &before { + assert!( + !alive.iter().any(|(live, _)| live == member), + "fixture 进程 {member} 必须被收割: {before:?}" + ); + } + let _ = child.kill(); + let _ = child.wait(); + } + + #[cfg(any(windows, target_os = "linux"))] + #[test] + fn live_process_executable_matches_current_process_image() { + let exe = std::env::current_exe().unwrap(); + let profile = Path::new("C:\\fixture\\ga-browser-x\\profile"); + // Windows 只核对镜像;Linux 还要求命令行绑定 profile,本测试进程 + // 不具备该标记,正例只在 Windows 断言。 + #[cfg(windows)] + assert!(live_process_executable_matches( + std::process::id(), + &exe, + profile + )); + assert!(!live_process_executable_matches( + std::process::id(), + Path::new("C:\\fixture\\chrome.exe"), + profile + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 3a1b403c1..2c24760aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -6164,6 +6164,16 @@ pub(crate) fn export_local_project_package( export_local_project_package_at(root) } +#[tauri::command] +pub(crate) fn read_local_project_export_package( + project_path: String, + package_relative_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_package")?; + read_local_project_export_package_at(root, package_relative_path.trim()) +} + #[tauri::command] pub(crate) fn list_local_project_export_packages( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs index 532503c75..4378f32d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs @@ -304,6 +304,8 @@ async fn host_npm( pub(crate) async fn host_web_creation_preflight() -> Value { let started = Instant::now(); + // 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。 + let _ = tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes).await; let run = async { let fixture = tempfile::tempdir().map_err(|_| "web-preflight-temp-unavailable")?; let root = fixture.path(); 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 5fd240119..2f3642a99 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2506,6 +2506,17 @@ fn main() { if let Some(directory) = game_creator_runtime_config_dir() { setup_log.set(directory.join("diagnostics/startup.log")); } + // 跨会话清扫陈旧的无头浏览器(上次异常退出/被杀留下的 ga-browser-*)。 + // 后台执行,不阻塞启动;杀树前按进程身份与可信浏览器路径双重校验。 + tauri::async_runtime::spawn(async move { + let notes = + tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes) + .await + .unwrap_or_default(); + if !notes.is_empty() { + app_log!("startup.browser-sweep: {}", notes.join("; ")); + } + }); setup_log.append("startup.appdata.configure.complete"); let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { let error = std::io::Error::new( @@ -2761,6 +2772,7 @@ fn main() { build_local_project_index, create_local_project_checkpoint, export_local_project_package, + read_local_project_export_package, list_local_project_export_packages, diff_local_project_checkpoint, restore_local_project_checkpoint, diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs index d963ea811..c96ef974c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs @@ -1810,3 +1810,112 @@ setInterval(() => {}, 1000); thread::sleep(Duration::from_millis(25)); } } + +#[cfg(windows)] +#[test] +fn windows_process_job_terminate_reaps_process_tree() { + use std::process::{Command, Stdio}; + + // 系统进程快照(pid, ppid),用于证明 ping 子进程真实存在并被收割。 + fn windows_process_snapshot_for_test() -> Vec<(u32, u32)> { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + // SAFETY: 快照句柄非 INVALID_HANDLE_VALUE 时由 CloseHandle 释放。 + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Vec::new(); + } + let mut entry = PROCESSENTRY32W::default(); + entry.dwSize = std::mem::size_of::() as u32; + let mut processes = Vec::new(); + // SAFETY: entry 指向可写的 PROCESSENTRY32W,dwSize 已初始化。 + let mut available = unsafe { Process32FirstW(snapshot, &mut entry) }; + while available != 0 { + processes.push((entry.th32ProcessID, entry.th32ParentProcessID)); + // SAFETY: 同上。 + available = unsafe { Process32NextW(snapshot, &mut entry) }; + } + // SAFETY: snapshot 是本函数持有的合法句柄。 + unsafe { CloseHandle(snapshot) }; + processes + } + + // cmd 启动第一个 ping 子进程后整树存活;terminate 必须连子进程一起收割。 + // timeout.exe 在 stdio 被重定向时会立即退出,ping 才能在 null stdio 下存活。 + let mut child = Command::new("cmd.exe") + .args([ + "/c", + "ping", + "127.0.0.1", + "-n", + "60", + "&", + "ping", + "127.0.0.1", + "-n", + "60", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn cmd fixture"); + let job = WindowsProcessJob::assign_std(&child).expect("assign job"); + thread::sleep(Duration::from_millis(500)); + // 前置检查不能只凭 Job 非空:快照里必须真的看到 cmd 拉起了 ping 子进程。 + let tree_before: Vec = { + let snapshot = windows_process_snapshot_for_test(); + let mut tree = vec![child.id()]; + let mut index = 0; + while index < tree.len() { + let parent = tree[index]; + for (pid, ppid) in &snapshot { + if *ppid == parent && !tree.contains(pid) { + tree.push(*pid); + } + } + index += 1; + } + tree + }; + assert!( + tree_before.len() >= 2, + "fixture 必须包含 ping 子进程,否则收割断言是空转: {tree_before:?}" + ); + assert!( + !job.is_empty().expect("query job"), + "fixture 进程树必须先存活,否则收割断言是空转" + ); + job.terminate().expect("terminate job"); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !job.is_empty().expect("query job") { + assert!( + std::time::Instant::now() < deadline, + "Windows Job 进程树未被收割" + ); + thread::sleep(Duration::from_millis(50)); + } + // Job 为空之外,快照里的整棵树(含 ping 子进程)也必须真实消失。 + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let snapshot = windows_process_snapshot_for_test(); + let survivors: Vec = tree_before + .iter() + .copied() + .filter(|pid| snapshot.iter().any(|(live, _)| live == pid)) + .collect(); + if survivors.is_empty() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "Job terminate 后 fixture 子进程仍存活: {survivors:?}" + ); + thread::sleep(Duration::from_millis(50)); + } + let _ = child.kill(); + let _ = child.wait(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index 6b4f7f43c..cb8fc948c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -1,5 +1,27 @@ use super::*; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::io::{Cursor, Read}; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectExportPackageFileDigest { + pub(crate) path: String, + pub(crate) size_bytes: u64, + pub(crate) sha256: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectExportPackagePayload { + pub(crate) package_relative_path: String, + pub(crate) package_bytes: Vec, + pub(crate) package_sha256: String, + pub(crate) package_size_bytes: u64, + pub(crate) files: Vec, +} + pub(crate) fn export_local_project_package_at( root: &Path, ) -> Result { @@ -110,6 +132,114 @@ pub(crate) fn export_local_project_package_at( }) } +/// Read a previously exported package for the explicit AGC publish flow. +/// +/// The caller receives the package bytes and a deterministic file manifest, but +/// never receives a filesystem path that it could accidentally send to the API. +pub(crate) fn read_local_project_export_package_at( + root: &Path, + package_relative_path: &str, +) -> Result { + validate_project_root(root)?; + let normalized = normalize_export_package_entry_path(package_relative_path)?; + if !normalized.starts_with("exports/playtest-package-") + || !normalized.ends_with(".zip") + || normalized.contains('/') && normalized.split('/').count() != 2 + { + return Err("发行包路径必须是 exports/playtest-package-*.zip".to_string()); + } + let package_path = resolve_local_project_path(root, &normalized)?; + prepare_game_creator_private_path_for_read(&package_path, false, "发行包")?; + let metadata = checked_export_package_metadata(&package_path, &normalized)?; + if !metadata.is_file() { + return Err("发行包必须是普通文件".to_string()); + } + if metadata.len() == 0 || metadata.len() > MAX_PROJECT_EXPORT_PACKAGE_BYTES { + return Err("发行包大小超出本地发布上限".to_string()); + } + let package_bytes = + fs::read(&package_path).map_err(|error| format!("读取发行包失败:{error}"))?; + if package_bytes.len() as u64 != metadata.len() { + return Err("发行包在读取期间发生变化,请重新导出".to_string()); + } + + let mut archive = zip::ZipArchive::new(Cursor::new(&package_bytes)) + .map_err(|error| format!("读取发行包 ZIP 失败:{error}"))?; + let mut entries = Vec::with_capacity(archive.len()); + let mut seen = BTreeSet::new(); + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|error| format!("读取发行包条目失败:{error}"))?; + if entry.is_dir() { + continue; + } + let source_path = normalize_export_package_entry_path(entry.name())?; + // 本地试玩包以 game/index.html 为入口,而平台发行合同要求根 + // index.html。把 game/ 前缀剥离到内存 ZIP,避免上传本地路径或修改 + // 工作区里的原始导出文件;根目录的 README/assets 等公共条目原样保留。 + let path = source_path + .strip_prefix("game/") + .unwrap_or(source_path.as_str()) + .to_string(); + let path = normalize_export_package_entry_path(&path)?; + if !seen.insert(path.clone()) { + return Err(format!("发行包包含重复条目:{path}")); + } + let expected_size = entry.size(); + let mut content = Vec::with_capacity(expected_size.min(16 * 1024 * 1024) as usize); + entry + .read_to_end(&mut content) + .map_err(|error| format!("读取发行包文件失败:{path}: {error}"))?; + if content.len() as u64 != expected_size { + return Err(format!("发行包条目长度不一致:{path}")); + } + entries.push((path, content)); + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + if entries.is_empty() { + return Err("发行包没有可上传文件".to_string()); + } + + let mut normalized_writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (path, content) in &entries { + normalized_writer + .start_file(path, options) + .map_err(|error| format!("写入发行包条目失败:{path}: {error}"))?; + normalized_writer + .write_all(content) + .map_err(|error| format!("写入发行包文件失败:{path}: {error}"))?; + } + let normalized_cursor = normalized_writer + .finish() + .map_err(|error| format!("完成发行包失败:{error}"))?; + let package_bytes = normalized_cursor.into_inner(); + if package_bytes.is_empty() || package_bytes.len() as u64 > MAX_PROJECT_EXPORT_PACKAGE_BYTES { + return Err("归一化发行包大小超出本地发布上限".to_string()); + } + let package_sha256 = format!("{:x}", Sha256::digest(&package_bytes)); + let files = entries + .into_iter() + .map(|(path, content)| LocalProjectExportPackageFileDigest { + size_bytes: content.len() as u64, + sha256: format!("{:x}", Sha256::digest(&content)), + path, + }) + .collect::>(); + if !files.iter().any(|file| file.path == "index.html") { + return Err("归一化发行包缺少根 index.html".to_string()); + } + Ok(LocalProjectExportPackagePayload { + package_relative_path: normalized, + package_size_bytes: package_bytes.len() as u64, + package_bytes, + package_sha256, + files, + }) +} + pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result { let seed = unix_millis(); for suffix in 0..1000 { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index d81ed7686..dd1c045bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -348,79 +348,113 @@ pub(crate) fn should_skip_project_index_path(relative_path: &str) -> bool { || should_skip_project_snapshot_path(relative_path) } +/// 项目索引、checkpoint、Agent 上下文与 git 检查共用的排除口径:`.agent` 是这些结果的 +/// 本机控制面,不参与其中。 pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool { + project_snapshot_path_is_excluded(relative_path, false) +} + +/** + * 项目快照同步(上传)的排除口径。 + * + * 与 `should_skip_project_snapshot_path` 是同一份组件与后缀规则,唯一区别是 `.agent`: + * 它承载项目身份与 Agent 状态(`manifest.json`、`agent.db`、会话、运行日志、checkpoint、 + * workbench、`project.lock`),必须整目录随快照同步,因此不再把 `.agent` 组件本身当作 + * 排除项,并放行其中的 Agent 状态数据库(`.db` / `.db-wal` / `.db-shm`)。 + * + * 其余排除项在 `.agent` 内同样生效:版本库、依赖与构建目录、凭据目录、敏感后缀、 + * `.env*` 与凭据类文件名一律不参与同步;符号链接与重解析点在扫描阶段单独跳过。 + */ +pub(crate) fn should_skip_project_snapshot_sync_path(relative_path: &str) -> bool { + project_snapshot_path_is_excluded(relative_path, true) +} + +/// 任意层级出现即排除的目录组件。`.agent` 只有项目快照同步会放行。 +const PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS: &[&str] = &[ + ".agent", + ".git", + ".hg", + ".svn", + ".ssh", + ".aws", + ".azure", + ".gnupg", + ".kube", + ".docker", + ".gcloud", + ".terraform", + ".password-store", + ".secrets", + "secrets", + "credentials", + "node_modules", + "target", + "dist", + "build", + ".next", + "coverage", + ".cache", +]; + +/// 凭据、密钥与数据库转储类文件名后缀。 +const PROJECT_SNAPSHOT_EXCLUDED_SUFFIXES: &[&str] = &[ + ".pem", + ".key", + ".p12", + ".pfx", + ".ppk", + ".jks", + ".keystore", + ".kdbx", + ".db", + ".db-wal", + ".db-shm", + ".sqlite", + ".sqlite-wal", + ".sqlite-shm", + ".sqlite3", + ".sqlite3-wal", + ".sqlite3-shm", + ".sql", + ".sql.gz", + ".sql.bz2", + ".sql.xz", + ".dump", + ".dump.gz", + ".dmp", + ".bak", + ".mdb", + ".accdb", + ".rdb", + ".bson", + ".pgdump", + ".tfstate", + ".tfstate.backup", +]; + +/// `.agent` 内的 Agent 状态数据库(`agent.db` 及其 WAL / SHM 旁文件)属于项目状态, +/// 随快照同步;其它数据库与转储后缀仍然排除。 +const PROJECT_AGENT_STATE_DATABASE_SUFFIXES: &[&str] = &[".db", ".db-wal", ".db-shm"]; + +fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: bool) -> bool { let components = relative_path .split('/') .filter(|component| !component.is_empty()) .map(str::to_ascii_lowercase) .collect::>(); if components.iter().any(|component| { - matches!( - component.as_str(), - ".agent" - | ".git" - | ".hg" - | ".svn" - | ".ssh" - | ".aws" - | ".azure" - | ".gnupg" - | ".kube" - | ".docker" - | ".gcloud" - | ".terraform" - | ".password-store" - | ".secrets" - | "secrets" - | "credentials" - | "node_modules" - | "target" - | "dist" - | "build" - | ".next" - | "coverage" - | ".cache" - ) + PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS.contains(&component.as_str()) + && !(include_agent_state && component == ".agent") }) { return true; } let Some(file_name) = components.last() else { return true; }; - let sensitive_suffixes = [ - ".pem", - ".key", - ".p12", - ".pfx", - ".ppk", - ".jks", - ".keystore", - ".kdbx", - ".db", - ".db-wal", - ".db-shm", - ".sqlite", - ".sqlite-wal", - ".sqlite-shm", - ".sqlite3", - ".sqlite3-wal", - ".sqlite3-shm", - ".sql", - ".sql.gz", - ".sql.bz2", - ".sql.xz", - ".dump", - ".dump.gz", - ".dmp", - ".bak", - ".mdb", - ".accdb", - ".rdb", - ".bson", - ".pgdump", - ".tfstate", - ".tfstate.backup", - ]; + let agent_state_database = include_agent_state + && components + .first() + .is_some_and(|first| first.as_str() == ".agent"); let structured_secret_suffixes = [".json", ".txt", ".toml", ".yaml", ".yml"]; file_name == ".env" || file_name.starts_with(".env.") @@ -471,9 +505,10 @@ pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool { || file_name.starts_with("id_ecdsa") || file_name.starts_with("id_ed25519") || file_name.starts_with("id_xmss") - || sensitive_suffixes - .iter() - .any(|suffix| file_name.ends_with(suffix)) + || PROJECT_SNAPSHOT_EXCLUDED_SUFFIXES.iter().any(|suffix| { + file_name.ends_with(suffix) + && !(agent_state_database && PROJECT_AGENT_STATE_DATABASE_SUFFIXES.contains(suffix)) + }) || ((file_name.contains("cookie") || file_name.contains("credential")) && structured_secret_suffixes .iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs index 964e10a38..a17a89b38 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs @@ -23,9 +23,10 @@ pub(crate) struct ProjectSnapshotScanResult { pub(crate) skipped: Vec, } -/// 扫描项目目录,复用 checkpoint / 项目索引同一份排除口径: -/// `.agent`、版本控制目录、依赖与构建产物目录、凭据目录、符号链接与重解析点 -/// 都不参与同步,超出单文件上限的文件进入跳过清单而不是静默丢弃。 +/// 扫描项目目录,排除口径见 `should_skip_project_snapshot_sync_path`: +/// `.agent` 是项目身份与 Agent 状态的权威位置,整目录参与同步;版本控制目录、 +/// 依赖与构建产物目录、凭据目录、符号链接与重解析点都不参与同步,超出单文件 +/// 上限的文件进入跳过清单而不是静默丢弃。 pub(crate) fn scan_project_snapshot_files( root: &Path, max_file_bytes: u64, @@ -52,7 +53,7 @@ pub(crate) fn scan_project_snapshot_files( let Ok(relative_path) = relative_project_path(root, &path) else { continue; }; - if should_skip_project_snapshot_path(&relative_path) { + if should_skip_project_snapshot_sync_path(&relative_path) { continue; } let metadata = match fs::symlink_metadata(&path) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs index a39b2b8b1..73a7209c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs @@ -95,8 +95,6 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() { let root = fixture_root(); write_fixture_file(root.path(), "game/index.html", b""); write_fixture_file(root.path(), "assets/manifest.json", b"{}"); - write_fixture_file(root.path(), ".agent/runtime/state.json", b"{}"); - write_fixture_file(root.path(), ".agent/manifest.json", b"{}"); write_fixture_file(root.path(), "node_modules/pkg/index.js", b"export {};"); write_fixture_file(root.path(), "game/dist/bundle.js", b"bundle"); write_fixture_file(root.path(), "secrets/key.pem", b"private-key"); @@ -111,7 +109,7 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() { assert_eq!( scanned, vec!["assets/manifest.json".to_string()], - ".agent、node_modules、dist 与凭据目录里的文件不能进入候选集合" + "node_modules、dist 与凭据目录里的文件不能进入候选集合" ); let skipped = scan .skipped @@ -125,6 +123,111 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() { ); } +#[test] +fn project_snapshot_scan_uploads_whole_agent_directory() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/index.html", b""); + write_fixture_file(root.path(), ".agent/manifest.json", b"{}"); + write_fixture_file(root.path(), ".agent/agent.db", b"sqlite"); + write_fixture_file(root.path(), ".agent/agent.db-wal", b"wal"); + write_fixture_file(root.path(), ".agent/project.lock", b"{}"); + write_fixture_file(root.path(), ".agent/.manifest.json.lock", b""); + write_fixture_file(root.path(), ".agent/conversations/project.jsonl", b"{}\n"); + write_fixture_file(root.path(), ".agent/runtime/events/art.jsonl", b"{}\n"); + write_fixture_file( + root.path(), + ".agent/runtime/command-env/home/.config.json", + b"{}", + ); + write_fixture_file(root.path(), ".agent/logs/command.log", b"log"); + write_fixture_file( + root.path(), + ".agent/checkpoints/0001/manifest.json", + b"{\"files\":[]}", + ); + write_fixture_file( + root.path(), + ".agent/workbench/resource-layouts/art.json", + b"{}", + ); + + let scan = scan_fixture(root.path()); + let scanned = scan + .files + .iter() + .map(|file| file.relative_path.clone()) + .collect::>(); + assert_eq!( + scanned, + vec![ + ".agent/.manifest.json.lock".to_string(), + ".agent/agent.db".to_string(), + ".agent/agent.db-wal".to_string(), + ".agent/checkpoints/0001/manifest.json".to_string(), + ".agent/conversations/project.jsonl".to_string(), + ".agent/logs/command.log".to_string(), + ".agent/manifest.json".to_string(), + ".agent/project.lock".to_string(), + ".agent/runtime/command-env/home/.config.json".to_string(), + ".agent/runtime/events/art.jsonl".to_string(), + ".agent/workbench/resource-layouts/art.json".to_string(), + "game/index.html".to_string(), + ], + "`.agent` 是项目身份与 Agent 状态的权威位置,必须整目录参与同步" + ); + assert!( + scan.skipped.is_empty(), + "`.agent` 内的普通文件既不跳过也不延后" + ); +} + +#[test] +fn project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials() { + for relative_path in [ + ".agent/manifest.json", + ".agent/agent.db", + ".agent/agent.db-wal", + ".agent/agent.db-shm", + ".agent/project.lock", + ".agent/runtime/events/art.jsonl", + ".agent/runtime/locks/append/01.lock", + ".agent/checkpoints/0001/manifest.json", + ".agent/conversations/project.jsonl", + ".agent/workbench/resource-layouts/art.json", + ".AGENT/manifest.json", + ] { + assert!( + !should_skip_project_snapshot_sync_path(relative_path), + "`.agent` 状态必须参与同步:{relative_path}" + ); + } + + for relative_path in [ + ".agent/credentials/platform.json", + ".agent/.ssh/id_rsa", + ".agent/certs/server.pem", + ".agent/node_modules/pkg/index.js", + ".agent/runtime/command-env/home/.env", + ".agent/runtime/command-env/home/.npmrc", + ".agent/backup/game.sql", + ".git/config", + "node_modules/pkg/index.js", + "game/dist/bundle.js", + "secrets/key.pem", + "", + ] { + assert!( + should_skip_project_snapshot_sync_path(relative_path), + "凭据、版本库与构建产物仍然排除:{relative_path}" + ); + } + + // 项目索引、checkpoint 与 Agent 上下文继续排除整个 `.agent`,本变更只放开快照同步。 + assert!(should_skip_project_snapshot_path(".agent/manifest.json")); + assert!(should_skip_project_index_path(".agent/manifest.json")); + assert!(should_skip_project_index_path(".agent/agent.db")); +} + #[test] fn project_snapshot_diff_reuses_metadata_and_reports_a_single_modification() { let root = fixture_root(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 6e9abb9e8..a67c171f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -3935,6 +3935,54 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() { + let root = unique_project_path(); + init_existing_html_project_at(&root, "project-publish", "在线试玩项目").expect("project init"); + write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) + .expect("write playable html"); + write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme"); + + let exported = export_local_project_package_at(&root).expect("export package"); + let payload = read_local_project_export_package_at(&root, &exported.package_relative_path) + .expect("read publish payload"); + + assert_eq!( + payload.package_relative_path, + exported.package_relative_path + ); + assert_eq!( + payload.package_size_bytes, + payload.package_bytes.len() as u64 + ); + assert_eq!(payload.files.len(), 2); + assert!(payload.files.iter().any(|file| file.path == "index.html")); + assert!(payload + .files + .iter() + .any(|file| file.path == "exports/README.md")); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&payload.package_bytes)) + .expect("read normalized package"); + let names = (0..archive.len()) + .map(|index| { + archive + .by_index(index) + .expect("normalized entry") + .name() + .to_string() + }) + .collect::>(); + assert!(names.iter().any(|name| name == "index.html")); + assert!(!names.iter().any(|name| name.starts_with("game/"))); + assert_eq!(payload.package_sha256.len(), 64); + assert!(payload + .package_sha256 + .chars() + .all(|value| value.is_ascii_hexdigit())); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_project_export_package_list_only_returns_recent_playtest_zips() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 044322d2c..00f0cda8f 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -40,12 +40,14 @@ import type { LocalGameProjectRevisionStatus, LocalPreviewResult, LocalPreviewStatus, + LocalProjectExportPackageResult, LocalProjectFileResult, LocalProjectKind, PendingUiConfirmation, ProjectPermissionPolicyView, TauriInvoke, } from './app/types'; +import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel'; import { agentConversationId, agentRuntimeStateFromResult, @@ -91,6 +93,7 @@ import { type ResourceReferenceInsertEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; +import { readGamePublishAvailability } from './services/gameDistributionPublish'; import { setAgcPluginProjectPath, startAvailableAgcEditorPlugins, @@ -167,7 +170,6 @@ type AppProps = { initialProjectManifest?: GameCreationAppManifest; initialProjectKind?: LocalProjectKind; planningStartMode?: boolean; - activeVersionId?: ProjectChatComponentProps['activeVersionId']; initialPlanningPrompt?: string; initialPlanningPromptClaimScope?: string; initialCreationType?: HomeCreationType | null; @@ -181,6 +183,7 @@ type AppProps = { metadata?: ProjectManifestSnapshotMetadata, ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; + onRunNotice?: ProjectChatComponentProps['onRunNotice']; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; @@ -203,7 +206,6 @@ export function App({ initialProjectManifest, initialProjectKind = 'web', planningStartMode = false, - activeVersionId = null, initialPlanningPrompt = '', initialPlanningPromptClaimScope = '', initialCreationType = null, @@ -213,6 +215,7 @@ export function App({ onPlayRequestHandled, onManifestChange, onPreviewChange, + onRunNotice, onAgentRuntimeSummariesChange, onAgentResultsChange, }: AppProps = {}) { @@ -361,6 +364,12 @@ export function App({ const chatComposerRef = useRef(null); const [chatAgentBusy, setChatAgentBusy] = useState(false); + // 发布到游戏广场:试玩包导出结果与面板开关由工作台壳持有,聊天容器只负责触发。 + const [publishPackageResult, setPublishPackageResult] = + useState(null); + const [publishPanelOpen, setPublishPanelOpen] = useState(false); + // 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。 + const [gamePublishAllowed, setGamePublishAllowed] = useState(false); const [projectChatError, setProjectChatError] = useState(''); const [designAgentTransientReply, setDesignAgentTransientReplyVisible] = useState(''); @@ -513,6 +522,7 @@ export function App({ texts.push(entry.text); reasoningByMessageId.set(entry.messageId, texts); } + // 持久策划消息没有发送时间,不能把读取时刻显示成历史发送时间。 const messages: ChatMessage[] = view.messages .filter((message) => message.text.trim()) .map((message) => ({ @@ -521,7 +531,6 @@ export function App({ runtimeOwned: true, messageId: message.id, reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'), - updatedAt: Date.now(), })); const initialPrompt = initialPlanningPromptLatchRef.current.prompt; if ( @@ -534,7 +543,6 @@ export function App({ role: 'user', text: initialPrompt, runtimeOwned: true, - updatedAt: Date.now(), }); } return messages; @@ -804,9 +812,7 @@ export function App({ const agentRuntimeResumeProjectPathRef = useRef(null); const initialProjectOpenedRef = useRef(false); const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null); - const executeRunLocalRef = useRef<(announceToChat: boolean) => void>( - () => undefined, - ); + const executeRunLocalRef = useRef<() => void>(() => undefined); const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); function requestRuntimeConfigOpen() { @@ -872,7 +878,15 @@ export function App({ if (messageList) { messageList.scrollTop = messageList.scrollHeight; } - }, [messages, projectChatError]); + }, [ + messages, + projectChatError, + designAgentTransientReply, + designAgentReasoning, + designAgentView, + pendingUiConfirmation, + chatFileImportNotice, + ]); useEffect(() => { latestMessagesRef.current = messages; @@ -1172,6 +1186,71 @@ export function App({ } } + useEffect(() => { + let cancelled = false; + void readGamePublishAvailability() + .then((allowed) => { + if (!cancelled) setGamePublishAllowed(allowed); + }) + .catch(() => { + if (!cancelled) setGamePublishAllowed(false); + }); + return () => { + cancelled = true; + }; + }, [localProject?.projectPath]); + + /** + * 导出试玩包并打开发布面板。 + * + * 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出; + * 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。 + */ + async function requestGamePublish() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setWorkspaceStatus('需要在 Tauri App 内发布'); + return; + } + const nextProjectPath = + resolveChatProjectPath(localProject) ?? projectPath.trim(); + if (!nextProjectPath) { + setWorkspaceStatus('先打开一个项目再发布'); + return; + } + const runExport = async () => { + try { + const result = await invoke( + 'export_local_project_package', + { projectPath: nextProjectPath }, + ); + setWorkspaceStatus(`已导出本地试玩包:${result.packageRelativePath}`); + setPublishPackageResult(result); + setPublishPanelOpen(true); + appendLocalPermissionLog( + nextProjectPath, + 'command.auto', + 'project.export_package', + ); + } catch (error) { + setWorkspaceStatus( + error instanceof Error ? error.message : String(error), + ); + } + }; + const queued = await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'project.export_package', + nextProjectPath, + '导出试玩包并打开「发布到游戏广场」面板。', + '导出试玩包需要确认,确认后继续。', + () => void runExport(), + ); + if (!queued) { + await runExport(); + } + } + async function confirmUiCommand() { const pending = pendingUiConfirmation; if (!pending) { @@ -1307,7 +1386,7 @@ export function App({ } handledPlayRequestRef.current = requestKey; onPlayRequestHandled?.(playRequest.requestId); - void executeRunLocalRef.current(true); + void executeRunLocalRef.current(); }, [localProject?.projectPath, onPlayRequestHandled, playRequest]); /** @@ -1639,20 +1718,6 @@ export function App({ } } - /** - * 工作台壳要把一句结果说给用户在项目对话里听。 - * - * DirectProject 的会话由聊天容器持有,壳只把这句话交给聊天的本地消息流; - * 立项策划路径仍写壳自己的 `messages`。 - */ - function announceProjectChatMessage(text: string) { - if (directProjectMode) { - directProjectChatRef.current?.announce(text); - return; - } - setMessages((current) => [...current, { role: 'assistant', text }]); - } - async function executeChatAgentReply({ prompt, clientTurnId: directConversationTurnId, @@ -1758,19 +1823,18 @@ export function App({ } } - async function executeRunLocal(announceToChat: boolean) { + async function executeRunLocal() { const invoke = resolveTauriInvoke(); if (!invoke) { - if (announceToChat) { - announceProjectChatMessage('需要在 Tauri App 内运行。'); - } + onRunNotice?.({ tone: 'error', message: '需要在 Tauri App 内运行。' }); return; } const nextProjectPath = resolveChatProjectPath(localProject); if (!nextProjectPath) { - if (announceToChat) { - announceProjectChatMessage('请先用 /project 设置本地项目。'); - } + onRunNotice?.({ + tone: 'error', + message: '请先用 /project 设置本地项目。', + }); return; } @@ -1781,11 +1845,9 @@ export function App({ ); if (activePreview) { updateClientPreview(activePreview); - if (announceToChat) { - announceProjectChatMessage( - `已切换到客户端运行视图:${activePreview.url}`, - ); - } + // 运行成功的反馈走工作台壳的 toast(对话区只保留对话内容),因此不再往聊天里 + // 写一条「已切换到客户端运行视图:URL」。 + onRunNotice?.({ message: '已载入客户端运行视图' }); return; } const previewResult = await invoke( @@ -1797,16 +1859,19 @@ export function App({ if (!directProjectMode) { void refreshAgentRunTrace(nextProjectPath); } - if (announceToChat) { - announceProjectChatMessage( - `运行通过,已载入客户端运行视图:${previewResult.url}`, - ); - } + /* + * 成功与失败都走工作台壳的 toast,对话区不再承载这条过程反馈,所以这两处不受 + * `announceToChat` 约束(它是旧的「聊天播报」开关)。当前唯一调用点由播放请求驱动、 + * 恒为 true(见 `executeRunLocalRef.current(true)`)。 + */ + onRunNotice?.({ message: '运行通过,已载入客户端运行视图' }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (announceToChat) { - announceProjectChatMessage(message); - } + onRunNotice?.({ + tone: 'error', + message: `运行游戏失败:${ + error instanceof Error ? error.message : String(error) + }`, + }); } } @@ -2132,11 +2197,6 @@ export function App({ const chatProjectAssets = manifest.assets.filter( (asset) => asset.localPath && !asset.localPath.startsWith('.agent/'), ); - // `@` 面板「当前版本素材」的版本来源:版本列表来自 manifest, - // 当前版本由工作台壳(`WorkspaceLauncherShell`)持有的那一份状态给出, - // 传 `null` 表示回退到 manifest 中最新的版本。 - const chatProjectVersions = manifest.versions ?? []; - const chatActiveVersionId = activeVersionId; const visibleMessages = latestVisibleItems( messages, @@ -2239,13 +2299,25 @@ export function App({ // 普通项目固定走 DirectProject 自己的聊天容器:订阅、历史、发送、队列和附件都由 // 容器持有,工作台壳只提供项目身份、入口首轮需求和两条权限门。 return ( - + <> + + setPublishPanelOpen(false)} + /> + ); } @@ -2253,7 +2325,6 @@ export function App({ <> void handleDesignComposerUploadFiles(files)} - versions={chatProjectVersions} /> {runtimeConfigOpen ? ( setRuntimeConfigOpen(false)} /> ) : null} + setPublishPanelOpen(false)} + /> ); } diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 210cbd489..227966041 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -801,6 +801,20 @@ export interface LocalProjectExportPackageResult { totalBytes: number; } +export interface LocalProjectExportPackageFileDigest { + path: string; + sizeBytes: number; + sha256: string; +} + +export interface LocalProjectExportPackagePayload { + packageRelativePath: string; + packageBytes: number[]; + packageSha256: string; + packageSizeBytes: number; + files: LocalProjectExportPackageFileDigest[]; +} + export interface LocalProjectExportPackageSummary { packagePath: string; packageRelativePath: string; diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx new file mode 100644 index 000000000..5f11e3f37 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -0,0 +1,511 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { resolveTauriInvoke } from '../../app/tauri'; +import type { LocalProjectExportPackageResult } from '../../app/types'; +import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload'; +import { + createGameDistributionPublishKey, + type GameDistributionPublishMetadata, + type GameDistributionPublishResult, + MAX_AGC_GAME_SCREENSHOTS, + publishLocalProjectGame, +} from '../../services/gameDistributionPublish'; +import { ThemedModal } from '../modal/ThemedModal'; + +/** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */ +const PANEL_IMAGE_MAX_BYTES = 6 * 1024 * 1024; +const PANEL_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif'; + +type PanelImageKind = 'cover' | 'screenshot'; + +type PanelImageAsset = { + /** 文件签名;同一文件重复选择时复用已上传素材,不重复直传。 */ + signature: string; + name: string; + assetObjectId: string; + previewUrl: string; +}; + +function resolvePanelImageLabel(kind: PanelImageKind) { + return kind === 'cover' ? '游戏封面' : '游戏截图'; +} + +/** 本地预检;服务端仍会独立校验素材归属与图片类型。 */ +function resolvePanelImageFileError(file: File, kind: PanelImageKind) { + const label = resolvePanelImageLabel(kind); + if (file.size <= 0) return `${label}文件为空,请重新选择`; + if (file.size > PANEL_IMAGE_MAX_BYTES) { + return `${label}过大,请压缩后再上传(最多 6MB)`; + } + const contentType = file.type.trim(); + if (contentType && !contentType.startsWith('image/')) { + return `${label}必须是图片文件`; + } + return ''; +} + +function buildPanelImageSignature(file: File, kind: PanelImageKind) { + return `${kind}:${file.name}:${file.size}:${file.lastModified}`; +} + +/** 预览只在 WebView 支持 object URL 时生成;否则退回文字占位,不影响上传。 */ +function buildPanelImagePreviewUrl(file: File) { + if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') { + return ''; + } + try { + return URL.createObjectURL(file); + } catch { + return ''; + } +} + +const CATEGORIES = [ + '休闲', + '益智', + '动作', + '冒险', + '模拟', + '策略', + '其他', +] as const; + +export function GameDistributionPublishPanel({ + open, + projectPath, + manifest, + packageResult, + onClose, + onPublished, +}: { + open: boolean; + projectPath: string; + manifest: GameCreationAppManifest; + packageResult: LocalProjectExportPackageResult | null; + onClose: () => void; + onPublished?: (result: GameDistributionPublishResult) => void; +}) { + const [title, setTitle] = useState(''); + const [summary, setSummary] = useState(''); + const [category, setCategory] = + useState('其他'); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [result, setResult] = useState( + null, + ); + const [cover, setCover] = useState(null); + const [screenshots, setScreenshots] = useState([]); + const [uploadingLabel, setUploadingLabel] = useState(''); + const publishIdempotencyKeyRef = useRef(''); + const coverInputRef = useRef(null); + const screenshotInputRef = useRef(null); + // 文件签名 → 素材 ID:同一次打开面板重复提交不会重复上传同一张图。 + const uploadedAssetCacheRef = useRef(new Map()); + const objectUrlsRef = useRef(new Set()); + + useEffect(() => { + if (!open) return; + setTitle(manifest.name.trim()); + setSummary( + (manifest.goal ?? '由陶泥儿创作的可在线游玩游戏').trim().slice(0, 120), + ); + setCategory('其他'); + setBusy(false); + setError(''); + setResult(null); + }, [manifest.goal, manifest.name, open, packageResult?.packageRelativePath]); + + useEffect(() => { + if (open) { + publishIdempotencyKeyRef.current = createGameDistributionPublishKey(); + } + }, [open, packageResult?.packageRelativePath]); + + useEffect(() => { + const objectUrls = objectUrlsRef.current; + return () => { + objectUrls.forEach((url) => { + try { + URL.revokeObjectURL(url); + } catch { + // 预览地址释放失败不影响关闭面板。 + } + }); + objectUrls.clear(); + }; + }, []); + + async function resolveAssetObjectId(file: File, kind: PanelImageKind) { + const signature = buildPanelImageSignature(file, kind); + const cached = uploadedAssetCacheRef.current.get(signature); + if (cached) return cached; + const uploaded = await uploadPlatformMediaAsset({ + file, + assetKind: + kind === 'cover' + ? 'game_distribution_cover' + : 'game_distribution_screenshot', + pathSegments: ['game-distribution', kind, `${Date.now()}`], + entityId: `game-distribution-${kind}`, + metadata: { game_distribution_media: kind }, + }); + uploadedAssetCacheRef.current.set(signature, uploaded.assetObjectId); + return uploaded.assetObjectId; + } + + function buildPanelImageAsset( + file: File, + kind: PanelImageKind, + assetObjectId: string, + ) { + const previewUrl = buildPanelImagePreviewUrl(file); + if (previewUrl) objectUrlsRef.current.add(previewUrl); + return { + signature: buildPanelImageSignature(file, kind), + name: file.name.trim() || resolvePanelImageLabel(kind), + assetObjectId, + previewUrl, + }; + } + + async function handleCoverSelected(file: File | null) { + if (!file) return; + setError(''); + const fileError = resolvePanelImageFileError(file, 'cover'); + if (fileError) { + setError(fileError); + return; + } + setUploadingLabel('正在上传封面…'); + try { + const assetObjectId = await resolveAssetObjectId(file, 'cover'); + setCover(buildPanelImageAsset(file, 'cover', assetObjectId)); + } catch (uploadError) { + setError( + uploadError instanceof Error + ? uploadError.message + : '封面上传失败,请重试', + ); + } finally { + setUploadingLabel(''); + if (coverInputRef.current) coverInputRef.current.value = ''; + } + } + + async function handleScreenshotsSelected(files: File[]) { + if (files.length === 0) return; + const remaining = MAX_AGC_GAME_SCREENSHOTS - screenshots.length; + if (files.length > remaining) { + setError( + remaining > 0 + ? `游戏截图最多 6 张,还可以再选 ${remaining} 张` + : '游戏截图最多 6 张', + ); + if (screenshotInputRef.current) screenshotInputRef.current.value = ''; + return; + } + setError(''); + setUploadingLabel('正在上传截图…'); + try { + for (const file of files) { + const fileError = resolvePanelImageFileError(file, 'screenshot'); + if (fileError) { + setError(fileError); + return; + } + const assetObjectId = await resolveAssetObjectId(file, 'screenshot'); + // 逐张入库:中途失败时已传好的截图保留,作者不用重新选择。 + setScreenshots((current) => + current.length >= MAX_AGC_GAME_SCREENSHOTS + ? current + : [ + ...current, + buildPanelImageAsset(file, 'screenshot', assetObjectId), + ], + ); + } + } catch (uploadError) { + setError( + uploadError instanceof Error + ? uploadError.message + : '截图上传失败,请重试', + ); + } finally { + setUploadingLabel(''); + if (screenshotInputRef.current) screenshotInputRef.current.value = ''; + } + } + + async function handleSubmit() { + if (!packageResult || !projectPath.trim()) { + setError('请先导出有效的试玩包'); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setError('需要在 Tauri App 内发布'); + return; + } + if (!cover) { + setError('请先选择游戏封面(JPG/PNG/WebP)'); + return; + } + if (uploadingLabel) { + setError('素材还在上传中,请稍候再发布'); + return; + } + setBusy(true); + setError(''); + try { + const next = await publishLocalProjectGame({ + invoke, + projectPath, + packageRelativePath: packageResult.packageRelativePath, + manifest, + metadata: { + title, + summary, + category, + coverAssetId: cover.assetObjectId, + screenshots: screenshots.map((item) => item.assetObjectId), + }, + idempotencyKey: publishIdempotencyKeyRef.current, + }); + setResult(next); + onPublished?.(next); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : String(nextError), + ); + } finally { + setBusy(false); + } + } + + return ( + undefined : onClose} + panelClassName="game-distribution-publish-panel" + closeOnBackdrop={!busy} + closeOnEscape={!busy} + > +
+
+ + 发布到游戏广场 + +

让玩家现在就能试玩

+
+ +
+ {result ? ( +
+ 已提交审核 +

版本已进入审核队列,审核通过后才会在游戏广场公开展示。

+

+ 版本 {result.versionNumber} ·{' '} + {result.packageSizeBytes.toLocaleString()} B ·{' '} + {result.packageSha256.slice(0, 16)}… +

+
+ +
+
+ ) : ( + <> +

+ 仅上传已导出的 ZIP 字节和摘要信息;不会上传本地路径或项目源码快照。 +

+
+ {packageResult?.packageRelativePath ?? '未找到试玩包'} + + {packageResult + ? `${packageResult.fileCount} 个文件 · ${packageResult.totalBytes.toLocaleString()} B` + : '请先导出'} + +
+
+ +