Compare commits

..

3 Commits

Author SHA1 Message Date
suzmii 92c05428ec 另一架构切片不得借用宿主 npm
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m55s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m17s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 6m48s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m43s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m28s
Project CI / Frontend tests (pull_request) Successful in 8m34s
Project CI / Native shell tests (pull_request) Successful in 12m47s
- runtimeSourceForTarget 对非宿主架构返回 npmCli: null,明令按该发行版自己的安装目录查找 npm
- 覆盖经 npm run 触发(npm_execpath 指向宿主 npm)时两份切片仍各带自己那份 npm 的用例
2026-09-21 12:06:39 +08:00
suzmii 91f1554ac7 macOS universal 管线接入双架构 Node 运行时前置
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- Jenkinsfile.ai-game-creator-shell-macos-build 增加 AGC_NODE_RUNTIME_DARWIN_X64_HOME,并在 Toolchain 阶段编译前核对 x86_64 发行版存在且与构建 Node 同版本
- Package 阶段导出 AGC_NODE_RUNTIME_DARWIN_X64_PATH / _LICENSE_PATH,供 universal staging 取另一架构来源
- 开发运维文档记录节点前置:官方 darwin-x64 发行版安装与校验命令、同版本要求、按架构并列的资源布局、Rust 侧按运行架构选择、约 260 MB 解包体积代价,以及本机跨架构诊断构建的取值方式
- 实施计划与更新检查方案文档同步 universal 携带两套架构运行时的契约
2026-09-21 11:58:22 +08:00
suzmii 6a0b75779b AGC 随包 Node 支持 universal 双架构运行时
- stage-node-runtime 新增 targetRuntimes:universal 展开为 aarch64/x86_64 两份单架构运行时
- 新增 runtimeSourceForTarget:宿主架构取当前构建 Node,其它架构必须由 AGC_NODE_RUNTIME_<PLATFORM>_<ARCH>_PATH 显式提供,缺失即失败关闭
- 把 stageNodeRuntime 拆成 inspectRuntimeSource(只读校验)+ writeRuntimeBundle(落盘),universal 在写出任何目录前完成全部来源校验与版本一致性核对
- stageNodeRuntimeForTarget 单架构仍写扁平 node-runtime/,universal 按架构写 node-runtime/<platform>-<arch>/
- build-release 默认按发布目标 stage,universal 构建不再只带宿主架构的运行时
- environment_check resolve_at 在扁平目录之后按当前运行架构查找 <platform>-<arch> 子目录,两份候选都要过清单校验,存在但损坏则失败关闭
- 覆盖双架构 staging、缺非宿主运行时、版本不一致、按运行架构解析等用例
2026-09-21 11:57:43 +08:00
174 changed files with 1095 additions and 24276 deletions
@@ -3,14 +3,12 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
createAdminAccount,
executeAdminRechargeRefund,
getAdminAgcTemplates,
getAdminFeatureGateConfig,
getAdminUserDetail,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
updateAdminAccount,
updateAdminAgcTemplate,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig,
upsertProfileWalletConfig,
@@ -20,51 +18,6 @@ afterEach(() => {
vi.unstubAllGlobals();
});
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
const library = { revision: 'revision-new', writable: true, templates: [] };
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: library }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
expect(await getAdminAgcTemplates('admin-token', controller.signal)).toEqual(
library,
);
const update = {
expectedRevision: 'revision-old',
title: '空白模板',
summary: '简介',
tags: ['2D'],
enabled: true,
cover: { contentType: 'image/png', dataBase64: 'aW1hZ2U=' },
};
expect(
await updateAdminAgcTemplate('admin-token', 'template/1', update),
).toEqual(library);
expect(fetchMock.mock.calls[0]).toEqual([
'/admin/api/agc-templates',
expect.objectContaining({
method: 'GET',
signal: controller.signal,
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
}),
]);
expect(fetchMock.mock.calls[1]).toEqual([
'/admin/api/agc-templates/template%2F1',
expect.objectContaining({
method: 'PUT',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Content-Type': 'application/json',
}),
body: JSON.stringify(update),
}),
]);
});
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
-20
View File
@@ -1,6 +1,5 @@
import type {
AdminAccountListResponse,
AdminAgcTemplateLibraryResponse,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
@@ -48,7 +47,6 @@ import type {
AdminTrackingEventListResponse,
AdminUpdateAccountRequest,
AdminUpdateAccountResponse,
AdminUpdateAgcTemplateRequest,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
@@ -1178,21 +1176,3 @@ export function saveAgcModelCatalog(
{ token, method: 'PUT', body },
);
}
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
token,
signal,
});
}
export function updateAdminAgcTemplate(
token: string,
id: string,
body: AdminUpdateAgcTemplateRequest,
) {
return request<AdminAgcTemplateLibraryResponse>(
`/admin/api/agc-templates/${encodeURIComponent(id)}`,
{ token, method: 'PUT', body },
);
}
-32
View File
@@ -1033,35 +1033,3 @@ export interface AdminAgcModelCatalog {
defaultModelId: string;
models: AdminAgcModel[];
}
export interface AdminAgcTemplatePayload {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
enabled: boolean;
coverUrl: string;
zipSizeBytes: number;
}
export interface AdminAgcTemplateLibraryResponse {
revision: string;
writable: boolean;
templates: AdminAgcTemplatePayload[];
}
export interface AdminUpdateAgcTemplateRequest {
expectedRevision: string;
title: string;
summary: string;
tags: string[];
enabled: boolean;
cover?: {
contentType: string;
dataBase64: string;
};
}
-7
View File
@@ -19,7 +19,6 @@ import {
} from '../auth/adminAuthStore';
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
@@ -295,12 +294,6 @@ export function AdminApp() {
{activeRouteId === 'agc-models' ? (
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{activeRouteId === 'agc-templates' ? (
<AdminAgcTemplatesPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-showcase' ? (
<AdminEditorShowcaseReviewPage
token={token}
-1
View File
@@ -53,7 +53,6 @@ const routeIcons = {
'project-snapshots': FolderArchive,
accounts: Users,
'agc-models': ListChecks,
'agc-templates': Images,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
@@ -147,30 +147,3 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
}),
).not.toContainEqual(route);
});
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
expect(adminRoutes).toContainEqual({
id: 'agc-templates',
label: '模板管理',
hash: '#agc-templates',
});
expect(resolveAdminRoute('#agc-templates')).toBe('agc-templates');
expect(routeHash('agc-templates')).toBe('#agc-templates');
expect(
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }).some(
(route) => route.id === 'agc-templates',
),
).toBe(true);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['agc-templates'],
}).map((route) => route.id),
).toEqual(['agc-templates']);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['editor-assets'],
}).some((route) => route.id === 'agc-templates'),
).toBe(false);
});
-2
View File
@@ -18,7 +18,6 @@ export type AdminRouteId =
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
| 'agc-templates'
| 'accounts';
export type AdminTabPermission = Exclude<
@@ -54,7 +53,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
hash: '#editor-generation-pricing',
},
{ 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: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
-1
View File
@@ -1,4 +1,3 @@
import '@genarrative/shared/styles.css';
import './styles/admin.css';
import { StrictMode } from 'react';
@@ -1,498 +0,0 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
AdminApiError,
getAdminAgcTemplates,
updateAdminAgcTemplate,
} from '../api/adminApiClient';
import type {
AdminAgcTemplateLibraryResponse,
AdminAgcTemplatePayload,
} from '../api/adminApiTypes';
import { AdminAgcTemplatesPage } from './AdminAgcTemplatesPage';
vi.mock('../api/adminApiClient', async (importOriginal) => ({
...(await importOriginal<typeof import('../api/adminApiClient')>()),
getAdminAgcTemplates: vi.fn(),
updateAdminAgcTemplate: vi.fn(),
}));
const template: AdminAgcTemplatePayload = {
id: 'cocos-empty-2d',
title: '空白 2D',
summary: '二维项目',
tags: ['2D', '入门'],
runtime: 'cocos',
engine: 'Cocos Creator',
engineVersion: '3.8.8',
templateVersion: '1',
enabled: true,
coverUrl: 'https://example.test/cover.png',
zipSizeBytes: 2048,
};
const library: AdminAgcTemplateLibraryResponse = {
revision: 'rev-1',
writable: true,
templates: [
template,
{
...template,
id: 'godot-starter',
title: 'Godot 起步',
runtime: 'godot',
engine: 'Godot',
engineVersion: '4',
tags: ['3D'],
enabled: false,
},
],
};
beforeEach(() => {
vi.mocked(getAdminAgcTemplates)
.mockReset()
.mockImplementation(async () => structuredClone(library));
vi.mocked(updateAdminAgcTemplate)
.mockReset()
.mockImplementation(async (_token, id, update) => ({
...structuredClone(library),
revision: 'rev-2',
templates: library.templates.map((entry) =>
entry.id === id
? {
...entry,
title: update.title,
summary: update.summary,
tags: update.tags,
enabled: update.enabled,
}
: entry,
),
}));
vi.stubGlobal(
'URL',
Object.assign(class extends URL {}, {
createObjectURL: vi.fn(() => 'blob:template-cover'),
revokeObjectURL: vi.fn(),
}),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
async function openEditor() {
fireEvent.click(await screen.findByRole('button', { name: '编辑 空白 2D' }));
return screen.getByRole('dialog', { name: '编辑模板' });
}
async function confirmWrite() {
const confirmation = await screen.findByRole('dialog', { name: '确认操作' });
fireEvent.click(within(confirmation).getByRole('button', { name: '确认' }));
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((next, fail) => {
resolve = next;
reject = fail;
});
return { promise, resolve, reject };
}
test('按名称、ID、标签、运行时和上下架状态筛选,展示版本与封面', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('空白 2D');
expect(screen.getByText('Cocos Creator 3.8.8')).not.toBeNull();
expect(screen.getAllByText('模板 1')).toHaveLength(2);
expect(screen.getAllByText('2.0 KiB')).toHaveLength(2);
expect(
screen.getByRole('img', { name: '空白 2D封面' }).getAttribute('src'),
).toBe(template.coverUrl);
for (const value of ['空白', 'cocos-empty-2d', '入门']) {
fireEvent.change(screen.getByLabelText('搜索模板'), { target: { value } });
expect(screen.getByText('空白 2D')).not.toBeNull();
expect(screen.queryByText('Godot 起步')).toBeNull();
}
fireEvent.change(screen.getByLabelText('搜索模板'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('运行时'), {
target: { value: 'godot' },
});
expect(screen.queryByText('空白 2D')).toBeNull();
fireEvent.change(screen.getByLabelText('上架状态'), {
target: { value: 'enabled' },
});
expect(screen.getByText('没有符合筛选条件的模板')).not.toBeNull();
});
test('独立弹窗保存经写确认,只提交展示字段和冻结 revision', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const editor = await openEditor();
expect(editor.closest('.admin-agc-templates')).toBeNull();
fireEvent.change(within(editor).getByLabelText('名称'), {
target: { value: ' 新名称 ' },
});
fireEvent.change(within(editor).getByLabelText('简介'), {
target: { value: '新简介' },
});
fireEvent.change(within(editor).getByLabelText('标签'), {
target: { value: '2D, 新手,2D' },
});
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
await confirmWrite();
await waitFor(() =>
expect(updateAdminAgcTemplate).toHaveBeenCalledWith('token', template.id, {
expectedRevision: 'rev-1',
title: '新名称',
summary: '新简介',
tags: ['2D', '新手'],
enabled: true,
}),
);
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '编辑模板' })).toBeNull(),
);
expect(screen.getByText('新名称')).not.toBeNull();
expect(screen.getByText('已保存')).not.toBeNull();
});
test('上下架复用确认且只用服务端结果更新列表', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: '下架 空白 2D' }));
await confirmWrite();
await waitFor(() =>
expect(updateAdminAgcTemplate).toHaveBeenCalledWith('token', template.id, {
expectedRevision: 'rev-1',
title: template.title,
summary: template.summary,
tags: template.tags,
enabled: false,
}),
);
expect(
await screen.findByRole('button', { name: '上架 空白 2D' }),
).not.toBeNull();
});
test('只读模式禁用字段、保存与上下架', async () => {
vi.mocked(getAdminAgcTemplates).mockResolvedValue({
...library,
writable: false,
});
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
expect(
await screen.findByText('当前为只读模式,无法保存或上下架'),
).not.toBeNull();
expect(
(screen.getByRole('button', { name: '下架 空白 2D' }) as HTMLButtonElement)
.disabled,
).toBe(true);
const editor = await openEditor();
expect(
(within(editor).getByLabelText('名称') as HTMLInputElement).disabled,
).toBe(true);
expect(
(within(editor).getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
});
test('409 保留草稿,显式刷新后重新编辑才采用新 revision,不自动重试', async () => {
vi.mocked(updateAdminAgcTemplate).mockRejectedValueOnce(
new AdminApiError({ message: '模板库已被修改', status: 409 }),
);
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
let editor = await openEditor();
fireEvent.change(within(editor).getByLabelText('名称'), {
target: { value: '未保存草稿' },
});
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
await confirmWrite();
await screen.findByText('模板库已被修改');
expect((screen.getByLabelText('名称') as HTMLInputElement).value).toBe(
'未保存草稿',
);
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1);
expect(
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
vi.mocked(getAdminAgcTemplates).mockResolvedValueOnce({
...library,
revision: 'rev-3',
templates: [{ ...template, title: '最新名称' }],
});
fireEvent.click(screen.getByRole('button', { name: '刷新后重新编辑' }));
await screen.findByDisplayValue('最新名称');
editor = screen.getByRole('dialog', { name: '编辑模板' });
fireEvent.change(within(editor).getByLabelText('名称'), {
target: { value: '重新编辑' },
});
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
await confirmWrite();
await waitFor(() =>
expect(updateAdminAgcTemplate).toHaveBeenLastCalledWith(
'token',
template.id,
expect.objectContaining({ expectedRevision: 'rev-3', title: '重新编辑' }),
),
);
});
test('防止重复确认和保存中重复提交', async () => {
const pending = deferred<AdminAgcTemplateLibraryResponse>();
vi.mocked(updateAdminAgcTemplate).mockReturnValue(pending.promise);
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const editor = await openEditor();
const form = within(editor).getByLabelText('名称').closest('form')!;
fireEvent.submit(form);
fireEvent.submit(form);
expect(screen.getAllByRole('dialog', { name: '确认操作' })).toHaveLength(1);
await confirmWrite();
await waitFor(() => expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1));
fireEvent.submit(form);
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1);
await act(async () => pending.resolve(library));
});
test.each([409, 503])(
'滚到表单底部后出现 HTTP %s 写入错误,聚焦并仅滚动弹窗到提示区',
async (status) => {
vi.mocked(updateAdminAgcTemplate).mockRejectedValueOnce(
new AdminApiError({ message: '本次保存失败', status }),
);
render(
<div data-testid="background-page">
<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />
</div>,
);
const background = screen.getByTestId('background-page');
background.scrollTop = 240;
const editor = await openEditor();
const viewport = editor.querySelector<HTMLElement>(
'.genarrative-ui-modal__body',
)!;
viewport.scrollTop = 400;
const bounds = (top: number, bottom: number) =>
({
top,
bottom,
height: bottom - top,
left: 0,
right: 300,
width: 300,
x: 0,
y: top,
toJSON: () => ({}),
}) as DOMRect;
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(
bounds(100, 450),
);
const originalBounds = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
function (this: HTMLElement) {
return this.classList.contains('admin-agc-template-feedback')
? bounds(-280, -120)
: originalBounds.call(this);
},
);
const windowScroll = vi
.spyOn(window, 'scrollTo')
.mockImplementation(() => {});
const focus = vi.spyOn(HTMLElement.prototype, 'focus');
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
await confirmWrite();
const message = await screen.findByRole('alert');
const feedback = message.parentElement!;
expect(document.activeElement).toBe(feedback);
expect(feedback.tabIndex).toBe(-1);
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(viewport.scrollTop).toBe(20);
expect(background.scrollTop).toBe(240);
expect(windowScroll).not.toHaveBeenCalled();
if (status === 409) {
expect(
within(feedback).getByRole('button', { name: '刷新后重新编辑' }),
).not.toBeNull();
}
},
);
test('token 切换后忽略旧列表和未确认写操作', async () => {
const pending = deferred<AdminAgcTemplateLibraryResponse>();
vi.mocked(getAdminAgcTemplates).mockReturnValueOnce(pending.promise);
const props = { onUnauthorized: vi.fn() };
const view = render(<AdminAgcTemplatesPage token="old" {...props} />);
view.rerender(<AdminAgcTemplatesPage token="new" {...props} />);
await screen.findByText('空白 2D');
await act(async () =>
pending.resolve({
...library,
templates: [{ ...template, title: '过期列表' }],
}),
);
expect(screen.queryByText('过期列表')).toBeNull();
const editor = await openEditor();
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
view.rerender(<AdminAgcTemplatesPage token="third" {...props} />);
await screen.findByText('空白 2D');
expect(screen.queryByRole('dialog')).toBeNull();
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
});
test.each(['success', 'unauthorized'])(
'token 切换后忽略旧保存 %s,不污染新会话',
async (result) => {
const pending = deferred<AdminAgcTemplateLibraryResponse>();
vi.mocked(updateAdminAgcTemplate).mockReturnValueOnce(pending.promise);
const onUnauthorized = vi.fn();
const view = render(
<AdminAgcTemplatesPage token="old" onUnauthorized={onUnauthorized} />,
);
const editor = await openEditor();
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
await confirmWrite();
await waitFor(() =>
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1),
);
view.rerender(
<AdminAgcTemplatesPage token="new" onUnauthorized={onUnauthorized} />,
);
await screen.findByText('空白 2D');
await act(async () => {
if (result === 'success') {
pending.resolve({
...library,
templates: [{ ...template, title: '旧保存结果' }],
});
} else {
pending.reject(
new AdminApiError({ status: 401, message: '旧会话失效' }),
);
}
});
expect(onUnauthorized).not.toHaveBeenCalled();
expect(screen.queryByText('旧会话失效')).toBeNull();
expect(screen.queryByText('旧保存结果')).toBeNull();
expect(screen.queryByText('已保存')).toBeNull();
},
);
test('封面在草稿中转 base64,保存携带,取消释放预览 URL 且不串到下一条', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
let editor = await openEditor();
const file = new File(['cover'], 'cover.png', { type: 'image/png' });
fireEvent.change(within(editor).getByLabelText('更换封面'), {
target: { files: [file] },
});
await waitFor(() => expect(screen.queryByText('正在读取封面')).toBeNull());
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
await confirmWrite();
await waitFor(() =>
expect(updateAdminAgcTemplate).toHaveBeenCalledWith(
'token',
template.id,
expect.objectContaining({
cover: { contentType: 'image/png', dataBase64: 'Y292ZXI=' },
}),
),
);
await waitFor(() =>
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:template-cover'),
);
editor = await openEditor();
fireEvent.change(within(editor).getByLabelText('更换封面'), {
target: { files: [file] },
});
fireEvent.click(within(editor).getByRole('button', { name: '取消' }));
fireEvent.click(screen.getByRole('button', { name: '编辑 Godot 起步' }));
expect(
screen.getByRole('img', { name: '模板封面预览' }).getAttribute('src'),
).toBe(template.coverUrl);
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2);
});
test.each([
['bad.svg', 'image/svg+xml', 1, '封面仅支持 PNG、JPEG 或 WebP'],
[
'large.png',
'image/png',
5 * 1024 * 1024 + 1,
'封面文件须大于 0 且不超过 5 MiB',
],
])('拒绝无效封面 %s,不上传', async (name, type, size, message) => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const editor = await openEditor();
const file = new File(['x'], name, { type });
Object.defineProperty(file, 'size', { value: size });
fireEvent.change(within(editor).getByLabelText('更换封面'), {
target: { files: [file] },
});
expect(screen.getByText(message)).not.toBeNull();
expect(
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(URL.createObjectURL).not.toHaveBeenCalled();
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
});
test('名称和标签校验失败时不进入写确认', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const editor = await openEditor();
fireEvent.change(within(editor).getByLabelText('名称'), {
target: { value: ' ' },
});
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
expect(screen.getByText('名称须为 180 个字符')).not.toBeNull();
fireEvent.change(within(editor).getByLabelText('名称'), {
target: { value: '有效名称' },
});
fireEvent.change(within(editor).getByLabelText('标签'), {
target: {
value: Array.from({ length: 17 }, (_, index) => `标签${index}`).join(','),
},
});
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
expect(
screen.getByText('最多 16 个标签,每个标签不超过 32 个字符'),
).not.toBeNull();
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
});
test('读取失败显示真实错误,401 交给会话处理且不显示空库', async () => {
const onUnauthorized = vi.fn();
vi.mocked(getAdminAgcTemplates).mockRejectedValueOnce(
new AdminApiError({ status: 503, message: '模板服务不可用' }),
);
render(
<AdminAgcTemplatesPage token="token" onUnauthorized={onUnauthorized} />,
);
await screen.findByText('模板服务不可用');
expect(screen.queryByText('暂无模板')).toBeNull();
vi.mocked(getAdminAgcTemplates).mockRejectedValueOnce(
new AdminApiError({ status: 401, message: '会话失效' }),
);
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await waitFor(() =>
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
});
File diff suppressed because it is too large Load Diff
-81
View File
@@ -13,87 +13,6 @@
text-rendering: optimizeLegibility;
}
.admin-agc-templates {
min-width: 0;
}
.admin-agc-template-filters {
display: grid;
grid-template-columns: minmax(180px, 1fr) repeat(2, minmax(130px, 190px));
gap: 14px;
}
.admin-agc-template-table {
min-width: 850px;
}
.admin-agc-template-table td:nth-child(2) {
min-width: 230px;
max-width: 380px;
}
.admin-agc-template-cover {
display: block;
width: 88px;
height: 62px;
border-radius: 8px;
object-fit: cover;
background: #f8efe7;
}
.admin-agc-template-summary {
display: -webkit-box;
overflow: hidden;
margin: 8px 0;
color: #866954;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.admin-agc-template-tags,
.admin-agc-template-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.admin-agc-template-tags span {
padding: 3px 7px;
border-radius: 5px;
background: #f8efe7;
font-size: 12px;
}
.admin-agc-template-dialog {
border-radius: 10px;
}
.admin-agc-template-editor,
.admin-agc-template-conflict {
display: grid;
gap: 14px;
}
.admin-agc-template-cover-preview {
display: block;
width: min(100%, 300px);
max-height: 180px;
border-radius: 8px;
object-fit: contain;
background: #f8efe7;
}
.admin-agc-template-dialog .admin-confirm-backdrop {
z-index: 1100;
}
@media (max-width: 680px) {
.admin-agc-template-filters {
grid-template-columns: minmax(0, 1fr);
}
}
* {
box-sizing: border-box;
}
@@ -9,7 +9,6 @@ import { fileURLToPath } from 'node:url';
import {
generateUpdateManifest,
prepareReleaseVersion,
resolveManifestPlatformKeys,
resolveReleaseContext,
resolveReleasePartition,
runTauriBuild,
@@ -21,13 +20,10 @@ import {
} from './verify-updater-signature.mjs';
/**
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 arm64 单架构包 → arm64 隔离 smoke → 生成 arm64 DMG
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 universal 包 → 双架构 smoke → 生成 universal DMG
* → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。
*
* 边界:
* - 只出 Apple Siliconarm64)单架构:清单只登记 `darwin-aarch64`。Intel 侧要可用,前提是随包 Node
* 也能按架构各带一份(`stage-node-runtime.mjs` 对 universal 目标失败关闭);在实现之前**不得**
* 把 arm64 产物登记成 `darwin-x86_64`,否则 Intel 客户端会装到跑不起来的包。
* - Apple 签名与公证暂缺:本入口剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,但**不能传
* `--no-sign`** —— 该标志同时会跳过 updater 的 minisign 签名,产物就没有 `.sig`
* 未签名 + 未公证必须显式记录而非静默通过;
@@ -98,14 +94,11 @@ process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
const dryRun = readReleaseDryRun();
process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
// 单架构目标:清单侧 `resolveManifestPlatformKeys` 只为它登记 darwin-aarch64。
const macTarget = 'aarch64-apple-darwin';
const context = resolveReleaseContext([`--target=${macTarget}`]);
const context = resolveReleaseContext(['--target=universal-apple-darwin']);
const partition = resolveReleasePartition(context.channel, context.target);
const version = await prepareReleaseVersion(context);
// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
const firstInstallName = `${productName}_${version}_aarch64.dmg`;
// 首装包名必须保持 `<产品名>_<版本>_universal.dmg`:清单侧按该后缀唯一匹配本次产物。
const firstInstallName = `${productName}_${version}_universal.dmg`;
// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则
// 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中);
@@ -124,7 +117,7 @@ for (const stale of [
}
const args = [
`--target=${macTarget}`,
'--target=universal-apple-darwin',
'--bundles',
'app',
'--ci',
@@ -139,13 +132,16 @@ const command = (binary, argv, options = {}) =>
runTauriBuild(args, context);
const app = path.join(context.bundleRoot, 'macos', appBundleName);
command(process.execPath, [
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
app,
'arm64',
]);
for (const architecture of ['arm64', 'x86_64']) {
command(process.execPath, [
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
app,
architecture,
'--universal',
]);
}
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_aarch64.dmg`。
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_universal.dmg`。
const dmgDirectory = path.join(context.bundleRoot, 'macos');
fs.mkdirSync(dmgDirectory, { recursive: true });
const dmg = path.join(dmgDirectory, firstInstallName);
@@ -174,7 +170,7 @@ const release = await generateUpdateManifest(context);
assert.equal(
path.resolve(release.downloadArtifact),
path.resolve(dmg),
'首装包必须锁定本次生成的 arm64 DMG',
'首装包必须锁定本次生成的 universal DMG',
);
// 上传前门禁:用产物里烘焙的公钥复核更新包签名。验不过就停在这里,绝不写 OSS。
@@ -270,9 +266,8 @@ fs.writeFileSync(
firstInstallSha256: dmgHash,
manifest: 'latest.json',
},
// 单架构发布:只跑 arm64 隔离 smokeIntel 未支持(清单里没有 darwin-x86_64 键)。
smokes: ['arm64'],
manifestPlatformKeys: resolveManifestPlatformKeys(context.target),
smokes: ['arm64', 'x86_64'],
intelSmoke: process.arch === 'arm64' ? 'Rosetta' : 'native',
},
null,
2,
@@ -13,7 +13,7 @@ import {
defaultEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
import { stageNodeRuntime } from './stage-node-runtime.mjs';
import { stageNodeRuntimeForTarget } from './stage-node-runtime.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
@@ -459,7 +459,8 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
export function runTauriBuild(
args = [],
context = resolveReleaseContext(args),
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
// 默认按发布目标 stage:universal 需要两份架构运行时,单架构目标行为不变。
{ spawn = spawnSync, stageRuntime = stageNodeRuntimeForTarget } = {},
) {
if (
explicitBuildTarget(args) &&
@@ -76,6 +76,33 @@ function run(command, args) {
return result;
}
/**
* 单独执行随包 Node 分片:分片架构与宿主一致时直接跑,不一致时用 `arch` 强制
* arm64 机器上的 x86_64 分片依赖 Rosetta,与 `.app` 双架构 smoke 的前提相同)。
*/
function runNodeSlice(directory, args) {
const native = process.arch === 'arm64' ? 'arm64' : 'x86_64';
const binary = path.join(directory, 'node');
const [command, argv] =
architecture === native
? [binary, args]
: ['/usr/bin/arch', [`-${architecture}`, binary, ...args]];
const result = spawnSync(command, argv, {
cwd: root,
env,
encoding: 'utf8',
timeout: 120_000,
maxBuffer: 1024 * 1024,
});
assert.ifError(result.error);
assert.equal(
result.status,
0,
`${directory} 随包 Node 执行失败:${result.stderr || result.stdout}`,
);
return result.stdout.trim();
}
/**
* APFS 上优先用 `ditto --clone`:整包按区块克隆,秒级完成且几乎不占额外空间。
* 跨卷或非 APFS 时回退到真实复制;两种路径都必须产出可独立改动的副本,
@@ -248,37 +275,84 @@ try {
}
}
assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md')));
// 随包 Node:单架构构建是扁平目录,universal 构建把两套架构运行时并列放进
// `game-runtime/node/<platform>-<arch>/`(与 Codex 侧车同形态,由 Rust 侧按运行架构选择)。
// 两套清单在本函数里都做完整性与架构校验;只执行与本次 smoke 架构一致的那一份,
// 另一份由另一次架构的 smoke 覆盖(build-macos-ci.mjs 会对 arm64 / x86_64 各跑一次)。
const nodeRoot = path.join(resources, 'game-runtime/node');
const nodeManifest = JSON.parse(
fs.readFileSync(path.join(nodeRoot, 'manifest.json'), 'utf8'),
);
assert.equal(nodeManifest.schemaVersion, 'agc-node-runtime.v1');
assert.equal(nodeManifest.platform, 'darwin');
assert.equal(nodeManifest.arch, process.arch);
const runtimeFiles = fs
.readdirSync(nodeRoot, { recursive: true })
.filter(
(file) =>
fs.statSync(path.join(nodeRoot, file)).isFile() &&
file !== 'manifest.json',
const nodeSlices = requireUniversal
? ['darwin-arm64', 'darwin-x64'].map((platform) => ({
platform,
directory: path.join(nodeRoot, platform),
}))
: [{ platform: null, directory: nodeRoot }];
for (const slice of nodeSlices) {
const { directory } = slice;
const nodeManifest = JSON.parse(
fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'),
);
assert.deepEqual(runtimeFiles.sort(), Object.keys(nodeManifest.files).sort());
for (const [file, digest] of Object.entries(nodeManifest.files)) {
assert.equal(await hashFile(path.join(nodeRoot, file)), digest, file);
assert.equal(nodeManifest.schemaVersion, 'agc-node-runtime.v1');
assert.equal(nodeManifest.platform, 'darwin');
if (slice.platform) {
// 目录名与清单架构必须一致:错位会让用户拿到跑不起来的运行时。
assert.equal(`darwin-${nodeManifest.arch}`, slice.platform);
assert.ok(
/^(arm64|x64)$/u.test(nodeManifest.arch),
`未知运行架构:${nodeManifest.arch}`,
);
} else {
assert.equal(nodeManifest.arch, process.arch);
}
const runtimeFiles = fs
.readdirSync(directory, { recursive: true })
.filter(
(file) =>
fs.statSync(path.join(directory, file)).isFile() &&
file !== 'manifest.json',
);
assert.deepEqual(
runtimeFiles.sort(),
Object.keys(nodeManifest.files).sort(),
slice.platform ?? 'flat',
);
for (const [file, digest] of Object.entries(nodeManifest.files)) {
assert.equal(await hashFile(path.join(directory, file)), digest, file);
}
assert.ok(nodeManifest.files['NODE-LICENSE']);
assert.ok(nodeManifest.files['node_modules/npm/LICENSE']);
fs.accessSync(path.join(directory, 'node'), fs.constants.X_OK);
// 二进制本身必须是本分片的单一架构:官方发行版不做 universal,lipo 能直接证明。
const sliceArchitecture = spawnSync(
'/usr/bin/lipo',
['-archs', path.join(directory, 'node')],
{ encoding: 'utf8' },
);
assert.equal(sliceArchitecture.status, 0, 'lipo -archs node');
assert.equal(
sliceArchitecture.stdout.trim(),
nodeManifest.arch === 'x64' ? 'x86_64' : 'arm64',
'随包 Node 的二进制架构必须等于清单架构',
);
// 只有与本次 smoke 架构一致的分片才执行;另一架构留给对应的那次 smoke。
if (
!requireUniversal ||
nodeManifest.arch === (architecture === 'arm64' ? 'arm64' : 'x64')
) {
assert.equal(
runNodeSlice(directory, ['--version']),
nodeManifest.nodeVersion,
`${slice.platform ?? 'flat'} node --version`,
);
assert.equal(
runNodeSlice(directory, [
path.join(directory, 'node_modules/npm/bin/npm-cli.js'),
'--version',
]),
nodeManifest.npmVersion,
`${slice.platform ?? 'flat'} npm --version`,
);
}
}
assert.ok(nodeManifest.files['NODE-LICENSE']);
assert.ok(nodeManifest.files['node_modules/npm/LICENSE']);
assert.equal(
run(path.join(nodeRoot, 'node'), ['--version']).stdout.trim(),
nodeManifest.nodeVersion,
);
assert.equal(
run(path.join(nodeRoot, 'node'), [
path.join(nodeRoot, 'node_modules/npm/bin/npm-cli.js'),
'--version',
]).stdout.trim(),
nodeManifest.npmVersion,
);
const plugin = path.join(resources, 'plugins/agc-cocos-editor');
for (const file of [
'plugin.json',
@@ -287,13 +361,6 @@ try {
]) {
assert.ok(fs.existsSync(path.join(plugin, file)), file);
}
// 随包 Node 的 npm 是包里唯一允许出现的 node_modules:除了 npm 目录自身与它的子项,
// 还要放行它的上级目录 `game-runtime/node/node_modules`recursive readdir 会列出目录项,
// 少了这一条会让整个门禁对合法包失败——#439 引入后一直没被跑到,直到 2026-09-21 才暴露)。
const allowedNodeModules = (file) =>
file === 'game-runtime/node/node_modules' ||
file === 'game-runtime/node/node_modules/npm' ||
file.startsWith('game-runtime/node/node_modules/npm/');
const packageFiles = fs.readdirSync(resources, { recursive: true });
assert.ok(
!packageFiles.some(
@@ -301,7 +368,13 @@ try {
/(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test(
file,
) ||
(/(^|\/)node_modules(\/|$)/.test(file) && !allowedNodeModules(file)),
// 只有随包 Node 自带的 npm 允许出现 node_modules;两种布局都要放行:
// 单架构的 `game-runtime/node/node_modules/npm` 与 universal 的
// `game-runtime/node/<platform>-<arch>/node_modules/npm`。
(/(^|\/)node_modules(\/|$)/.test(file) &&
!/^game-runtime\/node\/((darwin-(arm64|x64))\/)?node_modules\/npm(\/|$)/u.test(
file,
)),
),
);
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
@@ -178,12 +178,8 @@ test('macOS release entry and smoke script derive product names from config and
assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名');
assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名');
assert.ok(
entry.includes("const macTarget = 'aarch64-apple-darwin'"),
'macOS 发布入口必须固定单架构目标',
);
assert.ok(
entry.includes('_${version}_aarch64.dmg'),
'首装包名必须保留清单侧单架构分支唯一匹配所需的后缀(Tauri 口径 aarch64',
entry.includes('_${version}_universal.dmg'),
'首装包名必须保留清单侧唯一匹配所需的后缀',
);
const smoke = fs.readFileSync(
@@ -6,6 +6,13 @@ import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
export const nodeRuntimeSchema = 'agc-node-runtime.v1';
// 单架构目标写扁平目录;universal 在此目录下按架构分目录,见 stageNodeRuntimeForTarget。
const defaultRuntimeDestination = path.join(
appRoot,
'src-tauri',
'resources',
'node-runtime',
);
export function readInstalledNodeLicense(
version,
@@ -65,22 +72,119 @@ export function targetRuntime(target) {
'aarch64-apple-darwin': ['darwin', 'arm64'],
'x86_64-apple-darwin': ['darwin', 'x64'],
};
// 随包 Node 是**单架构**官方发行版:一份运行时只服务它自己的架构。
// macOS 发布当前固定为 aarch64-apple-darwin 单架构包(Intel 未支持),
// universal 目标没有正确的运行时来源,必须失败关闭——绝不能退化成
// 「按宿主架构暂存一份 arm64」:那样通用包自检(按 process.arch)能过,
// 但 Intel 机器上这份运行时不可执行,用户拿到的是坏包。
if (target === 'universal-apple-darwin')
throw new Error(
'Node 运行时不支持 universal-apple-darwin:随包 Node 只有单架构发行版,' +
'通用包需按架构各带一份(另行下载另一架构官方发行版)之后才能构建;' +
'当前 macOS 发布固定为 aarch64-apple-darwin 单架构',
);
const value = targets[target];
if (!value) throw new Error(`Node 运行时不支持发布目标:${target}`);
// universal 不是单份运行时能表达的目标:它必须按架构展开成两份,见 targetRuntimes。
if (!value)
throw new Error(
target === 'universal-apple-darwin'
? 'Node 运行时不能直接按 universal-apple-darwin 制作:双架构请用 stageNodeRuntimeForTarget 按架构展开'
: `Node 运行时不支持发布目标:${target}`,
);
return { platform: value[0], arch: value[1] };
}
/**
* 发布目标需要的随包运行时列表:universal 需要两份单架构运行时,
* 其余目标仍然是一份(保持既有扁平布局与行为)。
*/
export function targetRuntimes(target) {
if (target === 'universal-apple-darwin')
return ['aarch64-apple-darwin', 'x86_64-apple-darwin'];
return [target];
}
/**
* 按架构选择 staging 输入。
*
* 契约(见实施计划):发布包只从**本机已安装且与目标平台/架构一致**的工具链取材,
* 不得使用项目内或相对 PATH 的伪造运行时。宿主架构直接复用当前 Node;
* 其它架构必须由构建节点显式提供,缺失即失败关闭——不静默跳过、不回退系统 Node。
*/
export function runtimeSourceForTarget(
archTarget,
{
env = process.env,
nodePath,
npmCli,
licensePath,
hostNodePath = process.execPath,
hostNpmCli = process.env.npm_execpath,
hostLicensePath = env.AGC_NODE_LICENSE_PATH,
hostPlatform = process.platform,
hostArch = process.arch,
} = {},
) {
const native = targetRuntime(archTarget);
if (native.platform === hostPlatform && native.arch === hostArch) {
return {
nodePath: nodePath ?? hostNodePath,
npmCli: npmCli ?? hostNpmCli,
licensePath: licensePath ?? hostLicensePath,
};
}
const key = `${native.platform}_${native.arch}`.toUpperCase();
const configured = env[`AGC_NODE_RUNTIME_${key}_PATH`]?.trim();
if (!configured) {
throw new Error(
`缺少 ${native.platform}/${native.arch} 的 Node 运行时:该架构不是构建宿主,` +
`请先在本机安装同架构 Node,再用 AGC_NODE_RUNTIME_${key}_PATH 指向其 bin/node`,
);
}
return {
nodePath: configured,
// 明令不使用宿主 npm`npm_execpath`):另一架构的 npm 必须来自它自己那份发行版
// 安装目录,否则两份切片的 npm 来源不同源,架构与来源对不上。null 表示"按 node 目录查找"。
npmCli: null,
licensePath: env[`AGC_NODE_RUNTIME_${key}_LICENSE_PATH`]?.trim(),
};
}
/**
* 按发布目标 stage 运行时资源。
*
* 单架构目标沿用既有扁平目录(`node-runtime/`),universal 目标写进
* `node-runtime/<platform>-<arch>/`——与捆绑 Codex 的分架构目录同一形态,
* 由 Rust 侧按当前运行架构选择;构建期资源映射仍是整目录映射,无需按架构分叉。
*
* 先解析并校验**全部**来源,再逐个落盘:非宿主架构的运行时缺失、平台/架构不符、
* 两套版本不一致,都在写出任何运行时目录之前失败,不留下半套资源冒充发布内容。
*/
export function stageNodeRuntimeForTarget(
target,
{ destination = defaultRuntimeDestination, ...options } = {},
) {
const targets = targetRuntimes(target);
const plans = targets.map((archTarget) => {
const native = targetRuntime(archTarget);
return {
destination:
targets.length === 1
? destination
: path.join(destination, `${native.platform}-${native.arch}`),
inspected: inspectRuntimeSource(archTarget, {
...options,
...runtimeSourceForTarget(archTarget, options),
}),
};
});
// 两个切片必须是同一套 Node:版本不一致意味着其中一份被换过,
// 用户在不同架构上会拿到行为不同的工具链。
const identities = new Set(
plans.map(
({ inspected }) =>
`${inspected.info.version}|${inspected.npmPackage.version}`,
),
);
if (identities.size !== 1) {
throw new Error(
`多架构 Node 运行时版本不一致:${[...identities].join(' / ')}`,
);
}
return plans.map(({ inspected, destination }) =>
writeRuntimeBundle(inspected, { destination }),
);
}
function inside(root, file) {
const relative = path.relative(root, file);
return (
@@ -223,13 +327,19 @@ export function assertPortableMacNode(output) {
}
}
export function stageNodeRuntime(
/**
* 只读校验一份运行时来源,返回 staging 需要的全部事实。
*
* 与写盘分离的原因:多架构发布必须能在写出任何文件之前发现「另一份来源缺失或
* 与目标不符」。校验口径保持原样——平台/架构必须等于目标、macOS 二进制只能链接
* 系统动态库、npm 的身份与实际版本必须一致、许可必须来自发行版本体。
*/
export function inspectRuntimeSource(
target,
{
nodePath = process.execPath,
npmCli = process.env.npm_execpath,
licensePath = process.env.AGC_NODE_LICENSE_PATH,
destination = path.join(appRoot, 'src-tauri', 'resources', 'node-runtime'),
execute = execFileSync,
installedLicense = readInstalledNodeLicense,
} = {},
@@ -264,6 +374,7 @@ export function stageNodeRuntime(
);
}
const nodeDirectory = path.dirname(node);
// `npmCli: null` 表示显式拒绝沿用宿主 npm,只按这份 node 自己的安装目录查找。
const npmCandidates = [
npmCli,
path.join(nodeDirectory, 'node_modules/npm/bin/npm-cli.js'),
@@ -320,6 +431,14 @@ export function stageNodeRuntime(
throw new Error('Node LICENSE 不包含发行许可');
if (!fs.statSync(path.join(npmRoot, 'LICENSE')).isFile())
throw new Error('npm 缺少 LICENSE');
return { native, node, npmRoot, npmPackage, info, license, licenseName };
}
/** 把已校验的来源写成一份发布资源,返回清单。 */
function writeRuntimeBundle(
{ native, node, npmRoot, npmPackage, info, license, licenseName },
{ destination },
) {
// 临时同级目录完成后才替换资源;不污染 Node 安装或项目工作区。
const requestedDestination = path.resolve(destination);
if (requestedDestination === path.dirname(requestedDestination))
@@ -405,3 +524,26 @@ export function stageNodeRuntime(
cleanupStaging(staging, parent, stagingPrefix, stagingIdentity);
}
}
export function stageNodeRuntime(
target,
{
nodePath = process.execPath,
npmCli = process.env.npm_execpath,
licensePath = process.env.AGC_NODE_LICENSE_PATH,
destination = defaultRuntimeDestination,
execute = execFileSync,
installedLicense = readInstalledNodeLicense,
} = {},
) {
return writeRuntimeBundle(
inspectRuntimeSource(target, {
nodePath,
npmCli,
licensePath,
execute,
installedLicense,
}),
{ destination },
);
}
@@ -9,7 +9,9 @@ import {
assertPortableMacNode,
readInstalledNodeLicense,
stageNodeRuntime,
stageNodeRuntimeForTarget,
targetRuntime,
targetRuntimes,
} from './stage-node-runtime.mjs';
function fixture(run) {
@@ -281,10 +283,10 @@ test('native target and macOS dynamic dependency policy reject nonportable Node'
platform: 'darwin',
arch: 'arm64',
});
// universal 必须失败关闭:只带宿主架构那一份运行时,Intel 上不可执行
// universal 不是单份运行时目标:必须报出「按架构展开」而不是笼统的「不支持」
assert.throws(
() => targetRuntime('universal-apple-darwin'),
/universal-apple-darwin/u,
/stageNodeRuntimeForTarget/u,
);
assertPortableMacNode(
'/node:\n\t/usr/lib/libSystem.B.dylib (compatibility version 1)\n',
@@ -297,3 +299,164 @@ test('native target and macOS dynamic dependency policy reject nonportable Node'
/非系统动态库/u,
);
});
// 双架构夹具:每个架构一份来源目录,execute 按被查询的二进制回报对应架构。
function universalFixture(run) {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), 'agc-node-universal-test-'),
);
const sourceFor = (arch, nodeVersion) => {
const dir = path.join(root, `source-${arch}`);
const npm = path.join(dir, 'node_modules/npm');
fs.mkdirSync(path.join(npm, 'bin'), { recursive: true });
fs.writeFileSync(path.join(dir, 'node'), `node-${arch}`);
fs.writeFileSync(
path.join(dir, 'LICENSE'),
'Node.js\nPermission is hereby granted',
);
fs.writeFileSync(path.join(npm, 'LICENSE'), 'npm distribution license');
fs.writeFileSync(
path.join(npm, 'package.json'),
JSON.stringify({ name: 'npm', version: '11.0.0' }),
);
for (const name of ['npm', 'npx'])
fs.writeFileSync(path.join(npm, `bin/${name}-cli.js`), `// ${arch}`);
return {
nodePath: path.join(dir, 'node'),
npmCli: path.join(npm, 'bin/npm-cli.js'),
licensePath: path.join(dir, 'LICENSE'),
nodeVersion,
};
};
try {
return run(root, {
arm64: sourceFor('arm64', 'v22.23.2'),
x64: sourceFor('x64', 'v22.23.2'),
});
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
function optionsFor(root, sources, overrides = {}) {
const archOf = (file) => (file.includes('source-x64') ? 'x64' : 'arm64');
return {
destination: path.join(root, 'resources/node-runtime'),
hostPlatform: 'darwin',
hostArch: 'arm64',
hostNodePath: sources.arm64.nodePath,
hostNpmCli: sources.arm64.npmCli,
env: {
AGC_NODE_RUNTIME_DARWIN_X64_PATH: sources.x64.nodePath,
AGC_NODE_RUNTIME_DARWIN_X64_LICENSE_PATH: sources.x64.licensePath,
},
installedLicense() {
throw new Error('no installed fixture license');
},
execute(file, args) {
if (file === '/usr/bin/otool')
return `\t/usr/lib/libSystem.B.dylib\n\t/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation\n`;
if (args[0] === '-p') {
const arch = archOf(file);
return JSON.stringify({
platform: 'darwin',
arch,
version: sources[arch].nodeVersion,
});
}
return '11.0.0';
},
...overrides,
};
}
test('universal stages one runtime per architecture with architecture-correct manifests', () =>
universalFixture((root, sources) => {
assert.deepEqual(targetRuntimes('universal-apple-darwin'), [
'aarch64-apple-darwin',
'x86_64-apple-darwin',
]);
// 模拟经 `npm run` 触发的构建:npm 把宿主的 npm-cli.js 放进环境变量。
// 另一架构的切片必须无视它,只能使用自己发行版目录里的 npm。
const previousNpmExecPath = process.env.npm_execpath;
process.env.npm_execpath = sources.arm64.npmCli;
let manifests;
try {
manifests = stageNodeRuntimeForTarget(
'universal-apple-darwin',
optionsFor(root, sources),
);
} finally {
if (previousNpmExecPath === undefined) delete process.env.npm_execpath;
else process.env.npm_execpath = previousNpmExecPath;
}
assert.deepEqual(
manifests.map((manifest) => `${manifest.platform}-${manifest.arch}`),
['darwin-arm64', 'darwin-x64'],
);
for (const manifest of manifests) {
const directory = path.join(
root,
'resources/node-runtime',
`${manifest.platform}-${manifest.arch}`,
);
assert.equal(
JSON.parse(
fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'),
).arch,
manifest.arch,
);
assert.ok(fs.existsSync(path.join(directory, 'node')));
assert.ok(
fs.existsSync(path.join(directory, 'node_modules/npm/LICENSE')),
);
// 每份切片必须自带对应架构发行版的 npm,不能借用宿主那一份。
assert.equal(
fs.readFileSync(
path.join(directory, 'node_modules/npm/bin/npm-cli.js'),
'utf8',
),
`// ${manifest.arch}`,
);
}
// 单架构目标仍写扁平目录(与既有发布一致),不产生分架构子目录。
const flat = stageNodeRuntimeForTarget(
'aarch64-apple-darwin',
optionsFor(root, sources, { destination: path.join(root, 'flat') }),
);
assert.equal(flat.length, 1);
assert.ok(fs.existsSync(path.join(root, 'flat/manifest.json')));
assert.ok(!fs.existsSync(path.join(root, 'flat/darwin-arm64')));
}));
test('universal fails closed when the non-host architecture runtime is absent', () =>
universalFixture((root, sources) => {
const options = optionsFor(root, sources, { env: {} });
assert.throws(
() => stageNodeRuntimeForTarget('universal-apple-darwin', options),
/AGC_NODE_RUNTIME_DARWIN_X64_PATH/u,
);
// 缺失时不得留下半成品目录。
assert.ok(!fs.existsSync(path.join(root, 'resources')));
}));
test('universal rejects mismatched Node versions between the two architectures', () =>
universalFixture((root, sources) => {
const options = optionsFor(root, sources);
options.execute = (file, args) => {
if (file === '/usr/bin/otool') return '\t/usr/lib/libSystem.B.dylib\n';
if (args[0] === '-p')
return JSON.stringify({
platform: 'darwin',
arch: file.includes('source-x64') ? 'x64' : 'arm64',
version: file.includes('source-x64') ? 'v22.23.2' : 'v24.0.0',
});
return '11.0.0';
};
assert.throws(
() => stageNodeRuntimeForTarget('universal-apple-darwin', options),
/版本不一致/u,
);
// 版本核对在写出任何架构之前完成,失败时不留下半套运行时。
assert.ok(!fs.existsSync(path.join(root, 'resources')));
}));
@@ -1006,58 +1006,16 @@ pub(crate) async fn external_editor_json_request(
let response = crate::http_client::with_agc_main_site_marker(request)
.send()
.await
.map_err(|error| {
format!(
"{action}失败:{}",
describe_external_request_failure(&error)
)
})?;
.map_err(|error| format!("{action}失败:{error}"))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format_external_http_error(action, status, &body));
}
response.json::<serde_json::Value>().await.map_err(|error| {
format!(
"解析{action}响应失败:{}",
describe_external_request_failure(&error)
)
})
}
/// 把 reqwest 失败翻译成可现场定责的文案。
///
/// `reqwest::Error` 的 `Display` 只输出 kind:客户端预算内没读完正文(总超时)、
/// 正文被提前截断和正文不是合法 JSON 都会显示成同一句 `error decoding response body`
/// 现场无法区分是平台慢、链接慢还是响应被截断。这里补上 kind 语义与底层因链;
/// 因链只取错误文本,不拼接 URL,避免把绝对地址写进日志。
fn describe_external_request_failure(error: &reqwest::Error) -> String {
let kind = if error.is_timeout() {
"请求超时(连接、响应头或响应正文未在客户端预算内完成)"
} else if error.is_decode() {
"响应正文未完整返回或不是合法 JSON"
} else if error.is_body() {
"响应正文读取失败"
} else if error.is_connect() {
"连接失败"
} else if error.is_request() {
"请求发送失败"
} else {
"请求失败"
};
let mut causes: Vec<String> = Vec::new();
let mut source = std::error::Error::source(error);
while let Some(current) = source {
let text = current.to_string();
if !text.is_empty() && !causes.contains(&text) {
causes.push(text);
}
source = current.source();
}
if causes.is_empty() {
return kind.to_string();
}
format!("{kind}{}", causes.join(""))
response
.json::<serde_json::Value>()
.await
.map_err(|error| format!("解析{action}响应失败:{error}"))
}
/// Keep provider validation details useful to the operator without copying an
@@ -1485,14 +1443,10 @@ pub(crate) async fn prepare_external_canvas_generation_context(
let project_id = if let Some(project_id) = partial.remote_project_id.clone() {
project_id
} else {
// 这一步只需要按 projectId 确认绑定项目是否仍然存在,固定用摘要视图:
// 缺省 full 会把账号下每个项目的画布与全量资源都带回来,项目增长后会耗尽本次请求的
// 客户端预算,现场表现为「解析读取外部画布项目响应失败:error decoding response body」。
// 站内 `/api/editor/projects` 与 `/api/external/v1/editor/projects` 都支持该视图。
let projects_payload = external_editor_json_request(
client
.get(format!(
"{}{}?view=summary",
"{}{}",
access.api_base_url(),
access.api_route("/api/external/v1/editor/projects")
))
@@ -8581,28 +8535,6 @@ mod canvas_generation_tests {
);
}
/// reqwest 的 `Display` 只给 kind,超时 / 正文截断 / 非法 JSON 全都是同一句
/// `error decoding response body`。这个用例钉住「至少把 kind 语义换成人话」,
/// 避免再退回无法定责的原始文案。
#[tokio::test]
async fn external_request_failure_replaces_the_opaque_reqwest_kind() {
let error = reqwest::Client::new()
.get("http://127.0.0.1:1/healthz")
.timeout(Duration::from_secs(2))
.send()
.await
.expect_err("closed port must fail");
let described = describe_external_request_failure(&error);
// 该端口在真实环境里可能被直接拒绝、也可能被中间层吞掉直到超时,两种都必须能定责。
assert!(
described.contains("连接失败") || described.contains("请求超时"),
"{described}"
);
assert!(described.contains(""), "必须补上底层因链:{described}");
assert!(!described.contains("error sending request"), "{described}");
assert!(!described.contains("http://127.0.0.1:1"), "{described}");
}
fn read_test_http_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_nonblocking(false)
@@ -8911,8 +8843,7 @@ mod canvas_generation_tests {
}),
);
false
} else if request.starts_with("GET /api/external/v1/editor/projects?view=summary ")
{
} else if request.starts_with("GET /api/external/v1/editor/projects ") {
write_test_json_response(
&mut stream,
"200 OK",
@@ -9282,7 +9213,7 @@ mod canvas_generation_tests {
.expect("write concurrent generation png body");
return;
}
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
if request.starts_with("GET /api/external/v1/editor/projects ") {
// 项目绑定已由 `install_test_external_project_binding` 预置,远端只需回认同一组身份。
write_test_json_response(
stream,
@@ -9756,7 +9687,7 @@ mod canvas_generation_tests {
"Bearer token-b" => "b",
unexpected => panic!("unexpected authorization {unexpected}"),
};
let response = if request.starts_with("GET /api/editor/projects?view=summary ") {
let response = if request.starts_with("GET /api/editor/projects ") {
let projects = if project_created.get(account).copied().unwrap_or(false) {
vec![serde_json::json!({
"projectId": format!("remote-project-{account}"),
@@ -9922,7 +9853,7 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture concurrent binding request");
let response = if request.starts_with("GET /api/editor/projects?view=summary ") {
let response = if request.starts_with("GET /api/editor/projects ") {
let (state_lock, ready) = &*state;
let mut state = state_lock.lock().expect("lock project fixture state");
if !state.project_created {
@@ -10057,7 +9988,7 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture project partial request");
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
if request.starts_with("GET /api/external/v1/editor/projects ") {
write_test_json_response(
&mut stream,
"200 OK",
@@ -10191,7 +10122,7 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture frozen binding request");
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
if request.starts_with("GET /api/external/v1/editor/projects ") {
write_test_json_response(
&mut stream,
"200 OK",
@@ -10316,24 +10247,23 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture folder partial request");
let response =
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
serde_json::json!({"data": {"projects": []}})
} else if request.starts_with("POST /api/external/v1/editor/projects ") {
serde_json::json!({"data": {"project": {
"projectId": "folder-partial-project"
}}})
} else if request.starts_with("GET /api/external/v1/editor/assets/library ") {
serde_json::json!({"data": {"library": {"folders": []}}})
} else if request.starts_with("POST /api/external/v1/editor/assets/folders ") {
fs::create_dir_all(&blocked_binding_path)
.expect("block final binding write after folder creation");
serde_json::json!({"data": {"folder": {
"folderId": "folder-partial-folder"
}}})
} else {
panic!("unexpected folder partial request: {request}");
};
let response = if request.starts_with("GET /api/external/v1/editor/projects ") {
serde_json::json!({"data": {"projects": []}})
} else if request.starts_with("POST /api/external/v1/editor/projects ") {
serde_json::json!({"data": {"project": {
"projectId": "folder-partial-project"
}}})
} else if request.starts_with("GET /api/external/v1/editor/assets/library ") {
serde_json::json!({"data": {"library": {"folders": []}}})
} else if request.starts_with("POST /api/external/v1/editor/assets/folders ") {
fs::create_dir_all(&blocked_binding_path)
.expect("block final binding write after folder creation");
serde_json::json!({"data": {"folder": {
"folderId": "folder-partial-folder"
}}})
} else {
panic!("unexpected folder partial request: {request}");
};
write_test_json_response(&mut stream, "200 OK", &response);
}
});
@@ -2,8 +2,7 @@
//!
//! Every caller supplies a safe public summary and a private detail. This
//! module is the only persistence boundary for the latter: it redacts project
//! paths and credentials before writing a bounded diagnostic sidecar, and
//! projects the same bounded diagnosis into the AppData application log.
//! paths and credentials before writing a bounded diagnostic sidecar.
use super::{redact_agent_runtime_error, write_agent_runtime_json_sidecar_with_max_bytes};
use serde::{Deserialize, Serialize};
@@ -15,14 +14,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) const AGENT_RUNTIME_ERROR_SCHEMA_VERSION: &str = "agent-runtime-error.v1";
pub(crate) const AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS: usize = 8 * 1024;
/// 应用日志里 detail / metadata 的字符预算。
///
/// `application.log` 的每一行在落盘前还会被 `sanitize_diagnostic_message` 截到 2 KiB
/// 这里的预算留出身份字段与中文摘要的位置,保证被截掉的是诊断正文的尾部,而不是
/// `eventId`、`code` 或 `detailRef`。
pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_DETAIL_CHARS: usize = 1_200;
pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS: usize = 200;
static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
@@ -80,27 +71,6 @@ pub(crate) fn persist_agent_runtime_error(
"detail": safe_detail,
"metadata": metadata,
});
// 统一错误事件的项目内 sidecar 只在项目目录可见:用户提交错误报告时上传的是 AppData
// 应用日志,诊断包拿不到 detail。这里先把同一份已脱敏诊断留进应用日志,再落项目文件,
// 于是 sidecar 写失败也仍然留下可提交的诊断。
let app_log_lines = agent_runtime_error_app_log_lines(
root,
&event_id,
client_turn_id,
source,
stage,
code,
retryable,
public_text,
recovery_hint,
&detail_ref,
&safe_detail,
elapsed_ms,
&metadata,
);
for line in app_log_lines {
app_log!("{line}");
}
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&detail_ref,
@@ -126,65 +96,6 @@ pub(crate) fn persist_agent_runtime_error(
})
}
/// 把统一错误事件投影成 AppData `diagnostics/application.log` 里的两行。
///
/// 传进来的 `detail` 已经是 sidecar 用的脱敏文本;这里只再按应用日志的预算截一次,
/// 让日志与项目内 sidecar 是同一份诊断,不再单独拼一套字段。
///
/// 拆成「身份行 + 详情行」是因为整行只要出现凭据标记就会被
/// [`crate::sanitize_diagnostic_message`] 整体替换成脱敏占位,详情行可能因此消失;
/// 身份行保持短小且只含摘要与引用,保证事件还能被定位。
///
/// 单行口径在这里落地:`app_log!` 同时把这行写到 stderr,那里没有人替我们压行,
/// 所以每个字段(含 `source` / `stage` / `code` / `detailRef` 这些调用方给的标识)都先
/// 过一遍 [`single_line_log_field`],不假设调用方一定给单行文本。
pub(crate) fn agent_runtime_error_app_log_lines(
root: &Path,
event_id: &str,
client_turn_id: Option<&str>,
source: &str,
stage: &str,
code: &str,
retryable: bool,
public_text: &str,
recovery_hint: &str,
detail_ref: &str,
detail: &str,
elapsed_ms: Option<u64>,
metadata: &Value,
) -> [String; 2] {
let event_id = single_line_log_field(event_id);
let client_turn_id = single_line_log_field(client_turn_id.unwrap_or("none"));
let source = single_line_log_field(source);
let stage = single_line_log_field(stage);
let code = single_line_log_field(code);
let detail_ref = single_line_log_field(detail_ref);
let public_text = single_line_log_field(public_text);
let elapsed_ms = elapsed_ms
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string());
let identity = format!(
"agent.runtime.error eventId={event_id} source={source} stage={stage} code={code} retryable={retryable} clientTurnId={client_turn_id} elapsedMs={elapsed_ms} detailRef={detail_ref} summary={public_text}"
);
let detail = redact_agent_runtime_error(root, detail, AGENT_RUNTIME_ERROR_APP_LOG_DETAIL_CHARS);
let metadata = redact_agent_runtime_error(
root,
&metadata.to_string(),
AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS,
);
let detail_line = format!(
"agent.runtime.error.detail eventId={event_id} hint={} detail={} metadata={metadata}",
single_line_log_field(recovery_hint),
single_line_log_field(&detail),
);
[identity, detail_line]
}
/// 应用日志是逐行读取的:落到日志里的自由文本必须先压平换行。
fn single_line_log_field(value: &str) -> String {
value.replace(['\r', '\n'], " ")
}
pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
let normalized = error.to_ascii_lowercase();
if normalized.contains("等待 turn/completed 超时") {
@@ -242,47 +153,6 @@ mod tests {
assert!(!text.contains("token=secret"));
}
#[test]
fn error_event_app_log_lines_keep_identity_and_redact_detail() {
let parent = tempfile::tempdir().expect("temp root");
let root = parent.path().join("project");
crate::project::init_local_game_project_at(&root, "runtime-error", "错误事件")
.expect("init project");
let lines = agent_runtime_error_app_log_lines(
&root,
"error-1-1",
Some("turn-123"),
"direct-codex",
"code-generation",
"turn-idle-timeout",
true,
"本轮没有收到完成事件\n附带换行",
"查看诊断后重试",
".agent/runtime/errors/error-1-1.json",
"C:\\Users\\private\\project https://provider.example/a?token=secret\n第二行诊断",
Some(1200),
&serde_json::json!({"authorization": "Bearer secret"}),
);
for line in &lines {
assert!(!line.contains('\n'), "{line}");
}
// 落盘边界按真实口径核验:应用日志的整行脱敏既不能吃掉身份字段,也不能靠换行拆行。
let persisted = crate::sanitize_diagnostic_message(&lines.join(" "), None);
assert!(persisted.contains("eventId=error-1-1"), "{persisted}");
assert!(persisted.contains("code=turn-idle-timeout"), "{persisted}");
assert!(
persisted.contains("detailRef=.agent/runtime/errors/error-1-1.json"),
"{persisted}"
);
assert!(persisted.contains("summary=本轮没有收到完成事件 附带换行"));
assert!(persisted.contains("detail=<absolute-path> <redacted-url> 第二行诊断"));
assert!(
!persisted.contains("token=secret") && !persisted.contains("Bearer secret"),
"{persisted}"
);
assert!(persisted.chars().count() <= 2_048, "{persisted}");
}
#[test]
fn timeout_and_tool_errors_have_distinct_codes() {
assert_eq!(
@@ -290,15 +290,37 @@ fn resolve_at(
let root = root
.canonicalize()
.map_err(|_| "project-root-unavailable")?;
if let Some(bundle) = bundle.filter(|bundle| bundle.exists()) {
validate_bundle(bundle)?;
return runtime_from_paths(
&root,
bundle.join(executable_name()),
bundle.join("node_modules/npm/bin/npm-cli.js"),
"bundled",
path,
);
// 候选顺序:既有扁平布局(单架构构建),其后是按**当前运行架构**命名的子目录
// universal 构建把两套运行时并列放进去)。两个候选都要过 manifest 的平台/架构
// 校验,因此顺序不会让另一架构的运行时被采用;存在但校验失败则失败关闭,
// 绝不因此退回系统 Node。
let mut bundle_error: Option<String> = None;
if let Some(base) = bundle {
let candidates = [
base.to_path_buf(),
base.join(format!("{}-{}", native_platform(), native_arch())),
];
for candidate in candidates.iter().filter(|candidate| candidate.exists()) {
match validate_bundle(candidate) {
Ok(()) => {
return runtime_from_paths(
&root,
candidate.join(executable_name()),
candidate.join("node_modules/npm/bin/npm-cli.js"),
"bundled",
path,
);
}
Err(error) => {
if bundle_error.is_none() {
bundle_error = Some(error);
}
}
}
}
}
if let Some(error) = bundle_error {
return Err(error);
}
if !development {
return Err("node-runtime-bundle-missing".into());
@@ -595,6 +617,52 @@ mod tests {
);
}
/// universal 构建把两套架构运行时并列放在 `<base>/<platform>-<arch>/`
/// 解析必须按当前运行架构选中自己那一份,且另一架构的存在与否不影响结果。
#[test]
fn universal_bundle_resolves_the_directory_matching_the_running_architecture() {
let project = tempfile::tempdir().unwrap();
let bundle = tempfile::tempdir().unwrap();
let current = bundle
.path()
.join(format!("{}-{}", native_platform(), native_arch()));
fs::create_dir_all(&current).unwrap();
bundle_fixture(&current);
let runtime = resolve_at(project.path(), Some(bundle.path()), false, OsStr::new(""))
.expect("universal bundle must resolve the running architecture");
assert_eq!(runtime.source, "bundled");
assert_eq!(
runtime.node.canonicalize().unwrap(),
current.join(executable_name()).canonicalize().unwrap()
);
// 另一架构的目录即使损坏也不能影响本架构选择。
let other = if native_arch() == "arm64" {
"x86_64"
} else {
"arm64"
};
let other_dir = bundle
.path()
.join(format!("{}-{}", native_platform(), other));
fs::create_dir_all(&other_dir).unwrap();
fs::write(other_dir.join("manifest.json"), b"not-json").unwrap();
assert_eq!(
resolve_at(project.path(), Some(bundle.path()), false, OsStr::new(""))
.unwrap()
.node
.canonicalize()
.unwrap(),
current.join(executable_name()).canonicalize().unwrap()
);
// 本架构目录缺失而另一架构完整时,绝不采用另一架构的运行时。
fs::remove_dir_all(&current).unwrap();
bundle_fixture(&other_dir);
assert!(resolve_at(project.path(), Some(bundle.path()), false, OsStr::new("")).is_err());
}
#[test]
fn development_runtime_rejects_relative_and_project_path_entries() {
let root = tempfile::tempdir().unwrap();
@@ -862,22 +862,11 @@ fn resource_edit_state_sha256<T: Serialize>(value: &T, label: &str) -> Result<St
.map_err(|error| format!("序列化{label}身份失败:{error}"))
}
/// 只有仍可继续的阶段允许再次请求远端;终态必须在这里失败关闭。
///
/// 终态文案要带出稳定失败码和唯一出口(移出恢复队列):否则用户只会看到一句
/// 「已明确失败」并反复点重试。上游原文不进账本(见 `terminal_failure_code` 的写入边界),
/// 这里只透传分类码,不做二次解释。
fn ensure_resource_edit_phase_resumable(ledger: &ResourceEditLedger) -> Result<(), String> {
match ledger.phase {
ResourceEditLedgerPhase::RemoteFailed => Err(format!(
"remote-terminal-failed: 远端资源编辑已明确失败({}),不允许再次请求;如需重试,请先在待恢复资源编辑中把它移出恢复队列",
ledger
.terminal_failure_code
.as_deref()
.map(str::trim)
.filter(|code| !code.is_empty())
.unwrap_or("remote-failed")
)),
fn ensure_resource_edit_phase_resumable(phase: &ResourceEditLedgerPhase) -> Result<(), String> {
match phase {
ResourceEditLedgerPhase::RemoteFailed => {
Err("remote-terminal-failed: 远端资源编辑已明确失败,不允许再次请求".to_string())
}
ResourceEditLedgerPhase::Archived => {
Err("resource-edit-archived: 资源编辑已移出恢复队列".to_string())
}
@@ -5133,7 +5122,7 @@ pub(crate) async fn request_resource_edit_service_identity_confirmation_at(
{
return Err("资源编辑服务身份确认对应的 operation 身份无效".to_string());
}
ensure_resource_edit_phase_resumable(&ledger)?;
ensure_resource_edit_phase_resumable(&ledger.phase)?;
let (api_base_url, api_key, platform_session) =
resolve_canvas_sync_api_credentials(None, None)?;
match prepare_resource_edit_service_identity(
@@ -5206,7 +5195,7 @@ fn confirm_resource_edit_service_identity_under_lease(
return Err("资源编辑服务身份确认对应的 operation 身份已变化".to_string());
}
validate_resource_edit_service_identity_owner(&ledger, platform_session)?;
ensure_resource_edit_phase_resumable(&ledger)?;
ensure_resource_edit_phase_resumable(&ledger.phase)?;
let confirmation = ledger
.service_identity_confirmation
.clone()
@@ -5332,7 +5321,7 @@ pub(crate) async fn resume_local_project_resource_edit_at(
{
return Err("待恢复的资源编辑账本身份无效".to_string());
}
ensure_resource_edit_phase_resumable(&ledger)?;
ensure_resource_edit_phase_resumable(&ledger.phase)?;
let source_asset = ledger
.source_asset_id
.as_deref()
@@ -5563,7 +5552,7 @@ pub(crate) async fn derive_local_project_resource_at(
{
return Err("operationId 或幂等键已绑定到不同资源编辑请求".to_string());
}
ensure_resource_edit_phase_resumable(&ledger)?;
ensure_resource_edit_phase_resumable(&ledger.phase)?;
ledger
}
None => {
@@ -6168,8 +6157,8 @@ mod tests {
let request = read_http_request(&mut stream);
let request_line = request.lines().next().unwrap_or_default();
requests.push(request.clone());
if request_line.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|| request_line.starts_with("GET /api/editor/projects?view=summary ")
if request_line.starts_with("GET /api/external/v1/editor/projects ")
|| request_line.starts_with("GET /api/editor/projects ")
{
write_json(
&mut stream,
@@ -6697,9 +6686,7 @@ mod tests {
let request = read_http_request(&mut stream);
sender.send(request.clone()).expect("capture B request");
let request_line = request.lines().next().unwrap_or_default();
if request_line
.starts_with("GET /api/external/v1/editor/projects?view=summary ")
{
if request_line.starts_with("GET /api/external/v1/editor/projects ") {
write_json(
&mut stream,
"200 OK",
@@ -7199,7 +7186,7 @@ mod tests {
);
assert!(requests.iter().all(|request| {
let line = request.lines().next().unwrap_or_default();
line.starts_with("GET /api/external/v1/editor/projects?view=summary ")
line.starts_with("GET /api/external/v1/editor/projects ")
|| line.starts_with("GET /api/external/v1/editor/assets/library ")
}));
}
@@ -9306,7 +9293,7 @@ mod tests {
.expect("capture External video request");
let request_line = request.lines().next().unwrap_or_default();
let request_lower = request.to_ascii_lowercase();
if request_line.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
if request_line.starts_with("GET /api/external/v1/editor/projects ") {
write_json(
&mut stream,
"200 OK",
@@ -391,8 +391,8 @@ fn assert_developer_submission(request: &str) {
fn respond_canvas_context_request(stream: &mut TcpStream, request: &str) -> bool {
let line = request.lines().next().unwrap_or_default();
if line.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|| line.starts_with("GET /api/editor/projects?view=summary ")
if line.starts_with("GET /api/external/v1/editor/projects ")
|| line.starts_with("GET /api/editor/projects ")
{
write_json(
stream,
@@ -938,24 +938,6 @@ pub(crate) fn create_project_from_installed_template_at(
enforce_project_permission_policy(&project_root, "project.create")?;
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
copy_template_project(installed_project_dir, &project_root)?;
if discover_local_cocos_project_root(&project_root)?.is_some() {
let package_path = project_root.join("package.json");
let mut package: serde_json::Value = serde_json::from_slice(
&fs::read(&package_path)
.map_err(|error| format!("读取 Cocos 项目配置失败:{error}"))?,
)
.map_err(|error| format!("解析 Cocos 项目配置失败:{error}"))?;
package["name"] = serde_json::json!(project_name);
package["uuid"] = serde_json::json!(uuid::Uuid::new_v4().to_string());
let bytes = serde_json::to_vec_pretty(&package)
.map_err(|error| format!("序列化 Cocos 项目配置失败:{error}"))?;
write_game_creator_private_file(&package_path, &bytes, "Cocos 项目配置")?;
return import_local_cocos_project_at(
&project_root,
&format!("gameagent-{workspace_id}"),
&project_name,
);
}
init_local_game_project_at(
&project_root,
&format!("gameagent-{workspace_id}"),
@@ -1200,39 +1182,6 @@ mod tests {
assert!(error.contains("重复模板"), "{error}");
}
#[test]
fn parses_content_addressed_objects_without_changing_the_template_contract() {
let mut index: serde_json::Value =
serde_json::from_str(&sample_index_body()).expect("sample index");
let keys = [
("zipKey", "template.zip", "a"),
("coverKey", "cover.png", "b"),
("metadataKey", "template.json", "c"),
]
.map(|(field, file, hash)| {
(
field,
format!(
"templates/v1/demo-template/sha256/{}/{file}",
hash.repeat(64)
),
)
});
for (field, key) in &keys {
index["templates"][0][field] = serde_json::json!(key);
}
let (_, templates) =
parse_game_template_library_index(&index.to_string()).expect("content addressed index");
let parsed = serde_json::to_value(&templates[0]).expect("serialize template");
for (field, key) in &keys {
assert_eq!(parsed[field], *key);
assert!(template_object_url(key)
.expect("trusted URL")
.ends_with(key));
}
assert_eq!(templates[0].template_version, "1.0.0");
}
#[test]
fn rejects_object_keys_outside_the_templates_prefix() {
assert!(validate_template_object_key("agc/templates/v1/demo/template.zip").is_err());
@@ -1276,10 +1225,6 @@ mod tests {
"blank-2d-canvas",
"blank-3d-scene",
"blank-web",
"cocos-empty-2d",
"cocos-empty-3d",
"cocos-empty-3d-hq",
"cocos-hello-world",
"phaser-2d-starter",
"threejs-3d-starter",
]
@@ -1415,142 +1360,6 @@ mod tests {
assert!(error.contains("模板尚未安装"), "{error}");
}
fn template_source_files(root: &Path) -> Vec<(String, Vec<u8>)> {
fn collect(root: &Path, directory: &Path, files: &mut Vec<(String, Vec<u8>)>) {
for entry in fs::read_dir(directory).expect("read template directory") {
let path = entry.expect("template entry").path();
if path.is_dir() {
collect(root, &path, files);
} else {
files.push((
path.strip_prefix(root)
.expect("relative path")
.to_string_lossy()
.replace('\\', "/"),
fs::read(&path).expect("read template file"),
));
}
}
}
let mut files = Vec::new();
collect(root, root, &mut files);
files.sort_by(|left, right| left.0.cmp(&right.0));
files
}
fn assert_cocos_template_creates_independent_projects(
summary: &GameTemplateSummary,
archive: &[u8],
) {
let cache_root = tempfile::tempdir().expect("cache directory");
let projects_root = unique_projects_root();
let record = install_template_archive(cache_root.path(), summary, archive)
.expect("install Cocos template");
let installed_root = Path::new(&record.project_dir);
let installed_files = template_source_files(installed_root);
let original_package: serde_json::Value = serde_json::from_slice(
&fs::read(installed_root.join("package.json")).expect("installed package"),
)
.expect("parse installed package");
let mut project_uuids = std::collections::HashSet::new();
for name in ["Cocos 模板项目一", "Cocos 模板项目二"] {
let created = create_project_from_installed_template_at(
&projects_root,
installed_root,
Some(name),
false,
)
.expect("create Cocos project from template");
let root = Path::new(&created.project_path);
assert_eq!(created.manifest.name, name);
assert_eq!(created.manifest.cocos_project_root.as_deref(), Some("."));
assert!(created.manifest.godot_project_root.is_none());
assert!(root.join(".agent/manifest.json").is_file());
assert!(root.join(".agent/agent.db").is_file());
for unexpected in ["game", "memory", "exports", TEMPLATE_INSTALLED_MARKER_FILE] {
assert!(!root.join(unexpected).exists(), "unexpected {unexpected}");
}
let package: serde_json::Value = serde_json::from_slice(
&fs::read(root.join("package.json")).expect("created package"),
)
.expect("parse created package");
assert_eq!(package["name"], name);
assert_eq!(package["creator"]["version"], "3.8.8");
let uuid = package["uuid"].as_str().expect("project uuid");
uuid::Uuid::parse_str(uuid).expect("valid project uuid");
assert_ne!(package["uuid"], original_package["uuid"]);
assert!(project_uuids.insert(uuid.to_string()));
for (relative, bytes) in &installed_files {
if relative != "package.json" && relative != TEMPLATE_INSTALLED_MARKER_FILE {
assert_eq!(&fs::read(root.join(relative)).expect("copied file"), bytes);
}
}
}
assert_eq!(template_source_files(installed_root), installed_files);
fs::remove_dir_all(&projects_root).expect("remove test projects");
}
#[test]
fn installs_official_cocos_templates_and_creates_native_projects() {
let library = Path::new(env!("CARGO_MANIFEST_DIR")).join("../template-library/v1");
for id in [
"cocos-empty-2d",
"cocos-empty-3d",
"cocos-empty-3d-hq",
"cocos-hello-world",
] {
let source = library.join(id).join("project");
let source_files = template_source_files(&source);
let entries = source_files
.iter()
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
.collect::<Vec<_>>();
let archive = build_archive(&entries);
let mut summary = sample_summary();
summary.id = id.to_string();
summary.zip_size_bytes = archive.len() as u64;
summary.zip_sha256 = sha256_hex(&archive);
assert_cocos_template_creates_independent_projects(&summary, &archive);
assert_eq!(template_source_files(&source), source_files);
}
}
#[tokio::test]
#[ignore = "需要网络:下载线上 Cocos 模板并验证原生建项"]
async fn downloads_and_creates_live_cocos_templates() {
let base = template_library_base().expect("trusted base");
let client = build_template_library_client();
let body = fetch_limited_bytes(
&client,
&format!("{base}/{TEMPLATE_LIBRARY_INDEX_KEY}"),
TEMPLATE_LIBRARY_MAX_INDEX_BYTES,
)
.await
.expect("fetch live index");
let (_, templates) =
parse_game_template_library_index(&String::from_utf8(body).expect("utf-8 index"))
.expect("parse live index");
for id in [
"cocos-empty-2d",
"cocos-empty-3d",
"cocos-empty-3d-hq",
"cocos-hello-world",
] {
let summary = templates.iter().find(|entry| entry.id == id).expect(id);
assert_eq!(summary.runtime, "cocos");
assert_eq!(summary.engine_version, "3.8.8");
assert_eq!(summary.entry, "package.json");
let bytes = fetch_limited_bytes(
&client,
&template_object_url(&summary.zip_key).expect("trusted zip url"),
TEMPLATE_ARCHIVE_MAX_BYTES,
)
.await
.expect("download Cocos template");
assert_cocos_template_creates_independent_projects(summary, &bytes);
}
}
/// 正式构建(未开 feature)必须恒等透传:注入路径不能出现在默认产物里。
#[cfg(not(feature = "template-library-fixtures"))]
#[test]
@@ -3337,8 +3337,8 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
}
let normalized_request = request.to_ascii_lowercase();
let (status, content_type, body) = if request
.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|| request.starts_with("GET /api/editor/projects?view=summary ")
.starts_with("GET /api/external/v1/editor/projects ")
|| request.starts_with("GET /api/editor/projects ")
{
assert!(normalized_request.contains("authorization: bearer "));
("200 OK", "application/json", projects_body.as_bytes().to_vec())
@@ -3564,8 +3564,8 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String {
let (status, body) = match index {
0 => {
assert!(
request.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|| request.starts_with("GET /api/editor/projects?view=summary ")
request.starts_with("GET /api/external/v1/editor/projects ")
|| request.starts_with("GET /api/editor/projects ")
);
(
"200 OK",
@@ -2,13 +2,18 @@
"schemaVersion": "agc-template-library.v1",
"library": "agc-game-templates",
"libraryVersion": 1,
"updatedAt": "2026-09-17T13:55:10Z",
"updatedAt": "2026-09-17T03:22:43Z",
"templates": [
{
"id": "blank-2d-canvas",
"title": "空白二维画布工程",
"summary": "原生 Canvas 二维空白工程:自适应画布、按设备像素比缩放与 requestAnimationFrame 主循环已就绪。",
"tags": ["空白", "起步工程", "2d", "canvas"],
"tags": [
"空白",
"起步工程",
"2d",
"canvas"
],
"runtime": "html",
"engine": "canvas",
"engineVersion": "",
@@ -28,7 +33,12 @@
"id": "blank-3d-scene",
"title": "空白三维场景工程",
"summary": "Three.js 空白场景:空场景、透视相机、网格地面与自适应视口已就绪,适合从零搭三维玩法。",
"tags": ["空白", "起步工程", "3d", "three.js"],
"tags": [
"空白",
"起步工程",
"3d",
"three.js"
],
"runtime": "html",
"engine": "three.js",
"engineVersion": "0.180.0",
@@ -48,7 +58,12 @@
"id": "blank-web",
"title": "空白网页工程",
"summary": "最小网页工程(HTML + CSS + 原生 JS + Vite),没有任何引擎依赖,适合从零写玩法。",
"tags": ["空白", "起步工程", "网页", "原生"],
"tags": [
"空白",
"起步工程",
"网页",
"原生"
],
"runtime": "html",
"engine": "none",
"engineVersion": "",
@@ -64,91 +79,16 @@
"coverSha256": "326a2753386618971b1311043effa46c2e74bc1cfb116862c0ee4097dcd5086a",
"metadataKey": "templates/v1/blank-web/template.json"
},
{
"id": "cocos-empty-2d",
"title": "Cocos 空白 2D 工程",
"summary": "官方二维空白模板,保留精灵导入默认值与二维编辑视图。",
"tags": ["空白", "起步工程", "2d", "cocos"],
"runtime": "cocos",
"engine": "cocos-creator",
"engineVersion": "3.8.8",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T13:55:10Z",
"entry": "package.json",
"zipKey": "templates/v1/cocos-empty-2d/template.zip",
"zipSizeBytes": 1976,
"zipSha256": "e30bd6324fd2eb721c9a9fdcc9d8bb9838fba7b3413d09a890d3152c3576c485",
"coverKey": "templates/v1/cocos-empty-2d/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "4ca21e32e890e8a3d20bde2b9f1228993cbd770532c496433de0e5ffecc31342",
"metadataKey": "templates/v1/cocos-empty-2d/template.json"
},
{
"id": "cocos-empty-3d",
"title": "Cocos 空白 3D 工程",
"summary": "官方三维空白模板,适合从零搭建 Cocos 场景与玩法。",
"tags": ["空白", "起步工程", "3d", "cocos"],
"runtime": "cocos",
"engine": "cocos-creator",
"engineVersion": "3.8.8",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T13:55:10Z",
"entry": "package.json",
"zipKey": "templates/v1/cocos-empty-3d/template.zip",
"zipSizeBytes": 829,
"zipSha256": "e4016ed9bcd3d3b09d5d06e776c2cf4d98415473b3747bc4945e06e93967117a",
"coverKey": "templates/v1/cocos-empty-3d/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "88111f77f6801a77acc05277dc2698378ce9eeb8e9265b7d2dedafde0d893aae",
"metadataKey": "templates/v1/cocos-empty-3d/template.json"
},
{
"id": "cocos-empty-3d-hq",
"title": "Cocos 高质量 3D 工程",
"summary": "官方高质量三维空白模板,保留天空盒、阴影与线性纹理采样预设。",
"tags": ["空白", "起步工程", "3d", "cocos"],
"runtime": "cocos",
"engine": "cocos-creator",
"engineVersion": "3.8.8",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T13:55:10Z",
"entry": "package.json",
"zipKey": "templates/v1/cocos-empty-3d-hq/template.zip",
"zipSizeBytes": 1226,
"zipSha256": "4b4f12a7dd39be31bc471c7387b272717e9476b1a6eafb21273ce1a3ebc38384",
"coverKey": "templates/v1/cocos-empty-3d-hq/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "276752f234c1209c182ca79b65d97c1d52d9e22b60be9aaee6cb0b134ab666ea",
"metadataKey": "templates/v1/cocos-empty-3d-hq/template.json"
},
{
"id": "cocos-hello-world",
"title": "Cocos Hello World",
"summary": "官方三维示例场景,包含岛屿、角色、植被、材质与天空盒资源。",
"tags": ["示例", "起步工程", "3d", "cocos"],
"runtime": "cocos",
"engine": "cocos-creator",
"engineVersion": "3.8.8",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T13:55:10Z",
"entry": "package.json",
"zipKey": "templates/v1/cocos-hello-world/template.zip",
"zipSizeBytes": 2484948,
"zipSha256": "dac8cc2eae2ec9ff3cd429a1be2c2981b06919be720bfa50226619c0a56e00da",
"coverKey": "templates/v1/cocos-hello-world/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "9363b728a792835f57d22d129c598449801d343f739e85159e83e531ba7d949c",
"metadataKey": "templates/v1/cocos-hello-world/template.json"
},
{
"id": "phaser-2d-starter",
"title": "Phaser 2D 起步工程",
"summary": "AGC 新建项目使用的默认二维起步工程(Phaser 4 + Vite),解压后即为可运行项目根。",
"tags": ["起步工程", "2d", "phaser", "像素"],
"tags": [
"起步工程",
"2d",
"phaser",
"像素"
],
"runtime": "html",
"engine": "phaser",
"engineVersion": "4.2.1",
@@ -168,7 +108,12 @@
"id": "threejs-3d-starter",
"title": "Three.js 3D 起步工程",
"summary": "网页三维起步工程(Three.js + Vite),自带可旋转立方体场景、方向光与自适应视口。",
"tags": ["起步工程", "3d", "three.js", "网页"],
"tags": [
"起步工程",
"3d",
"three.js",
"网页"
],
"runtime": "html",
"engine": "three.js",
"engineVersion": "0.180.0",

Some files were not shown because too many files have changed in this diff Show More