diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
index b5403ff48..3f1bcd3dc 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
@@ -182,6 +182,26 @@ export function registerClientHomeTests() {
);
});
+ it('hides template navigation and recommendations when the account is outside gray release', async () => {
+ const invoke = vi.fn(async (command: string) => {
+ if (command === 'get_game_template_library_access') return false;
+ if (command === 'read_game_creator_app_config') return { config: {} };
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ window.__TAURI__ = { core: { invoke } };
+ renderLauncherAt('/?launcher');
+ await waitFor(() =>
+ expect(invoke).toHaveBeenCalledWith('get_game_template_library_access'),
+ );
+ expect(screen.queryByRole('button', { name: '模板库' })).toBeNull();
+ expect(screen.queryByLabelText('模板库推荐')).toBeNull();
+ expect(
+ invoke.mock.calls.some(
+ ([command]) => command === 'fetch_game_template_library',
+ ),
+ ).toBe(false);
+ });
+
it('shows the home template recommendations and opens the library without creating a project', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const templateLibrarySnapshot = {
@@ -244,6 +264,7 @@ export function registerClientHomeTests() {
if (command === 'read_game_creator_app_config') {
return { config: { selectedModelId: 'quality' } };
}
+ if (command === 'get_game_template_library_access') return true;
if (command === 'fetch_game_template_library') {
return templateLibrarySnapshot;
}
diff --git a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
index 4182d8c1e..7a27cdbd9 100644
--- a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
+++ b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
@@ -74,6 +74,7 @@ function controller(
installedOnly: false,
};
return {
+ enabled: true,
snapshot: null,
status: 'ready',
error: '',
diff --git a/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx b/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx
new file mode 100644
index 000000000..0d11196e2
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx
@@ -0,0 +1,306 @@
+// @vitest-environment jsdom
+import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
+import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation';
+import type {
+ GameTemplateEntry,
+ GameTemplateLibrarySnapshot,
+} from '../src/features/template-library/templateLibraryModel';
+import { useTemplateLibrary } from '../src/features/template-library/useTemplateLibrary';
+import {
+ beginPlatformSessionClearTransition,
+ resetPlatformSessionStateForTests,
+} from '../src/services/platformSession';
+
+const invoke = vi.hoisted(() => vi.fn());
+vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: () => invoke }));
+
+const template: GameTemplateEntry = {
+ id: 'demo',
+ title: '演示',
+ summary: '',
+ tags: [],
+ runtime: 'html',
+ engine: 'phaser',
+ engineVersion: '4',
+ templateVersion: '1',
+ updatedAt: '',
+ entry: 'index.html',
+ zipUrl: 'https://example.invalid/demo.zip',
+ zipSizeBytes: 1,
+ zipSha256: 'a'.repeat(64),
+ coverUrl: '',
+ coverWidth: 100,
+ coverHeight: 100,
+ installed: true,
+ installedVersion: '1',
+ installedAtMillis: 1,
+};
+const snapshot = (id = 'demo'): GameTemplateLibrarySnapshot => ({
+ schemaVersion: 'agc-template-library.v1',
+ library: 'official',
+ libraryVersion: 1,
+ updatedAt: '',
+ fetchedAtMillis: 1,
+ source: 'cache',
+ templates: [{ ...template, id }],
+});
+function deferred
() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ return { promise, resolve };
+}
+const onProjectCreated = vi.fn();
+const mount = () =>
+ renderHook(({ userId }) => useTemplateLibrary({ userId, onProjectCreated }), {
+ initialProps: { userId: 'user-a' },
+ });
+
+beforeEach(() => {
+ resetPlatformSessionStateForTests();
+ invoke.mockReset();
+ onProjectCreated.mockReset();
+});
+afterEach(cleanup);
+
+describe('模板库灰度会话', () => {
+ it('等待权威权限时不读取清单,拒绝后也不能调用下载', async () => {
+ const access = deferred();
+ invoke.mockImplementation(() => access.promise);
+ const { result } = mount();
+ expect(result.current.enabled).toBe(false);
+ expect(invoke.mock.calls.map(([command]) => command)).toEqual([
+ 'get_game_template_library_access',
+ ]);
+ await act(async () => {
+ access.resolve(false);
+ });
+ await expect(result.current.downloadTemplate(template)).rejects.toThrow(
+ '暂未',
+ );
+ expect(result.current.snapshot).toBeNull();
+ expect(invoke).toHaveBeenCalledTimes(1);
+ });
+
+ it('命中后读取清单,窗口重新聚焦失去权限就清空缓存投影', async () => {
+ let allowed = true;
+ invoke.mockImplementation(async (command) =>
+ command === 'get_game_template_library_access' ? allowed : snapshot(),
+ );
+ const { result } = mount();
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ expect(result.current.enabled).toBe(true);
+ expect(result.current.templates).toHaveLength(1);
+ allowed = false;
+ await act(async () => {
+ window.dispatchEvent(new Event('focus'));
+ });
+ expect(result.current.enabled).toBe(false);
+ expect(result.current.templates).toEqual([]);
+ expect(result.current.snapshot).toBeNull();
+ expect(
+ invoke.mock.calls.filter(
+ ([command]) => command === 'fetch_game_template_library',
+ ),
+ ).toHaveLength(1);
+ });
+
+ it('账号切换后丢弃前一账号迟到的允许结果', async () => {
+ const access = deferred();
+ invoke
+ .mockImplementationOnce(() => access.promise)
+ .mockResolvedValue(false);
+ const { result, rerender } = mount();
+ rerender({ userId: 'user-b' });
+ await act(async () => {
+ access.resolve(true);
+ });
+ expect(result.current.enabled).toBe(false);
+ expect(
+ invoke.mock.calls.filter(
+ ([command]) => command === 'fetch_game_template_library',
+ ),
+ ).toHaveLength(0);
+ });
+
+ it('账号切换后旧清单不能覆盖当前账号的清单', async () => {
+ const oldManifest = deferred();
+ let fetches = 0;
+ invoke.mockImplementation(async (command) => {
+ if (command === 'get_game_template_library_access') return true;
+ return ++fetches === 1
+ ? oldManifest.promise
+ : snapshot('user-b-template');
+ });
+ const { result, rerender } = mount();
+ await waitFor(() => expect(fetches).toBe(1));
+ rerender({ userId: 'user-b' });
+ await waitFor(() =>
+ expect(result.current.templates[0]?.id).toBe('user-b-template'),
+ );
+ await act(async () => {
+ oldManifest.resolve(snapshot('user-a-template'));
+ });
+ expect(result.current.templates[0]?.id).toBe('user-b-template');
+ });
+
+ it.each(['download', 'create'] as const)(
+ '账号切换后迟到的%s结果不能写状态或跳转',
+ async (operation) => {
+ const completion = deferred();
+ let allowed = true;
+ invoke.mockImplementation(async (command) => {
+ if (command === 'get_game_template_library_access') return allowed;
+ if (command === 'fetch_game_template_library') return snapshot();
+ return completion.promise;
+ });
+ const { result, rerender } = mount();
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ let pending!: Promise;
+ act(() => {
+ pending = (
+ operation === 'create'
+ ? result.current.createProjectFromTemplate(template)
+ : result.current.downloadTemplate(template)
+ ).catch((error) => error);
+ });
+ allowed = false;
+ rerender({ userId: 'user-b' });
+ await act(async () => {
+ completion.resolve({ projectPath: '/old', templateVersion: '1' });
+ await pending;
+ });
+ expect(await pending).toBeInstanceOf(Error);
+ expect(onProjectCreated).not.toHaveBeenCalled();
+ expect(result.current.enabled).toBe(false);
+ expect(result.current.notice).toBe('');
+ expect(result.current.busyTemplateId).toBeNull();
+ },
+ );
+
+ it('原生命令拒绝权限后立即关闭入口,已安装模板不能绕过', async () => {
+ invoke.mockImplementation(async (command) => {
+ if (command === 'get_game_template_library_access') return true;
+ if (command === 'fetch_game_template_library') return snapshot();
+ throw new Error('template-library-unavailable: 模板库暂未开放');
+ });
+ const { result } = mount();
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ await act(async () => {
+ await expect(
+ result.current.createProjectFromTemplate(template),
+ ).rejects.toThrow('暂未开放');
+ });
+ expect(result.current.enabled).toBe(false);
+ expect(result.current.snapshot).toBeNull();
+ expect(onProjectCreated).not.toHaveBeenCalled();
+ });
+
+ it('检查权限失败时关闭入口而不使用先前允许结果', async () => {
+ invoke.mockImplementation(async (command) =>
+ command === 'get_game_template_library_access' ? true : snapshot(),
+ );
+ const { result } = mount();
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ invoke.mockRejectedValueOnce(new Error('offline'));
+ await act(async () => {
+ await result.current.refresh();
+ });
+ expect(result.current.enabled).toBe(false);
+ expect(result.current.templates).toEqual([]);
+ });
+
+ it('退出登录开始时即使 userId 未变也清空权限并停止旧建项回调', async () => {
+ const completion = deferred();
+ invoke.mockImplementation(async (command) => {
+ if (command === 'get_game_template_library_access') return true;
+ if (command === 'fetch_game_template_library') return snapshot();
+ return completion.promise;
+ });
+ const { result } = mount();
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ let pending!: Promise;
+ act(() => {
+ pending = result.current
+ .createProjectFromTemplate(template)
+ .catch((error) => error);
+ });
+ act(() => {
+ beginPlatformSessionClearTransition();
+ });
+ expect(result.current.enabled).toBe(false);
+ const count = invoke.mock.calls.length;
+ await act(async () => {
+ await result.current.refresh();
+ });
+ expect(invoke).toHaveBeenCalledTimes(count);
+ await act(async () => {
+ completion.resolve({ projectPath: '/old' });
+ await pending;
+ });
+ expect(onProjectCreated).not.toHaveBeenCalled();
+ });
+
+ it.each(['get_local_game_project_revision', 'get_local_game_preview_status'])(
+ '退出登录时 %s 的旧结果不能进入项目或登记最近项目',
+ async (delayedCommand) => {
+ const delayed = deferred();
+ const manifest = createGameCreationAppManifest(
+ 'template-project',
+ '模板项目',
+ );
+ invoke.mockImplementation(async (command) => {
+ if (command === 'get_game_template_library_access') return true;
+ if (command === 'fetch_game_template_library') return snapshot();
+ if (command === 'create_automatic_local_game_project_from_template')
+ return { projectPath: 'C:/test/template-project', manifest };
+ if (command === delayedCommand) return delayed.promise;
+ if (command === 'get_local_game_project_revision')
+ return { revision: 1 };
+ if (command === 'get_local_game_preview_status')
+ return { status: 'stopped' };
+ throw new Error(command);
+ });
+ const setLauncherView = vi.fn();
+ const rememberRecentWorkspace = vi.fn();
+ const { result } = renderHook(() => {
+ const home = useHomeProjectCreation({
+ setLauncherView,
+ rememberRecentWorkspace,
+ setStatus: vi.fn(),
+ setAgentChatProjectPath: vi.fn(),
+ });
+ const library = useTemplateLibrary({
+ userId: 'user-a',
+ onProjectCreated: (created, isCurrent) =>
+ home.enterCreatedTemplateProject(created, isCurrent),
+ });
+ return { home, library };
+ });
+ await waitFor(() => expect(result.current.library.status).toBe('ready'));
+ let pending!: Promise;
+ act(() => {
+ pending = result.current.library.createProjectFromTemplate(template);
+ });
+ await waitFor(() =>
+ expect(
+ invoke.mock.calls.some(([command]) => command === delayedCommand),
+ ).toBe(true),
+ );
+ act(() => {
+ beginPlatformSessionClearTransition();
+ });
+ await act(async () => {
+ delayed.resolve({ revision: 1, status: 'stopped' });
+ await pending;
+ });
+ expect(result.current.home.currentProjectContext).toBeNull();
+ expect(setLauncherView).not.toHaveBeenCalled();
+ expect(rememberRecentWorkspace).not.toHaveBeenCalled();
+ },
+ );
+});
diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md
index 7a95d0f41..9235ebded 100644
--- a/docs/project-memory/shared-memory/team-conventions.md
+++ b/docs/project-memory/shared-memory/team-conventions.md
@@ -22,7 +22,8 @@
- AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。
-- AGC 平台服务固定为 `https://dev.genarrative.world`,会话凭据按 origin 隔离。官网通过同源公开 `/api/client-downloads` 汇总 Windows/Mac 渠道的首装 `downloads`,按真实平台/架构显示;未发布隐藏,单渠道失败不影响其它下载。发布先上传 EXE/DMG 再写本渠道清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`,浏览器不直接跨域读取 OSS 清单。完整约定见 AGC 客户端更新检查与下载专题。
+- AGC 平台服务固定为 `https://dev.genarrative.world`,会话凭据按 origin 隔离。发布渠道为 `dev/release/自定义名称`,Windows/Mac 是系统,OSS 的 `-win/mac` 仅是延续既有地址的分区。官网通过服务端 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(默认 dev)选择渠道,公开同源 `/api/client-downloads` 汇总其各系统首装包与真实版本;未发布隐藏,单系统失败不影响其它下载,不跨渠道补齐。发布先上传 EXE/DMG 再写对应分区清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`。完整约定见 AGC 客户端更新检查与下载专题。
+- AGC 模板库灰度复用 `agc:template-library`:未配置关闭,已配置时遵循现有灰度启停、用户 ID/标签和比例规则;服务端返回权威结论,客户端入口和原生清单/下载/建项均执行门禁,主体切换丢弃旧异步结果。公开 OSS 不是保密边界,已创建项目不受影响。
- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。
- 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本,不新建平行入口或业务真相。
diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md
index 757e35157..14f2ab8dc 100644
--- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md
+++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md
@@ -1,13 +1,21 @@
# AGC 客户端更新检查与下载
-更新时间:`2026-09-19`
+更新时间:`2026-09-20`
本文件是 AGC 客户端自动更新的主规范:更新能力由 Tauri 官方插件 `tauri-plugin-updater` 承担,并按下文渠道分发。
## 目标
- 客户端自动更新改用 Tauri 官方 `tauri-plugin-updater`:清单请求、版本比较、更新包下载、签名校验、安装与退出全部在原生侧完成;前端只负责触发、展示和渠道选择。
-- 更新按渠道分发。当前渠道集合为 `dev-win`(Windows x64)与 `dev-mac`(macOS);构建管线按渠道产出并上传清单,客户端只读取自己渠道的清单。
+- 更新按渠道分发。渠道为 `dev`、`release` 或自定义名称;Windows/macOS 是独立的系统维度,每个渠道分别维护各系统已发布的版本、清单和安装包。客户端只读取构建时确定的渠道及系统对应的清单。
+
+## 渠道与网站配置合同
+
+- 构建参数 `AGC_UPDATE_CHANNEL` 默认 `dev`,支持 `release` 和自定义小写名称;名称符合 `[a-z][a-z0-9-]{0,31}`,不能以连字符结尾,不能为 `win/mac/windows/macos/darwin/linux` 或以 `-win/-mac` 结尾。构建目标独立决定系统和架构。
+- 为延续已发布客户端地址,OSS 继续使用 `agc/-win/` 和 `agc/-mac/` 作为物理分区;`dev-win/dev-mac` 是分区键,不是可填写的渠道。每个分区独立维护 `latest.json` 与版本目录,发布 release 不覆盖 dev。旧 `agc/latest.json` 迁移桥与其版本高水位仅属于 dev 的 Windows 分区。
+- 网站由服务端配置 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL` 选择检测渠道,默认 `dev`;更改后重启 API 服务生效。`GET /api/client-downloads` 返回该渠道 Windows/macOS 的真实已发布版本。请求参数不能覆盖配置或指定 URL;配置非法时失败关闭,不悄悄改读 dev。
+- 同一渠道中不同系统仍可具有不同 release 版本;未发布的系统隐藏,单系统失败不影响另一系统。不会从其他渠道补齐缺失版本。
+- 验收必须覆盖 dev/release/自定义渠道各自端点和对象地址、独立版本、非法名称、旧 dev 地址延续、release 不写旧迁移桥、网站配置贯通、跨渠道链接拒绝和部分失败。
- 更新链路的信任来源从「清单里的 sha256 + 受信域名」升级为「发布签名 + 受信域名」:清单里的 `signature` 由构建期私钥生成,客户端用内置公钥校验,校验不过就拒绝安装。
## 非目标
@@ -33,11 +41,11 @@
### 官网下载与客户端服务地址
- 官网首页提供无需登录的「下载客户端」入口,桌面和移动视口均可访问;入口打开独立下载面板,复用平台按钮、弹窗和状态组件。
-- 每次打开下载面板请求同源公开 `GET /api/client-downloads`,后端并行读取固定 `dev-win/latest.json` 与 `dev-mac/latest.json` 并汇总已发布平台,网页和接口均禁用缓存。平台列表与每项版本完全来自清单;不在网页写死版本或猜测文件名。
+- 每次打开下载面板请求同源公开 `GET /api/client-downloads`,后端并行读取配置渠道的 `-win/latest.json` 与 `-mac/latest.json` 并汇总已发布平台,网页和接口均禁用缓存。平台列表与每项版本完全来自清单;不在网页写死版本或猜测文件名。
- 接口返回 `{ downloads: [{ platform, architecture, version, downloadUrl }], unavailablePlatforms: [] }`,平台为 `windows` / `macos`,架构为 `x86_64` / `aarch64`;Windows 仅支持 x86_64,Mac 按清单实际提供的架构展示 Apple Silicon / Intel 下载项。DTO 由 `shared-contracts` 与 `packages/shared` 对齐,不接受请求参数指定上游 URL,不触及 SpacetimeDB。
- 渠道清单新增可选 `downloads` 字典,键与 updater 平台键一致,值为 `{ url }`,只登记首装包。Windows `.exe` 可同时用于首装和更新;macOS 首装必须为 `.dmg`,不得将 `.app.tar.gz` 当首装包。已发布且没有 `downloads` 字段的 Windows 清单可读取既有 `platforms.windows-x86_64.url`;Mac 没有首装元数据时隐藏,不推导 DMG 地址。
- 渠道 404 视为尚未发布并隐藏该平台;两端均未发布时显示空状态。单渠道请求失败、超时或格式非法时保留另一端有效下载项,同时提示部分平台暂不可用并允许重试;没有任何有效项且存在失败时返回可读 502。关闭面板取消请求,迟到响应不能覆盖下一次打开的状态。
-- 每个上游请求总超时 10 秒、响应体上限 128 KiB、不跟随重定向、不附加用户凭据;返回的链接仅接受固定 OSS 来源、对应 `/agc///` 下的 HTTPS `.exe` / `.dmg` 对象,架构键必须属于该渠道。不提供陈旧、未知来源或版本不匹配的下载地址,不泄露上游正文。
+- 每个上游请求总超时 10 秒、响应体上限 128 KiB、不跟随重定向、不附加用户凭据;返回的链接仅接受固定 OSS 来源、对应 `/agc/-win|mac//` 分区下的 HTTPS `.exe` / `.dmg` 对象,架构键必须属于对应系统。不提供陈旧、未知来源或版本不匹配的下载地址,不泄露上游正文。
- AGC 开发态和正式包的平台服务地址统一固定为 `https://dev.genarrative.world`;登录页不提供服务器选择或自定义地址。旧的服务器偏好不能覆盖固定地址;已有会话仍按 origin 隔离,不能将其他服务的凭据迁往 dev。自定义 LLM 配置不属于平台服务器选择。
- access token 与 origin 一起保存;已有 dev origin 的 token 保留。没有 origin 的旧 token 一律清除,因为旧版可以单独修改服务器偏好,偏好不能证明 token 来源。随后仅使用 dev 自己的 refresh cookie 恢复或重新登录;原生会话回写同样绑定 dev origin。
- 验收覆盖固定 dev 的登录/会话与请求行为、旧服务器偏好、首页入口挂载、动态最新版本链接、清单失败与重试、关闭取消、桌面和移动布局,以及公开清单和安装包的真实可读性。
@@ -47,7 +55,7 @@
- 正式包启动时检查一次渠道清单;仅当清单版本高于当前版本时显示更新提示,提示包含目标版本与发布说明。
- 用户确认后下载更新包:下载期间显示进度与已下载字节数;下载完成后按平台安装。
- Windows 使用静默安装模式(NSIS `quiet`),安装启动成功后客户端退出并由安装程序重启新版本;macOS 由客户端在安装完成后重启进程接管新版本。
-- 渠道在构建期确定并烘焙进产物:`dev-win` 产物只读 `dev-win` 清单,`dev-mac` 产物只读 `dev-mac` 清单,同一份二进制不会在运行期跨渠道切换。
+- 渠道与系统在构建期确定并烘焙进产物:如 dev 的 Windows 产物只读 `dev-win` 分区,release 的 Mac 产物只读 `release-mac` 分区,同一份二进制不会在运行期跨渠道切换。
- 开发态(`npm run agc` / `agc:serve` 由 Vite dev server 提供前端)不检查更新、不显示更新入口,也不下载任何更新包。
### 失败、重试与幂等
@@ -85,19 +93,19 @@
}
```
-- 渠道与平台映射:
+- 渠道与平台分区映射(`` 为 dev、release 或自定义名称):
-| 渠道 | 构建目标 | 清单平台键 | 更新包 | 清单地址 |
+| 系统 | 构建目标 | 清单平台键 | 更新包 | 清单地址 |
| --------- | ------------------------ | ---------------------------------------------- | ------------------------ | ------------------------------------ |
-| `dev-win` | `x86_64-pc-windows-msvc` | `windows-x86_64` | NSIS `.exe` + `.exe.sig` | `/agc/dev-win/latest.json` |
-| `dev-mac` | `aarch64-apple-darwin` 或 `x86_64-apple-darwin` | 对应 `darwin-aarch64` 或 `darwin-x86_64` | `*.app.tar.gz` + `.sig` | `/agc/dev-mac/latest.json` |
+| Windows | `x86_64-pc-windows-msvc` | `windows-x86_64` | NSIS `.exe` + `.exe.sig` | `/agc/-win/latest.json` |
+| macOS | `aarch64-apple-darwin` 或 `x86_64-apple-darwin` | 对应 `darwin-aarch64` 或 `darwin-x86_64` | `*.app.tar.gz` + `.sig` | `/agc/-mac/latest.json` |
-- 对象布局:清单固定写成 `agc//latest.json`;安装包与签名写成 `agc///` 与 `.sig`。
+- 对象布局:清单固定写成 `agc/-win|mac/latest.json`;安装包与签名写成同一分区的 `/` 与 `.sig`。
- macOS 当前采用单架构包:Apple Silicon 使用 `aarch64-apple-darwin`,Intel 使用 `x86_64-apple-darwin`;每次生成的清单只登记本次实际构建的架构,不把单架构原生 Codex 资源挂到另一架构。`universal-apple-darwin` 在版本读取/写入、构建和清单生成之前拒绝。
- 渠道清单以实际运行架构为键。两种单架构构建不可轮流覆盖同一个 `latest.json` 并宣称双架构均可更新;当前不实现跨构建合并,Intel 发布需先完成其构建验证与多架构清单发布方案。
- 构建期要求:打开 `bundle.createUpdaterArtifacts` 以生成 `.sig`;构建环境提供签名私钥与密码(私钥内容不得入库);公钥写入客户端配置。公钥在首个带更新能力的版本发布后不可更换,更换等于放弃自动更新(只能手动重装)。
-- 版本递增按渠道独立进行:发布脚本读取该渠道远端 `latest.json` 的 `version`,与本地版本取较高者递增 patch;两个渠道的版本号互不影响。
-- 版本高水位:发布脚本取「渠道清单版本」与「旧协议迁移指针版本」(迁移窗口内)中的较大值再递增。只看渠道清单会在渠道启用初期把版本链改小 —— 2026-09-17 首次渠道发布即把旧指针的 0.1.57 退回 0.1.48,随后以显式 0.1.60 纠偏;迁移窗口结束(旧指针 404)后自动只剩渠道清单,`dev-mac` 不参与旧指针比较。
+- 版本递增按渠道及系统分区独立进行:发布脚本读取该分区远端 `latest.json` 的 `version`,与本地版本取较高者递增 patch;不同分区的远端版本互不影响。
+- 版本高水位:仅 dev 的 Windows 分区在迁移窗口内取「分区清单版本」与「旧协议迁移指针版本」较大值再递增,避免已发布旧客户端版本倒退。迁移窗口结束(旧指针 404)后只读分区清单;release、自定义渠道与所有 Mac 分区均不参与旧指针比较。
- 迁移(旧协议 → 渠道清单):
- 迁移起点:已发布客户端(含当前线上版本)内置自研清单地址 `agc/latest.json`(sha256 格式),下载与安装由自研 Rust 命令完成。
- 迁移策略见「未决问题与决策」。迁移完成后,自研清单解析、下载命令、下载进度事件以及为此放行的 CSP / HTTP 白名单条目按「四不写」整条删除,不留兼容分支与墓碑说明。
@@ -106,17 +114,19 @@
- 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。
- 发布入口只解析一次目标,优先级为 CLI `--target value` / `--target=value` / `-t value`、`AGC_BUILD_TARGET`、Windows 默认值;重复/空目标与不支持目标失败关闭。版本高水位、构建 feature/渠道端点、bundle 路径、产物后缀、清单平台键及摘要必须消费同一个发布上下文,不能分别回读默认目标。
-- 渠道由构建参数显式指定,并按目标平台校验:Windows 目标只允许 `dev-win`,macOS 目标只允许 `dev-mac`;未显式指定时按目标平台取默认渠道。
+- 渠道由 `AGC_UPDATE_CHANNEL` 显式指定,默认 dev;Windows 与 macOS 目标均支持 dev、release 和自定义渠道,目标校验独立进行。
- 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。
- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。
- 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。
-- 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。
+- 上传:安装包与 `.sig` 上传到 `agc/-win|mac//`,清单以 `--force` 覆盖上传到对应分区的 `latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。
- 首装发布:发布脚本生成 `downloads`,Windows 复用已选 NSIS `.exe`,Mac 选择本次版本和目标架构匹配的非空 `.dmg`;缺失、歧义或版本/架构不匹配时失败,不发布带悬空地址的清单。上传顺序为更新包、签名及首装包全部成功后再更新渠道清单,Windows 相同对象只上传一次。`dry-run` 不写 OSS。各渠道独立写自己的清单,由 BFF 汇总,Windows 与 Mac 发布不会覆盖彼此的下载项;Mac 跨架构合并仍遵循现有单架构发布约束。
- Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。
- 归档证据:安装包、`.sig`、渠道清单与源码 commit。
## 验收标准与证据
+渠道与系统分离的定向验证覆盖发布脚本、上传计划、网站配置贯通及跨渠道链接拒绝:`build-release.test.mjs`、`release-oss.test.mjs`、`platform-oss client_downloads` 和 `api-server client_download`。本地隔离数据库的 API smoke 验证 `/healthz` 成功、配置 release 时仅读取 release 分区、查询参数不能覆盖渠道、未发布版本返回空列表且 `no-store`;这不代表已构建或上传 release 安装包。真实 Windows/macOS 安装、签名和更新仍由发布验收单独执行。
+
官网下载与固定服务地址已于 `2026-09-19` 完成源码验收:
| 条款 | 验收方式 | 结果 |
@@ -161,8 +171,8 @@
- macOS 采用单架构包,只登记实际构建架构;Intel 真机构建与跨架构清单合并未验收,不公开宣称双架构分发就绪。
- 旧客户端迁移桥:保留一个版本周期。渠道清单上线后,发布管线同时把旧的 `agc/latest.json`(sha256 格式)指向 `dev-win` 最新安装包,让已发布客户端自动升级到新协议;下个周期整条删除。
- 签名密钥:由本仓库维护者生成并保管,私钥保存在仓库外(`%USERPROFILE%\.tauri\genarrative-agc-updater.key`),只有公钥进入客户端配置;Jenkins 用受保护凭据 `AgcUpdaterSigningKey` 与 `AgcUpdaterSigningKeyPassword` 注入为 Tauri 打包器读取的 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,本机可用 `TAURI_SIGNING_PRIVATE_KEY_PATH` 指向同一私钥。当前密钥不带密码;首次发布前仍可重新生成,首次发布后不可更换。
-- macOS 发布方式:`dev-mac` 产物在本机 mac 上执行发布入口上传,Jenkins 暂不新增 macOS 节点;macOS 代码签名与公证凭据未确认前,相关闭环记为未验证项,不静默通过。
+- macOS 发布方式:对应渠道的 Mac 产物在本机 mac 上执行发布入口上传,Jenkins 暂不新增 macOS 节点;macOS 代码签名与公证凭据未确认前,相关闭环记为未验证项,不静默通过。
待办:
-- macOS `dev-mac` 渠道落地(macOS 构建机、签名与公证、安装后重启验证、是否接入 Jenkins macOS 节点)暂缓,由后续独立变更单独完成;在此之前 `dev-mac` 渠道只有构建与清单能力,不发布。
+- macOS 实际发布(macOS 构建机、签名与公证、安装后重启验证、是否接入 Jenkins macOS 节点)仍需独立验证;代码中的渠道与分区支持不等于已有 Mac 安装包发布。
diff --git a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md
index ed73c2297..e2f5df3a6 100644
--- a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md
+++ b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md
@@ -8,6 +8,17 @@ AGC 客户端接入公共 OSS 上的**游戏模板库**(真·游戏模板,
- 客户端侧:Rust `template_library` 模块(读清单、下载、安装、建项目)+ 模板库全屏页 + 首页模板推荐 + 左侧导航入口。
- 不在本次范围:模板制作工具、模板审核、模板计费、增量更新、已建项目的模板回填。
+## 模板库灰度访问
+
+- 后台现有灰度发布页登记 `agc:template-library`,控制整个模板库;复用用户 ID 白名单、用户标签、拒绝名单和稳定用户分桶比例,不新增数据库表或字段。未配置此 Gate 时默认关闭;已配置且停用灰度时遵循现有语义全量开放,启用灰度时拒绝名单优先于白名单及比例。
+- `/api/runtime/frontend-config` 增加 `agcTemplateLibraryEnabled`,按认证主体返回服务端权威结论。客户端尚未得到结果、匿名或请求失败时关闭入口;权限结果不作为离线缓存。
+- 登录、主体变化、窗口重新获得焦点和每次模板操作时刷新权限,不承诺后台修改的实时推送。未获准时首页模板推荐和左侧模板库导航隐藏,不读取 OSS 清单;已在模板页检测到失去权限时回到首页。原生命令明确拒绝或权限检查失败后同样清空并关闭入口。账号切换或退出后立即清空前一主体的清单、操作状态和可见性,旧异步响应不能恢复权限或导航。
+- 原生清单读取、下载和模板建项命令均独立核对当前平台会话及服务端权限,不能依靠前端隐藏;远端等待后的会话变化必须拒绝,安装和建项写入使用当前会话身份保护。已缓存清单和已安装模板不能绕过权限。
+- 此灰度控制当前客户端的产品功能,不承诺公开 OSS 模板内容的保密性,也不限制已创建项目的正常打开和编辑。旧客户端升级后才接入此控制。
+- 验收覆盖缺省关闭、明确全量、允许/拒绝名单、标签和比例、请求失败、缓存绕过、原生命令阻断、首页/导航/模板页以及账号切换竞态。
+- 验证入口:后台灰度页面测试、`useTemplateLibrary.test.tsx`、AppSurface 实际挂载的 template 用例、原生 `template_library` 测试和后端 `frontend_runtime_config` 测试。退出开始使用既有平台会话代次立即撤销,建项返回、revision 读取和预览核验后的旧回调均不得导航或登记最近项目;同主体 token 轮换不误撤销原生身份。
+- 本地隔离数据库已验证 Gate 经后台 API 保存后可重新读回,匿名运行时配置为 false;后台受控浏览器 smoke 验证桌面和 320px 布局及保存确认交互。线上 OSS 下载和正式安装包登录后的端到端操作不由这些测试替代。
+
## OSS 契约
```text
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index ec4c761fa..032a40f3d 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -75,6 +75,7 @@ npm run check:server-rs-ddd
- 健康检查:`GET /healthz`、`GET /readyz`。
- 后台管理:`/admin/api/*`,现役路由包括登录与账号管理、Dashboard / 概览、HTTP debug、埋点、表查询、通用 feature gate、编辑器定价与素材 / 精选管理,以及账号侧兑换码、邀请码、任务、钱包、充值与退款管理;不再挂载旧创作入口配置、旧作品互动或旧玩法运营路由。环境变量管理员固定作为 owner,持久化 member 每次请求按当前 `enabled`、`token_version` 和一级 Tab 权限实时校验;账号管理仅 owner 可访问,未登记权限映射的新后台路由对 member 默认拒绝。完整权限矩阵见 [`docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md`](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md),Dashboard 指标口径见 [`docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。
- 通用灰度控制面固定为后台 `#gray-release` 与 `GET/PUT /admin/api/feature-gates`;页面固定目标只登记现役功能,不读取旧 `/admin/api/creation-entry/config`,也不恢复 `creation-entry:*` 动态目标。
+- AGC 模板库使用 `agc:template-library`,不新增 schema;`GET /api/runtime/frontend-config` 追加账号相关的 `agcTemplateLibraryEnabled`,匿名和 Gate 缺失为 false,存在配置时复用通用灰度规则。响应设置 `Cache-Control: no-store` 与 `Vary: Authorization`,客户端原生操作独立检查权限。官网公开 `GET /api/client-downloads` 则读取服务端 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(默认 dev)对应的 Windows/macOS 分区,渠道与系统独立,不从请求 query 接受 URL 或渠道覆盖。
- 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
- 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build
index c110c8214..0e7fff89c 100644
--- a/jenkins/Jenkinsfile.ai-game-creator-shell-build
+++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build
@@ -22,7 +22,7 @@ pipeline {
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支')
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit')
string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按该渠道 OSS 与本地版本自动递增 patch')
- choice(name: 'AGC_UPDATE_CHANNEL', choices: ['dev-win', 'dev-mac'], description: 'AGC 发布渠道;dev-win 在 Windows 节点执行,dev-mac 需在 macOS 构建机本地执行')
+ string(name: 'AGC_UPDATE_CHANNEL', defaultValue: 'dev', description: 'AGC 发布渠道:dev、release 或自定义小写名称;此 Job 构建 Windows,macOS 在对应构建机执行')
booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '勾选后只构建并打印将要执行的上传命令,不写入 OSS')
text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本;留空则由本次发布的客户端相关提交自动生成更新摘要')
string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名')
@@ -134,7 +134,10 @@ pipeline {
def anchor = ''
try {
def previousBuild = currentBuild.previousSuccessfulBuild
- anchor = (previousBuild?.buildVariables?.COMMIT_HASH ?: '').toString().trim()
+ def previousChannel = (previousBuild?.buildVariables?.AGC_UPDATE_CHANNEL ?: '').toString().trim()
+ if (previousChannel == params.AGC_UPDATE_CHANNEL.trim()) {
+ anchor = (previousBuild?.buildVariables?.COMMIT_HASH ?: '').toString().trim()
+ }
} catch (error) {
echo "读取上一次成功构建的 commit 失败,跳过摘要锚点兜底:${error}"
}
@@ -154,6 +157,7 @@ pipeline {
"OSSUTIL_BIN=${params.OSSUTIL_BIN}",
"AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}",
"AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}",
+ 'AGC_BUILD_TARGET=x86_64-pc-windows-msvc',
"AGC_RELEASE_DRY_RUN=${params.AGC_RELEASE_DRY_RUN ? '1' : '0'}",
"AGC_UPDATE_PREVIOUS_COMMIT=${env.AGC_UPDATE_PREVIOUS_COMMIT ?: ''}",
"AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}",
diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs
index 97ae778a1..0af004df1 100644
--- a/server-rs/crates/api-server/src/app.rs
+++ b/server-rs/crates/api-server/src/app.rs
@@ -1006,6 +1006,51 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn frontend_runtime_config_template_library_is_scoped_to_authenticated_gate() {
+ let state = AppState::new(AppConfig::default()).expect("state should build");
+ let user = seed_phone_user_with_password(&state, "13800138194", TEST_PASSWORD).await;
+ let token = sign_test_user_token(&state, &user, "sess_template_library_gate");
+ let mut gate = test_feature_gate(module_runtime::AGC_TEMPLATE_LIBRARY_GATE_KEY);
+ let mut cases = vec![(vec![], false)];
+ cases.push((vec![gate.clone()], false));
+ gate.allow_user_ids = vec![user.id.clone()];
+ cases.push((vec![gate.clone()], true));
+ gate.deny_user_ids = vec![user.id.clone()];
+ cases.push((vec![gate.clone()], false));
+ gate.enabled = false;
+ cases.push((vec![gate.clone()], true));
+ gate.enabled = true;
+ gate.allow_user_ids.clear();
+ gate.deny_user_ids.clear();
+ gate.rollout_percent = 100;
+ cases.push((vec![gate], true));
+
+ for (gates, expected) in cases {
+ state.set_test_feature_gate_config(gates);
+ let app = build_router(state.clone());
+ for authenticated in [false, true] {
+ let mut request = Request::builder().uri("/api/runtime/frontend-config");
+ if authenticated {
+ request = request.header("authorization", format!("Bearer {token}"));
+ }
+ let response = app
+ .clone()
+ .oneshot(request.body(Body::empty()).unwrap())
+ .await
+ .unwrap();
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
+ assert_eq!(response.headers().get("vary").unwrap(), "Authorization");
+ let payload = read_json_response(response).await;
+ assert_eq!(
+ payload["agcTemplateLibraryEnabled"],
+ Value::Bool(authenticated && expected)
+ );
+ }
+ }
+ }
+
#[tokio::test]
async fn frontend_runtime_config_returns_agent_sidebar_env_flag() {
let config = AppConfig {
diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs
index a2200d2a0..77f803958 100644
--- a/server-rs/crates/api-server/src/config.rs
+++ b/server-rs/crates/api-server/src/config.rs
@@ -91,6 +91,7 @@ pub struct AppConfig {
pub editor_bgfilter_circuit_failure_threshold: u32,
pub editor_bgfilter_circuit_cooldown: Duration,
pub image_editor_agent_sidebar_enabled: bool,
+ pub client_download_channel: String,
pub log_filter: String,
pub otel_enabled: bool,
pub admin_username: Option,
@@ -397,6 +398,7 @@ impl Default for AppConfig {
DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS,
),
image_editor_agent_sidebar_enabled: false,
+ client_download_channel: "dev".to_string(),
log_filter: "info,tower_http=info".to_string(),
otel_enabled: false,
admin_username: None,
@@ -713,6 +715,10 @@ impl AppConfig {
]) {
config.editor_bgfilter_circuit_cooldown = Duration::from_secs(cooldown_seconds.max(1));
}
+ if let Ok(channel) = std::env::var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL") {
+ // 显式空值或非法值也保留,由下载入口失败关闭,不能悄悄改读 dev。
+ config.client_download_channel = channel.trim().to_string();
+ }
if let Some(enabled) =
read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"])
{
@@ -3046,6 +3052,36 @@ mod tests {
}
}
+ #[test]
+ fn client_download_channel_defaults_to_dev_and_preserves_explicit_configuration() {
+ let _guard = ENV_LOCK
+ .get_or_init(|| Mutex::new(()))
+ .lock()
+ .expect("env lock");
+ let previous = std::env::var_os("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL");
+ unsafe {
+ std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL");
+ }
+ assert_eq!(AppConfig::from_env().client_download_channel, "dev");
+ for (value, expected) in [
+ ("release", "release"),
+ (" qa-2026 ", "qa-2026"),
+ ("", ""),
+ ("dev-win", "dev-win"),
+ ] {
+ unsafe {
+ std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", value);
+ }
+ assert_eq!(AppConfig::from_env().client_download_channel, expected);
+ }
+ unsafe {
+ match previous {
+ Some(value) => std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", value),
+ None => std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"),
+ }
+ }
+ }
+
#[test]
fn from_env_reads_prefixed_character_animation_ffmpeg_paths() {
let _guard = ENV_LOCK
diff --git a/server-rs/crates/api-server/src/frontend_runtime_config.rs b/server-rs/crates/api-server/src/frontend_runtime_config.rs
index 89750105e..28ab6bd31 100644
--- a/server-rs/crates/api-server/src/frontend_runtime_config.rs
+++ b/server-rs/crates/api-server/src/frontend_runtime_config.rs
@@ -1,10 +1,10 @@
use axum::{
- Json,
extract::{Extension, State},
- http::{HeaderMap, StatusCode},
+ http::{HeaderMap, StatusCode, header},
+ response::{IntoResponse, Response},
};
use serde::Serialize;
-use serde_json::{Value, json};
+use serde_json::json;
use crate::{
api_response::json_success_body, auth::optional_access_token_from_headers,
@@ -15,13 +15,14 @@ use crate::{
#[serde(rename_all = "camelCase")]
pub struct FrontendRuntimeConfigResponse {
pub image_editor_agent_sidebar_enabled: bool,
+ pub agc_template_library_enabled: bool,
}
pub async fn get_frontend_runtime_config(
State(state): State,
Extension(request_context): Extension,
headers: HeaderMap,
-) -> Result, AppError> {
+) -> Result {
let authenticated = optional_access_token_from_headers(
&state,
"/api/runtime/frontend-config".to_string(),
@@ -44,10 +45,30 @@ pub async fn get_frontend_runtime_config(
}))
})?;
- Ok(json_success_body(
- Some(&request_context),
- FrontendRuntimeConfigResponse {
- image_editor_agent_sidebar_enabled,
- },
- ))
+ let agc_template_library_enabled = state
+ .is_agc_template_library_enabled_for_user(user_id)
+ .await
+ .map_err(|error| {
+ AppError::from_status(StatusCode::BAD_GATEWAY)
+ .with_message("读取前端运行时配置失败")
+ .with_details(json!({
+ "provider": "spacetimedb",
+ "message": error.to_string(),
+ }))
+ })?;
+
+ Ok((
+ [
+ (header::CACHE_CONTROL, "no-store"),
+ (header::VARY, "Authorization"),
+ ],
+ json_success_body(
+ Some(&request_context),
+ FrontendRuntimeConfigResponse {
+ image_editor_agent_sidebar_enabled,
+ agc_template_library_enabled,
+ },
+ ),
+ )
+ .into_response())
}
diff --git a/server-rs/crates/api-server/src/modules/client_downloads.rs b/server-rs/crates/api-server/src/modules/client_downloads.rs
index 04bfeb5a5..f43f39a65 100644
--- a/server-rs/crates/api-server/src/modules/client_downloads.rs
+++ b/server-rs/crates/api-server/src/modules/client_downloads.rs
@@ -1,5 +1,6 @@
use axum::{
Json, Router,
+ extract::State,
http::{HeaderValue, StatusCode, header::CACHE_CONTROL},
response::{IntoResponse, Response},
routing::get,
@@ -18,8 +19,8 @@ pub fn router(_state: AppState) -> Router {
Router::new().route("/api/client-downloads", get(client_downloads))
}
-async fn client_downloads() -> Response {
- download_response(fetch_latest_client_downloads().await)
+async fn client_downloads(State(state): State) -> Response {
+ download_response(fetch_latest_client_downloads(&state.config.client_download_channel).await)
}
fn download_response(result: Result) -> Response {
@@ -69,6 +70,29 @@ mod tests {
use platform_oss::client_downloads::ClientDownload as SourceDownload;
use serde_json::{Value, json};
+ #[tokio::test]
+ async fn client_downloads_uses_server_config_and_does_not_accept_query_overrides() {
+ use axum::{body::Body, http::Request};
+ use tower::ServiceExt;
+ let state = AppState::new(crate::config::AppConfig {
+ client_download_channel: "dev-win".to_string(),
+ ..Default::default()
+ })
+ .unwrap();
+ let app = router(state.clone()).with_state(state);
+ let response = app
+ .oneshot(
+ Request::builder()
+ .uri("/api/client-downloads?channel=dev&url=https://example.com")
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
+ assert_eq!(response.headers()[CACHE_CONTROL], "no-store");
+ }
+
async fn read_success(result: ClientDownloads) -> Value {
let response = download_response(Ok(result));
assert_eq!(response.status(), StatusCode::OK);
@@ -151,6 +175,7 @@ mod tests {
#[tokio::test]
async fn upstream_failures_share_a_safe_retryable_uncached_error() {
for error in [
+ ClientDownloadError::InvalidChannel,
ClientDownloadError::Transport,
ClientDownloadError::UpstreamStatus,
ClientDownloadError::ManifestTooLarge,
diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs
index 98033a8f5..52513b078 100644
--- a/server-rs/crates/api-server/src/state.rs
+++ b/server-rs/crates/api-server/src/state.rs
@@ -1177,6 +1177,29 @@ impl AppState {
Ok(module_runtime::is_feature_gate_allowed(gate, &user_context))
}
+ pub async fn is_agc_template_library_enabled_for_user(
+ &self,
+ user_id: Option<&str>,
+ ) -> Result {
+ let Some(user_id) = user_id.map(str::trim).filter(|id| !id.is_empty()) else {
+ return Ok(false);
+ };
+ let gates = self.get_feature_gate_config().await?;
+ let Some(gate) = gates
+ .iter()
+ .find(|item| item.gate_key == module_runtime::AGC_TEMPLATE_LIBRARY_GATE_KEY)
+ else {
+ return Ok(false);
+ };
+ let user_context = self
+ .feature_gate_user_context(Some(user_id), feature_gate_requires_user_tags(gate))
+ .await;
+ Ok(module_runtime::is_feature_gate_allowed(
+ Some(gate),
+ &user_context,
+ ))
+ }
+
#[cfg(any())]
pub async fn list_admin_work_visibility(
&self,
diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs
index e956bf723..bf3e51bb1 100644
--- a/server-rs/crates/module-runtime/src/application.rs
+++ b/server-rs/crates/module-runtime/src/application.rs
@@ -83,6 +83,7 @@ pub fn creation_entry_feature_gate_key(creation_type_id: &str) -> String {
}
pub const IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY: &str = "image-editor:agent-sidebar";
+pub const AGC_TEMPLATE_LIBRARY_GATE_KEY: &str = "agc:template-library";
#[cfg(any())]
pub fn apply_feature_gates_to_creation_entry_config(
diff --git a/server-rs/crates/platform-oss/src/client_downloads.rs b/server-rs/crates/platform-oss/src/client_downloads.rs
index ff1aac961..d8476fc44 100644
--- a/server-rs/crates/platform-oss/src/client_downloads.rs
+++ b/server-rs/crates/platform-oss/src/client_downloads.rs
@@ -3,10 +3,6 @@ use std::time::Duration;
use reqwest::{Client, Url, header::CACHE_CONTROL, redirect::Policy};
use serde_json::Value;
-const WINDOWS_MANIFEST_URL: &str =
- "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json";
-const MACOS_MANIFEST_URL: &str =
- "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json";
const DOWNLOAD_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs.com";
const MANIFEST_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_MANIFEST_BYTES: usize = 128 * 1024;
@@ -18,11 +14,12 @@ pub enum DownloadPlatform {
}
impl DownloadPlatform {
- fn channel(self) -> &'static str {
- match self {
- Self::Windows => "dev-win",
- Self::Macos => "dev-mac",
- }
+ fn partition(self, channel: &str) -> String {
+ let system = match self {
+ Self::Windows => "win",
+ Self::Macos => "mac",
+ };
+ format!("{channel}-{system}")
}
fn extension(self) -> &'static str {
@@ -55,29 +52,67 @@ pub struct ClientDownloads {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClientDownloadError {
+ InvalidChannel,
Transport,
UpstreamStatus,
ManifestTooLarge,
InvalidManifest,
}
-pub async fn fetch_latest_client_downloads() -> Result {
- fetch_downloads_at(WINDOWS_MANIFEST_URL, MACOS_MANIFEST_URL, MANIFEST_TIMEOUT).await
+/// 渠道与系统独立;分区后缀仅用于延续既有公开更新地址。
+pub fn validate_download_channel(channel: &str) -> Result<(), ClientDownloadError> {
+ if channel.is_empty()
+ || channel.len() > 32
+ || !channel.as_bytes()[0].is_ascii_lowercase()
+ || !channel
+ .bytes()
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
+ || channel.ends_with('-')
+ || channel.ends_with("-win")
+ || channel.ends_with("-mac")
+ || matches!(
+ channel,
+ "win" | "mac" | "windows" | "macos" | "darwin" | "linux"
+ )
+ {
+ return Err(ClientDownloadError::InvalidChannel);
+ }
+ Ok(())
+}
+
+fn manifest_urls(channel: &str) -> Result<(String, String), ClientDownloadError> {
+ validate_download_channel(channel)?;
+ let url = |platform: DownloadPlatform| {
+ format!(
+ "https://{DOWNLOAD_HOST}/agc/{}/latest.json",
+ platform.partition(channel)
+ )
+ };
+ Ok((url(DownloadPlatform::Windows), url(DownloadPlatform::Macos)))
+}
+
+pub async fn fetch_latest_client_downloads(
+ channel: &str,
+) -> Result {
+ let (windows_url, macos_url) = manifest_urls(channel)?;
+ fetch_downloads_at(&windows_url, &macos_url, MANIFEST_TIMEOUT, channel).await
}
async fn fetch_downloads_at(
windows_url: &str,
macos_url: &str,
timeout: Duration,
+ channel: &str,
) -> Result {
+ validate_download_channel(channel)?;
let client = Client::builder()
.redirect(Policy::none())
.timeout(timeout)
.build()
.map_err(|_| ClientDownloadError::Transport)?;
let (windows, macos) = tokio::join!(
- fetch_manifest(&client, windows_url, DownloadPlatform::Windows),
- fetch_manifest(&client, macos_url, DownloadPlatform::Macos)
+ fetch_manifest(&client, windows_url, DownloadPlatform::Windows, channel),
+ fetch_manifest(&client, macos_url, DownloadPlatform::Macos, channel)
);
collect_downloads(windows, macos)
}
@@ -116,6 +151,7 @@ async fn fetch_manifest(
client: &Client,
manifest_url: &str,
platform: DownloadPlatform,
+ channel: &str,
) -> Result, ClientDownloadError> {
// 公共清单不复用带鉴权的 OSS client,也不接收或转发用户请求头。
let mut response = client
@@ -147,12 +183,13 @@ async fn fetch_manifest(
}
body.extend_from_slice(&chunk);
}
- parse_manifest(&body, platform)
+ parse_manifest(&body, platform, channel)
}
fn parse_manifest(
body: &[u8],
platform: DownloadPlatform,
+ channel: &str,
) -> Result, ClientDownloadError> {
let manifest: Value =
serde_json::from_slice(body).map_err(|_| ClientDownloadError::InvalidManifest)?;
@@ -184,7 +221,7 @@ fn parse_manifest(
.get("url")
.and_then(Value::as_str)
.ok_or(ClientDownloadError::InvalidManifest)?;
- validate_download_url(download_url, version, platform)?;
+ validate_download_url(download_url, version, platform, channel)?;
downloads.push(ClientDownload {
platform,
architecture,
@@ -199,12 +236,13 @@ fn validate_download_url(
download_url: &str,
version: &str,
platform: DownloadPlatform,
+ channel: &str,
) -> Result<(), ClientDownloadError> {
let url = Url::parse(download_url).map_err(|_| ClientDownloadError::InvalidManifest)?;
let encoded_version = version.replace('+', "%2B");
let expected_prefix = format!(
"https://{DOWNLOAD_HOST}/agc/{}/{encoded_version}/",
- platform.channel()
+ platform.partition(channel)
);
let file_name = download_url
.strip_prefix(&expected_prefix)
@@ -286,11 +324,115 @@ mod tests {
task::JoinHandle,
};
+ fn parse_manifest(
+ body: &[u8],
+ platform: DownloadPlatform,
+ ) -> Result, ClientDownloadError> {
+ super::parse_manifest(body, platform, "dev")
+ }
+
+ async fn fetch_downloads_at(
+ windows_url: &str,
+ macos_url: &str,
+ timeout: Duration,
+ ) -> Result {
+ super::fetch_downloads_at(windows_url, macos_url, timeout, "dev").await
+ }
+
+ #[test]
+ fn release_channels_are_independent_of_system_and_keep_existing_dev_addresses() {
+ for channel in ["dev", "release", "beta", "qa-2026"] {
+ let (windows, macos) = manifest_urls(channel).unwrap();
+ assert_eq!(
+ windows,
+ format!("https://{DOWNLOAD_HOST}/agc/{channel}-win/latest.json")
+ );
+ assert_eq!(
+ macos,
+ format!("https://{DOWNLOAD_HOST}/agc/{channel}-mac/latest.json")
+ );
+ }
+ for channel in [
+ "",
+ "DEV",
+ "win",
+ "mac",
+ "windows",
+ "macos",
+ "darwin",
+ "linux",
+ "dev-win",
+ "release-mac",
+ "beta-",
+ "../dev",
+ "a/b",
+ "https://evil",
+ "dev?x=1",
+ "a_b",
+ " dev",
+ "1dev",
+ "abcdefghijklmnopqrstuvwxyz1234567",
+ ] {
+ assert_eq!(
+ manifest_urls(channel),
+ Err(ClientDownloadError::InvalidChannel),
+ "{channel}"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn configured_channel_fetches_both_systems_and_rejects_other_channel_links() {
+ for channel in ["release", "qa-2026"] {
+ let windows = String::from_utf8(windows_manifest("1.2.3"))
+ .unwrap()
+ .replace("/dev-win/", &format!("/{channel}-win/"));
+ let macos = String::from_utf8(macos_manifest("2.3.4"))
+ .unwrap()
+ .replace("/dev-mac/", &format!("/{channel}-mac/"));
+ let (windows_url, windows_task) =
+ upstream(http_response("200 OK", windows.as_bytes()), Duration::ZERO).await;
+ let (macos_url, macos_task) =
+ upstream(http_response("200 OK", macos.as_bytes()), Duration::ZERO).await;
+ let result =
+ super::fetch_downloads_at(&windows_url, &macos_url, MANIFEST_TIMEOUT, channel)
+ .await
+ .unwrap();
+ windows_task.await.unwrap();
+ macos_task.await.unwrap();
+ assert_eq!(result.downloads.len(), 3);
+ assert_eq!(result.downloads[0].version, "1.2.3");
+ assert_eq!(result.downloads[1].version, "2.3.4");
+ assert!(
+ result
+ .downloads
+ .iter()
+ .all(|download| download.download_url.contains(&format!("/{channel}-")))
+ );
+ assert_eq!(
+ super::parse_manifest(
+ &windows_manifest("1.2.3"),
+ DownloadPlatform::Windows,
+ channel
+ ),
+ Err(ClientDownloadError::InvalidManifest)
+ );
+ assert_eq!(
+ super::parse_manifest(windows.as_bytes(), DownloadPlatform::Windows, "dev"),
+ Err(ClientDownloadError::InvalidManifest)
+ );
+ }
+ assert_eq!(
+ fetch_latest_client_downloads("../dev").await,
+ Err(ClientDownloadError::InvalidChannel)
+ );
+ }
+
fn download_url(platform: DownloadPlatform, version: &str, file: &str) -> String {
let encoded_version = version.replace('+', "%2B");
format!(
"https://{DOWNLOAD_HOST}/agc/{}/{encoded_version}/{file}",
- platform.channel()
+ platform.partition("dev")
)
}
diff --git a/src/services/frontendRuntimeConfigService.ts b/src/services/frontendRuntimeConfigService.ts
index de64cbfa1..4be526523 100644
--- a/src/services/frontendRuntimeConfigService.ts
+++ b/src/services/frontendRuntimeConfigService.ts
@@ -4,6 +4,7 @@ const FRONTEND_RUNTIME_CONFIG_API = '/api/runtime/frontend-config';
export type FrontendRuntimeConfig = {
imageEditorAgentSidebarEnabled: boolean;
+ agcTemplateLibraryEnabled: boolean;
};
export async function loadFrontendRuntimeConfig() {