合并最新master到图标生成修复分支
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 / Frontend 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
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 / Frontend 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
合入origin/master最新变更 保留双方新增排障记录并解决文档冲突 验证图标生成定向测试、客户端类型检查、编码与文档索引
This commit is contained in:
@@ -34,6 +34,7 @@ import type {
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
AdminProjectSnapshotChannelsResponse,
|
||||
AdminProjectSnapshotListQuery,
|
||||
AdminProjectSnapshotListResponse,
|
||||
AdminRechargeOrderListQuery,
|
||||
@@ -214,6 +215,7 @@ export function listAdminProjectSnapshots(
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (query.cursor) params.set('cursor', query.cursor);
|
||||
if (query.channel) params.set('channel', query.channel);
|
||||
params.set('limit', String(query.limit ?? 20));
|
||||
return request<AdminProjectSnapshotListResponse>(
|
||||
`/admin/api/project-snapshots?${params.toString()}`,
|
||||
@@ -221,13 +223,27 @@ export function listAdminProjectSnapshots(
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminProjectSnapshotChannels(
|
||||
token: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return request<AdminProjectSnapshotChannelsResponse>(
|
||||
'/admin/api/project-snapshots/channels',
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadAdminProjectSnapshot(
|
||||
token: string,
|
||||
channel: string,
|
||||
userId: string,
|
||||
projectId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`;
|
||||
const params = new URLSearchParams();
|
||||
if (channel) params.set('channel', channel);
|
||||
const query = params.toString();
|
||||
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download${query ? `?${query}` : ''}`;
|
||||
const response = await fetch(buildRequestUrl(path), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.trim()}`,
|
||||
|
||||
@@ -105,11 +105,15 @@ export interface AdminProjectSnapshotEntry {
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
status: 'ready' | 'partial' | 'unverified';
|
||||
channel: string;
|
||||
authorDisplayName?: string | null;
|
||||
authorPublicUserCode?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminProjectSnapshotListQuery {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
channel?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminProjectSnapshotListResponse {
|
||||
@@ -117,6 +121,11 @@ export interface AdminProjectSnapshotListResponse {
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface AdminProjectSnapshotChannelsResponse {
|
||||
defaultChannel: string;
|
||||
channels: string[];
|
||||
}
|
||||
|
||||
export interface AdminErrorReportEntry {
|
||||
batchId: string;
|
||||
eventCount: number;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
downloadAdminProjectSnapshot,
|
||||
getAdminProjectSnapshotChannels,
|
||||
listAdminProjectSnapshots,
|
||||
} from './adminApiClient';
|
||||
|
||||
@@ -21,12 +22,12 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
|
||||
expect(
|
||||
await listAdminProjectSnapshots(
|
||||
'admin-token',
|
||||
{ cursor: 'user/a+项目', limit: 20 },
|
||||
{ cursor: 'user/a+项目', limit: 20, channel: 'release' },
|
||||
controller.signal,
|
||||
),
|
||||
).toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20',
|
||||
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&channel=release&limit=20',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||
signal: controller.signal,
|
||||
@@ -34,6 +35,23 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('渠道列表按后台授权读取,解析本部署渠道', async () => {
|
||||
const payload = { defaultChannel: 'release', channels: ['dev', 'release'] };
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true, data: payload })),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(await getAdminProjectSnapshotChannels('admin-token')).toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/admin/api/project-snapshots/channels',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('PK\u0003\u0004', {
|
||||
@@ -48,6 +66,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
|
||||
const controller = new AbortController();
|
||||
const archive = await downloadAdminProjectSnapshot(
|
||||
'admin-token',
|
||||
'release',
|
||||
'user/a',
|
||||
'project/b',
|
||||
controller.signal,
|
||||
@@ -55,7 +74,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
|
||||
expect(archive.filename).toBe('三消-r2.zip');
|
||||
expect(archive.blob.type).toBe('application/zip');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download',
|
||||
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download?channel=release',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer admin-token',
|
||||
@@ -81,7 +100,8 @@ test.each([
|
||||
vi.fn().mockResolvedValue(new Response('PK', { headers })),
|
||||
);
|
||||
expect(
|
||||
(await downloadAdminProjectSnapshot('token', 'user', 'project')).filename,
|
||||
(await downloadAdminProjectSnapshot('token', 'release', 'user', 'project'))
|
||||
.filename,
|
||||
).toBe(expected.replace('工程', 'project'));
|
||||
});
|
||||
|
||||
@@ -104,7 +124,7 @@ test.each([401, 403, 409, 500])(
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
||||
).rejects.toMatchObject({
|
||||
status,
|
||||
code: 'SNAPSHOT_FAILURE',
|
||||
@@ -123,6 +143,6 @@ test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
||||
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
AdminApiError,
|
||||
downloadAdminProjectSnapshot,
|
||||
getAdminProjectSnapshotChannels,
|
||||
listAdminProjectSnapshots,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||
@@ -23,6 +24,7 @@ vi.mock('../api/adminApiClient', async () => ({
|
||||
'../api/adminApiClient',
|
||||
)),
|
||||
downloadAdminProjectSnapshot: vi.fn(),
|
||||
getAdminProjectSnapshotChannels: vi.fn(),
|
||||
listAdminProjectSnapshots: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -35,12 +37,18 @@ const entry: AdminProjectSnapshotEntry = {
|
||||
fileCount: 12,
|
||||
totalBytes: 2048,
|
||||
status: 'ready',
|
||||
channel: 'dev',
|
||||
authorDisplayName: '陶泥作者',
|
||||
authorPublicUserCode: 'SY-00000007',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [entry], nextCursor: null });
|
||||
vi.mocked(getAdminProjectSnapshotChannels)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] });
|
||||
vi.mocked(downloadAdminProjectSnapshot).mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -88,36 +96,166 @@ test('按项目展示完整性并限制未完成工程下载', async () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => {
|
||||
test('按游标翻页回到上一页时复用已取得的游标', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
||||
nextCursor: 'page-3',
|
||||
})
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' });
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
const pagination = await screen.findByRole('navigation', {
|
||||
name: '项目工程分页',
|
||||
});
|
||||
expect(pagination.textContent).toContain('第 1 页');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '上一页' }).hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第二工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'token',
|
||||
{ cursor: 'page-2', limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(screen.queryByText('三消工程')).toBeNull();
|
||||
expect(pagination.textContent).toContain('第 2 页');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
||||
await screen.findByText('三消工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(pagination.textContent).toContain('第 1 页');
|
||||
});
|
||||
|
||||
test('切换每页条数从第一页按新条数重新加载', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
||||
nextCursor: 'page-3',
|
||||
})
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: null });
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第二工程');
|
||||
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
|
||||
expect(pagination.textContent).toContain('第 2 页');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('每页条数'), {
|
||||
target: { value: '50' },
|
||||
});
|
||||
await screen.findByText('三消工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'token',
|
||||
{ cursor: null, limit: 50, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
// 重新加载时页脚会重建,必须重新取节点再断言。
|
||||
expect(
|
||||
screen.getByRole('navigation', { name: '项目工程分页' }).textContent,
|
||||
).toContain('第 1 页');
|
||||
});
|
||||
|
||||
test('刷新重载当前页,翻页失败保留当前页并提示错误', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
||||
nextCursor: null,
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('远端清单读取失败'))
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: null });
|
||||
.mockRejectedValueOnce(new Error('翻页读取失败'));
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第二工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'token',
|
||||
{ cursor: 'page-2', limit: 20 },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(screen.getByText('三消工程')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.getByText('第二工程')).toBeTruthy();
|
||||
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByText('暂无已上传项目');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith(
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'token',
|
||||
{ cursor: null, limit: 20 },
|
||||
{ cursor: 'page-2', limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
|
||||
expect(pagination.textContent).toContain('第 2 页');
|
||||
expect(screen.getByText('第二工程')).toBeTruthy();
|
||||
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
||||
|
||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByText('暂无已上传项目');
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
|
||||
test('用户列与素材查询同口径展示昵称、陶泥号和用户详情入口', async () => {
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const row = (await screen.findByText('三消工程')).closest('tr')!;
|
||||
expect(within(row).getByText('陶泥作者')).toBeTruthy();
|
||||
expect(within(row).getByText('SY-00000007')).toBeTruthy();
|
||||
expect(
|
||||
within(row).getByRole('button', { name: '查看用户信息' }),
|
||||
).toBeTruthy();
|
||||
expect(within(row).queryByText('user-1')).toBeNull();
|
||||
});
|
||||
|
||||
test('默认查询本部署渠道,切换渠道后从第一页按该渠道重新查询', async () => {
|
||||
vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
|
||||
defaultChannel: 'release',
|
||||
channels: ['dev', 'release'],
|
||||
});
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, channel: 'dev', projectName: 'dev 工程' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'release' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
const channelSelect = screen.getByLabelText('项目工程渠道');
|
||||
expect((channelSelect as HTMLSelectElement).value).toBe('release');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第 2 页,本页 1 个项目');
|
||||
fireEvent.change(channelSelect, { target: { value: 'dev' } });
|
||||
await screen.findByText('dev 工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(screen.getByText('第 1 页,本页 1 个项目')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('下载使用返回的中文文件名,随后释放对象 URL', async () => {
|
||||
@@ -154,6 +292,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
|
||||
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
||||
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
|
||||
'token',
|
||||
'dev',
|
||||
'user-1',
|
||||
'project-1',
|
||||
expect.any(AbortSignal),
|
||||
@@ -164,7 +303,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
|
||||
|
||||
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
|
||||
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
|
||||
(_token, _user, _project, signal) =>
|
||||
(_token, _channel, _user, _project, signal) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')),
|
||||
@@ -177,7 +316,7 @@ test('取消下载中止请求且不显示错误,卸载中止列表请求', as
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
|
||||
expect(
|
||||
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted,
|
||||
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted,
|
||||
).toBe(true);
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
|
||||
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
|
||||
@@ -248,6 +387,10 @@ test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>,
|
||||
);
|
||||
// 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。
|
||||
await waitFor(() =>
|
||||
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
|
||||
view.rerender(
|
||||
<AdminProjectSnapshotsPage
|
||||
@@ -278,7 +421,7 @@ test('卸载后完成的下载不会创建浏览器文件', async () => {
|
||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3];
|
||||
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4];
|
||||
view.unmount();
|
||||
expect(signal?.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Download, RefreshCcw, X } from 'lucide-react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
RefreshCcw,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
downloadAdminProjectSnapshot,
|
||||
getAdminProjectSnapshotChannels,
|
||||
listAdminProjectSnapshots,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminProjectSnapshotsPageProps {
|
||||
@@ -31,21 +39,32 @@ const snapshotStatuses = {
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const PAGE_SIZE_OPTIONS = [20, 50, 100];
|
||||
|
||||
export function AdminProjectSnapshotsPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: AdminProjectSnapshotsPageProps) {
|
||||
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
// null 表示渠道还没确定:先读完可选渠道再发列表请求,避免用错渠道白跑一次。
|
||||
const [channel, setChannel] = useState<string | null>(null);
|
||||
const [channelOptions, setChannelOptions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
||||
const listController = useRef<AbortController | null>(null);
|
||||
const downloadController = useRef<AbortController | null>(null);
|
||||
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
|
||||
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
|
||||
const pageCursors = useRef<(string | null)[]>([null]);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (cursor: string | null = null) => {
|
||||
async (cursor: string | null, limit: number, page: number) => {
|
||||
listController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
listController.current = controller;
|
||||
@@ -54,23 +73,16 @@ export function AdminProjectSnapshotsPage({
|
||||
try {
|
||||
const response = await listAdminProjectSnapshots(
|
||||
token,
|
||||
{ cursor, limit: 20 },
|
||||
{ cursor, limit, channel },
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setItems((current) => {
|
||||
if (!cursor) return response.items;
|
||||
const entries = new Map(
|
||||
current.map((entry) => [snapshotKey(entry), entry]),
|
||||
);
|
||||
response.items.forEach((entry) =>
|
||||
entries.set(snapshotKey(entry), entry),
|
||||
);
|
||||
return [...entries.values()];
|
||||
});
|
||||
setItems(response.items);
|
||||
setNextCursor(response.nextCursor);
|
||||
setPageIndex(page);
|
||||
setHasLoaded(true);
|
||||
} catch (error: unknown) {
|
||||
// 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。
|
||||
if (!controller.signal.aborted)
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
@@ -80,22 +92,70 @@ export function AdminProjectSnapshotsPage({
|
||||
}
|
||||
}
|
||||
},
|
||||
[token, onUnauthorized],
|
||||
[token, onUnauthorized, channel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 换令牌或首次进入时取一次可选渠道;已选渠道保持不变,只在还没选时落到本部署渠道。
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await getAdminProjectSnapshotChannels(
|
||||
token,
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setChannelOptions(response.channels);
|
||||
setChannel((current) => current ?? response.defaultChannel);
|
||||
} catch (error: unknown) {
|
||||
if (controller.signal.aborted) return;
|
||||
// 渠道列表失败不阻塞查询:不带渠道按本部署渠道查询,并提示失败原因。
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
setChannel((current) => current ?? '');
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [token, onUnauthorized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (channel === null) return undefined;
|
||||
pageCursors.current = [null];
|
||||
setItems([]);
|
||||
setNextCursor(null);
|
||||
setPageIndex(1);
|
||||
setHasLoaded(false);
|
||||
setDownloadingKey(null);
|
||||
void loadPage();
|
||||
void loadPage(null, pageSize, 1);
|
||||
return () => {
|
||||
listController.current?.abort();
|
||||
listController.current = null;
|
||||
downloadController.current?.abort();
|
||||
downloadController.current = null;
|
||||
};
|
||||
}, [loadPage]);
|
||||
}, [loadPage, pageSize, channel]);
|
||||
|
||||
function goToNextPage() {
|
||||
if (!nextCursor) return;
|
||||
pageCursors.current[pageIndex] = nextCursor;
|
||||
void loadPage(nextCursor, pageSize, pageIndex + 1);
|
||||
}
|
||||
|
||||
function goToPreviousPage() {
|
||||
if (pageIndex <= 1) return;
|
||||
void loadPage(
|
||||
pageCursors.current[pageIndex - 2] ?? null,
|
||||
pageSize,
|
||||
pageIndex - 1,
|
||||
);
|
||||
}
|
||||
|
||||
function refreshCurrentPage() {
|
||||
void loadPage(
|
||||
pageCursors.current[pageIndex - 1] ?? null,
|
||||
pageSize,
|
||||
pageIndex,
|
||||
);
|
||||
}
|
||||
|
||||
async function downloadProject(entry: AdminProjectSnapshotEntry) {
|
||||
if (downloadController.current || entry.status === 'partial') return;
|
||||
@@ -106,6 +166,7 @@ export function AdminProjectSnapshotsPage({
|
||||
try {
|
||||
const archive = await downloadAdminProjectSnapshot(
|
||||
token,
|
||||
channel ?? '',
|
||||
entry.userId,
|
||||
entry.projectId,
|
||||
controller.signal,
|
||||
@@ -140,19 +201,42 @@ export function AdminProjectSnapshotsPage({
|
||||
setDownloadingKey(null);
|
||||
}
|
||||
|
||||
// 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。
|
||||
const visibleChannelOptions = channelOptions.length
|
||||
? channelOptions
|
||||
: channel
|
||||
? [channel]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<h2>项目工程</h2>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={() => void loadPage()}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '加载中' : '刷新'}</span>
|
||||
</button>
|
||||
<div className="admin-action-row">
|
||||
<label className="admin-field admin-field-compact">
|
||||
<span>渠道</span>
|
||||
<select
|
||||
aria-label="项目工程渠道"
|
||||
value={channel ?? ''}
|
||||
onChange={(event) => setChannel(event.target.value)}
|
||||
>
|
||||
{visibleChannelOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshCurrentPage}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '加载中' : '刷新'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="alert">
|
||||
@@ -169,7 +253,7 @@ export function AdminProjectSnapshotsPage({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目</th>
|
||||
<th>用户 ID</th>
|
||||
<th>用户</th>
|
||||
<th>同步时间</th>
|
||||
<th>文件数</th>
|
||||
<th>体积</th>
|
||||
@@ -187,7 +271,22 @@ export function AdminProjectSnapshotsPage({
|
||||
<strong>{entry.projectName || entry.projectId}</strong>
|
||||
<small>{entry.projectId}</small>
|
||||
</td>
|
||||
<td data-label="用户 ID">{entry.userId}</td>
|
||||
<td data-label="用户">
|
||||
<div className="admin-inline-identity">
|
||||
<div>
|
||||
{projectOwnerDisplayName(entry)}
|
||||
<small>
|
||||
{entry.authorPublicUserCode?.trim() || '-'}
|
||||
</small>
|
||||
</div>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.userId}
|
||||
publicUserCode={entry.authorPublicUserCode}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="同步时间">
|
||||
<span>
|
||||
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
|
||||
@@ -239,23 +338,66 @@ export function AdminProjectSnapshotsPage({
|
||||
{hasLoaded && items.length === 0 && !errorMessage ? (
|
||||
<p className="admin-muted-text">暂无已上传项目</p>
|
||||
) : null}
|
||||
{nextCursor ? (
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={() => void loadPage(nextCursor)}
|
||||
>
|
||||
{isLoading ? '加载中' : '加载更多'}
|
||||
</button>
|
||||
</div>
|
||||
{hasLoaded ? (
|
||||
<nav
|
||||
className="admin-action-row admin-project-snapshot-pagination"
|
||||
aria-label="项目工程分页"
|
||||
>
|
||||
<span className="admin-project-snapshot-pagination-info">
|
||||
第 {pageIndex} 页
|
||||
{items.length ? `,本页 ${items.length} 个项目` : ''}
|
||||
</span>
|
||||
<div className="admin-action-row">
|
||||
<label className="admin-field admin-field-compact">
|
||||
<span>每页</span>
|
||||
<select
|
||||
aria-label="每页条数"
|
||||
value={pageSize}
|
||||
onChange={(event) => setPageSize(Number(event.target.value))}
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
aria-label="上一页"
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading || pageIndex <= 1}
|
||||
title="上一页"
|
||||
type="button"
|
||||
onClick={goToPreviousPage}
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
<span>上一页</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="下一页"
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading || !nextCursor}
|
||||
title="下一页"
|
||||
type="button"
|
||||
onClick={goToNextPage}
|
||||
>
|
||||
<span>下一页</span>
|
||||
<ChevronRight size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
) : null}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) {
|
||||
return (
|
||||
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotKey(entry: AdminProjectSnapshotEntry) {
|
||||
return `${entry.userId}/${entry.projectId}`;
|
||||
}
|
||||
|
||||
@@ -1594,15 +1594,15 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-project-snapshot-table th:first-child {
|
||||
width: 20%;
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
.admin-project-snapshot-table th:nth-child(2) {
|
||||
width: 14%;
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
.admin-project-snapshot-table th:nth-child(3) {
|
||||
width: 18%;
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
.admin-project-snapshot-table th:nth-child(4) {
|
||||
@@ -1689,6 +1689,21 @@ button:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
.admin-project-snapshot-pagination {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-project-snapshot-pagination-info {
|
||||
color: #755a49;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-project-snapshot-pagination .admin-field {
|
||||
min-width: 92px;
|
||||
}
|
||||
|
||||
.admin-recharge-table {
|
||||
min-width: 1080px;
|
||||
table-layout: fixed;
|
||||
|
||||
@@ -20,7 +20,10 @@ import { createInterface } from 'node:readline/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { inflateSync } from 'node:zlib';
|
||||
|
||||
export const appIdentifier = 'world.genarrative.ai-game-creator';
|
||||
import { AGC_APP_IDENTIFIER } from './channel-identity.mjs';
|
||||
|
||||
// 联调工具驱动的始终是默认渠道客户端:安装身份取渠道基线,不跟随发布渠道。
|
||||
export const appIdentifier = AGC_APP_IDENTIFIER;
|
||||
export const configFileName = 'game-creator.config.json';
|
||||
export const localConfigFileName = 'game-creator.config.local.json';
|
||||
export const runnerEndpointFileName = 'agent-runner.endpoint.json';
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
resolveReleasePartition,
|
||||
runTauriBuild,
|
||||
} from './build-release.mjs';
|
||||
import { resolveChannelInstallIdentity } from './channel-identity.mjs';
|
||||
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
|
||||
import {
|
||||
readUpdaterPubkey,
|
||||
@@ -39,27 +40,19 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = path.resolve(appRoot, '../..');
|
||||
|
||||
/**
|
||||
* 产品名只从 Tauri 配置读取:它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。
|
||||
* 写死会在改名后让入口静默找错对象(清理、打包、归档三处一起失效)。
|
||||
* 产品名只从渠道安装身份派生(渠道身份由构建期 `--config` 注入 Tauri 配置):
|
||||
* 它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。写死会在改名或换渠道后
|
||||
* 让入口静默找错对象(清理、打包、归档三处一起失效)。
|
||||
*/
|
||||
function readProductName() {
|
||||
const read = (file) =>
|
||||
JSON.parse(fs.readFileSync(path.join(appRoot, 'src-tauri', file), 'utf8'));
|
||||
const base = read('tauri.conf.json');
|
||||
const macosPath = path.join(appRoot, 'src-tauri', 'tauri.macos.conf.json');
|
||||
const productName = fs.existsSync(macosPath)
|
||||
? (read('tauri.macos.conf.json').productName ?? base.productName)
|
||||
: base.productName;
|
||||
function resolveProductName(channel) {
|
||||
const { productName } = resolveChannelInstallIdentity(channel);
|
||||
assert.ok(
|
||||
typeof productName === 'string' && productName.trim().length > 0,
|
||||
'Tauri 配置缺少 productName',
|
||||
'渠道安装身份缺少 productName',
|
||||
);
|
||||
return productName;
|
||||
}
|
||||
|
||||
const productName = readProductName();
|
||||
const appBundleName = `${productName}.app`;
|
||||
const updaterArtifactName = `${productName}.app.tar.gz`;
|
||||
assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行');
|
||||
assert.equal(
|
||||
process.env.JENKINS_URL?.length > 0,
|
||||
@@ -102,6 +95,9 @@ process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
|
||||
const macTarget = 'aarch64-apple-darwin';
|
||||
const context = resolveReleaseContext([`--target=${macTarget}`]);
|
||||
const partition = resolveReleasePartition(context.channel, context.target);
|
||||
const productName = resolveProductName(context.channel);
|
||||
const appBundleName = `${productName}.app`;
|
||||
const updaterArtifactName = `${productName}.app.tar.gz`;
|
||||
const version = await prepareReleaseVersion(context);
|
||||
// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`,
|
||||
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
defaultEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
} from './cargo-features.mjs';
|
||||
import {
|
||||
resolveChannelInstallIdentity,
|
||||
resolveReleaseChannel,
|
||||
} from './channel-identity.mjs';
|
||||
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
|
||||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||
|
||||
@@ -89,14 +93,7 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
|
||||
const defaultOssBaseUrl =
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
||||
|
||||
const reservedChannelNames = new Set([
|
||||
'win',
|
||||
'mac',
|
||||
'windows',
|
||||
'macos',
|
||||
'darwin',
|
||||
'linux',
|
||||
]);
|
||||
export { resolveReleaseChannel } from './channel-identity.mjs';
|
||||
|
||||
/**
|
||||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
||||
@@ -165,21 +162,6 @@ export function resolveReleasePlatform(target = defaultTarget()) {
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
}
|
||||
|
||||
export function resolveReleaseChannel(env = process.env) {
|
||||
const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev';
|
||||
if (
|
||||
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
|
||||
channel.endsWith('-') ||
|
||||
reservedChannelNames.has(channel) ||
|
||||
/-(win|mac)$/u.test(channel)
|
||||
) {
|
||||
throw new Error(
|
||||
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
|
||||
);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */
|
||||
export function resolveReleasePartition(
|
||||
channel = resolveReleaseChannel(),
|
||||
@@ -428,12 +410,19 @@ export function buildTauriBuildArguments(
|
||||
];
|
||||
}
|
||||
|
||||
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
||||
/**
|
||||
* 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道,
|
||||
* 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录,
|
||||
* 不同渠道必须在同一台设备上并存而不是互相顶掉。
|
||||
*/
|
||||
export function createChannelConfig(
|
||||
channel = resolveReleaseChannel(),
|
||||
target = defaultTarget(),
|
||||
) {
|
||||
const { productName, identifier } = resolveChannelInstallIdentity(channel);
|
||||
return {
|
||||
productName,
|
||||
identifier,
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [updateManifestUrl(channel, target)],
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
selectReleaseArtifact,
|
||||
updateManifestUrl,
|
||||
} from './build-release.mjs';
|
||||
import {
|
||||
AGC_APP_IDENTIFIER,
|
||||
AGC_PRODUCT_NAME,
|
||||
resolveChannelInstallIdentity,
|
||||
} from './channel-identity.mjs';
|
||||
|
||||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||||
const universalTarget = 'universal-apple-darwin';
|
||||
@@ -184,6 +189,8 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
|
||||
);
|
||||
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [
|
||||
@@ -204,6 +211,68 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('channel install identity isolates co-installed builds and keeps the default channel stable', () => {
|
||||
// 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。
|
||||
assert.deepEqual(resolveChannelInstallIdentity('dev'), {
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
});
|
||||
assert.deepEqual(resolveChannelInstallIdentity('release'), {
|
||||
productName: '陶泥儿 Release',
|
||||
identifier: `${AGC_APP_IDENTIFIER}.release`,
|
||||
});
|
||||
assert.deepEqual(resolveChannelInstallIdentity('beta-2'), {
|
||||
productName: '陶泥儿 Beta-2',
|
||||
identifier: `${AGC_APP_IDENTIFIER}.beta-2`,
|
||||
});
|
||||
|
||||
// 同一台设备上不同渠道的安装目录、卸载项与数据目录必须互不相同。
|
||||
for (const channel of ['release', 'beta-2', 'a'.repeat(32)]) {
|
||||
const identity = resolveChannelInstallIdentity(channel);
|
||||
assert.notEqual(identity.productName, AGC_PRODUCT_NAME);
|
||||
assert.notEqual(identity.identifier, AGC_APP_IDENTIFIER);
|
||||
assert.ok(identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`));
|
||||
}
|
||||
|
||||
for (const channel of ['dev-win', 'Release', 'win', 'beta-']) {
|
||||
assert.throws(
|
||||
() => resolveChannelInstallIdentity(channel),
|
||||
/发布渠道无效/u,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('channel install identity is baked into the same build-time config as the endpoint', () => {
|
||||
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
|
||||
const config = createChannelConfig('release', windowsTarget);
|
||||
assert.equal(config.productName, '陶泥儿 Release');
|
||||
assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`);
|
||||
assert.match(
|
||||
config.plugins.updater.endpoints[0],
|
||||
/\/release-win\/latest\.json$/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('channel products keep first-install selection working under the channel product name', () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-'));
|
||||
try {
|
||||
const { productName } = resolveChannelInstallIdentity('release');
|
||||
const dmg = path.join(root, `${productName}_${packageVersion}_aarch64.dmg`);
|
||||
writeFileSync(dmg, 'channel first installation disk image');
|
||||
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
|
||||
assert.equal(
|
||||
selectFirstInstallArtifact([dmg, path.join(root, 'windows.exe')], {
|
||||
target: 'aarch64-apple-darwin',
|
||||
version: packageVersion,
|
||||
}),
|
||||
dmg,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('packaged renderer receives the same channel as the updater manifest', () => {
|
||||
const context = resolveReleaseContext([], {
|
||||
AGC_BUILD_TARGET: windowsTarget,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AGC 渠道 → 安装身份。
|
||||
*
|
||||
* 渠道同时决定两件事:
|
||||
* - 更新端点:OSS 分区 `<channel>-win` / `<channel>-mac` 的清单地址;
|
||||
* - 安装身份:`productName` 与 `identifier`。
|
||||
*
|
||||
* 安装身份决定 Windows 安装目录与卸载项、macOS `.app` 名字与 bundle id、
|
||||
* Windows WebView2 数据目录以及 `%APPDATA%\<identifier>` 客户端数据目录。
|
||||
* 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、
|
||||
* 本地项目与运行锁。
|
||||
*
|
||||
* 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。
|
||||
*/
|
||||
|
||||
export const AGC_DEFAULT_CHANNEL = 'dev';
|
||||
export const AGC_PRODUCT_NAME = '陶泥儿';
|
||||
export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator';
|
||||
|
||||
const reservedChannelNames = new Set([
|
||||
'win',
|
||||
'mac',
|
||||
'windows',
|
||||
'macos',
|
||||
'darwin',
|
||||
'linux',
|
||||
]);
|
||||
|
||||
/** 校验渠道名:小写字母开头,允许数字与连字符,系统名不属于渠道。 */
|
||||
export function validateReleaseChannel(channel) {
|
||||
if (
|
||||
typeof channel !== 'string' ||
|
||||
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
|
||||
channel.endsWith('-') ||
|
||||
reservedChannelNames.has(channel) ||
|
||||
/-(win|mac)$/u.test(channel)
|
||||
) {
|
||||
throw new Error(
|
||||
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
|
||||
);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
export function resolveReleaseChannel(env = process.env) {
|
||||
return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev');
|
||||
}
|
||||
|
||||
/** 安装身份里的展示后缀:`release` → `Release`,`beta-2` → `Beta-2`。 */
|
||||
export function channelDisplaySuffix(channel) {
|
||||
return validateReleaseChannel(channel)
|
||||
.split('-')
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀,
|
||||
* 保证同一台设备上不同渠道互不覆盖。
|
||||
*/
|
||||
export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) {
|
||||
validateReleaseChannel(channel);
|
||||
if (channel === AGC_DEFAULT_CHANNEL) {
|
||||
return Object.freeze({
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`,
|
||||
identifier: `${AGC_APP_IDENTIFIER}.${channel}`,
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
appIdentifier,
|
||||
defaultRealSwarmTestTask,
|
||||
} from './agent-swarm-test-chat.mjs';
|
||||
import {
|
||||
AGC_APP_IDENTIFIER,
|
||||
AGC_PRODUCT_NAME,
|
||||
resolveChannelInstallIdentity,
|
||||
} from './channel-identity.mjs';
|
||||
import {
|
||||
askHidden,
|
||||
assertSafeGameCreatorConfigDestination,
|
||||
@@ -1308,7 +1313,8 @@ if (
|
||||
}
|
||||
|
||||
for (const requiredSource of [
|
||||
"export const appIdentifier = 'world.genarrative.ai-game-creator'",
|
||||
"import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'",
|
||||
'export const appIdentifier = AGC_APP_IDENTIFIER',
|
||||
"'--swarm-chat'",
|
||||
"'--autonomous-game-build'",
|
||||
"'--preview-serve'",
|
||||
@@ -1319,14 +1325,38 @@ for (const requiredSource of [
|
||||
}
|
||||
}
|
||||
|
||||
if (tauriConfig.productName !== '陶泥儿') {
|
||||
// 基线配置必须等于默认渠道的安装身份:默认渠道不能改身份,否则已发布客户端
|
||||
// 的升级链路与既有安装目录都会断开。
|
||||
const defaultChannelIdentity = resolveChannelInstallIdentity('dev');
|
||||
if (tauriConfig.productName !== AGC_PRODUCT_NAME) {
|
||||
throw new Error('AI game creator shell productName drifted');
|
||||
}
|
||||
|
||||
if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
|
||||
if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) {
|
||||
throw new Error('AI game creator shell identifier drifted');
|
||||
}
|
||||
|
||||
if (
|
||||
tauriConfig.productName !== defaultChannelIdentity.productName ||
|
||||
tauriConfig.identifier !== defaultChannelIdentity.identifier
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator shell baseline config must match the default channel identity',
|
||||
);
|
||||
}
|
||||
|
||||
// 非默认渠道必须派生出独立安装身份,否则同机安装会互相顶掉。
|
||||
for (const channel of ['release', 'beta-2']) {
|
||||
const identity = resolveChannelInstallIdentity(channel);
|
||||
if (
|
||||
identity.productName === defaultChannelIdentity.productName ||
|
||||
identity.identifier === defaultChannelIdentity.identifier ||
|
||||
!identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`)
|
||||
) {
|
||||
throw new Error(`channel install identity not isolated: ${channel}`);
|
||||
}
|
||||
}
|
||||
|
||||
const expectedBundledDesignAgentResources = {
|
||||
'design-agent': 'design-agent',
|
||||
...Object.fromEntries(
|
||||
|
||||
@@ -174,8 +174,16 @@ test('macOS release entry and smoke script derive product names from config and
|
||||
new URL('./build-macos-ci.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// 产品名决定 *.app、updater 归档与 DMG 卷名:写死会在改名后静默找错对象。
|
||||
assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名');
|
||||
// 产品名决定 *.app、updater 归档与 DMG 卷名:它必须从渠道安装身份派生,
|
||||
// 写死会在换渠道或改名后静默找错对象。
|
||||
assert.ok(
|
||||
entry.includes('resolveChannelInstallIdentity'),
|
||||
'入口必须从渠道安装身份派生产品名',
|
||||
);
|
||||
assert.ok(
|
||||
entry.includes('resolveProductName(context.channel)'),
|
||||
'产品名必须按当前发布渠道解析',
|
||||
);
|
||||
assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名');
|
||||
assert.ok(
|
||||
entry.includes("const macTarget = 'aarch64-apple-darwin'"),
|
||||
|
||||
@@ -7684,7 +7684,7 @@ done
|
||||
&temp.path().join("host"),
|
||||
&project,
|
||||
"turn-0001",
|
||||
"fixture-request",
|
||||
&format!("{:x}", Sha256::digest("请创建菜单".as_bytes())),
|
||||
false,
|
||||
&super::super::direct_validation::DirectValidationConfig::default(),
|
||||
)
|
||||
|
||||
@@ -21,30 +21,36 @@ pub(crate) fn validate_direct_codex_user_item(
|
||||
return Err("DirectProject user item 缺少稳定 id".to_string());
|
||||
}
|
||||
if message.content.is_empty() {
|
||||
return Err("DirectProject user item content 不能为空".to_string());
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let mut reference_count = 0usize;
|
||||
let mut has_effective_content = false;
|
||||
for part in &message.content {
|
||||
match part {
|
||||
DirectCodexUserContentPart::InputText { text } => {
|
||||
if text.trim().is_empty() {
|
||||
return Err("DirectProject input_text 不能为空".to_string());
|
||||
if !text.trim().is_empty() {
|
||||
has_effective_content = true;
|
||||
}
|
||||
}
|
||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||
reference_count = reference_count.saturating_add(1);
|
||||
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
||||
has_effective_content = true;
|
||||
}
|
||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||
reference_count = reference_count.saturating_add(1);
|
||||
validate_runtime_region_reference(&manifest, reference)?;
|
||||
has_effective_content = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||
}
|
||||
if !has_effective_content {
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +197,10 @@ fn render_ui_design_code_context(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item};
|
||||
use super::{
|
||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
||||
validate_direct_codex_user_item,
|
||||
};
|
||||
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
|
||||
use serde_json::json;
|
||||
use shared_contracts::game_creation_app::{
|
||||
@@ -295,7 +298,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_item_projection_uses_input_text_not_turn_input_text() {
|
||||
fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||
.expect("init project");
|
||||
@@ -303,12 +306,132 @@ mod tests {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [{"type": "input_text", "text": "你好"}]
|
||||
"content": [
|
||||
{"type": "input_text", "text": ""},
|
||||
{"type": "input_text", "text": "你好"},
|
||||
{"type": "input_text", "text": "\n"},
|
||||
{"type": "input_text", "text": "第二段"},
|
||||
{"type": "input_text", "text": "\n"},
|
||||
{"type": "input_text", "text": " "}
|
||||
]
|
||||
});
|
||||
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
||||
.expect("user response item should project");
|
||||
assert_eq!(projected["content"][0]["type"], "input_text");
|
||||
assert_ne!(projected["content"][0]["type"], "text");
|
||||
assert_eq!(projected["content"], item["content"]);
|
||||
let canonical = serde_json::from_value(item).expect("canonical user item");
|
||||
assert_eq!(
|
||||
direct_codex_user_item_to_prompt(root.path(), &canonical).expect("multiline prompt"),
|
||||
"你好\n第二段\n "
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_input_rejects_empty_or_whitespace_only_messages() {
|
||||
let root = prompt_context_project();
|
||||
for content in [
|
||||
json!([]),
|
||||
json!([{"type": "input_text", "text": ""}]),
|
||||
json!([
|
||||
{"type": "input_text", "text": ""},
|
||||
{"type": "input_text", "text": " \t\r\n\u{3000}"}
|
||||
]),
|
||||
] {
|
||||
let item = serde_json::from_value(json!({
|
||||
"type": "message", "role": "user", "id": "turn-1:user", "content": content
|
||||
}))
|
||||
.expect("canonical user item");
|
||||
assert_eq!(
|
||||
validate_direct_codex_user_item(root.path(), &item),
|
||||
Err("聊天内容不能为空".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_references_allow_missing_text_and_whitespace_parts() {
|
||||
let root = prompt_context_project();
|
||||
let asset_id = register_fixture_asset(
|
||||
root.path(),
|
||||
"assets/hero.png",
|
||||
GameCreationAppAssetKind::Character,
|
||||
"image/png",
|
||||
);
|
||||
for (reference, expected_text) in [
|
||||
(
|
||||
json!({"type": "agc_resource_reference", "resourceId": asset_id}),
|
||||
format!("[素材引用 resourceId={asset_id};项目路径=assets/hero.png]"),
|
||||
),
|
||||
(
|
||||
json!({"type": "agc_runtime_region_reference", "label": "主画面"}),
|
||||
"[运行画面区域:名称=主画面 ]".to_string(),
|
||||
),
|
||||
] {
|
||||
for (content, expected_prompt) in [
|
||||
(json!([reference.clone()]), expected_text.clone()),
|
||||
(
|
||||
json!([
|
||||
{"type": "input_text", "text": ""},
|
||||
{"type": "input_text", "text": "\n"},
|
||||
reference,
|
||||
{"type": "input_text", "text": " "}
|
||||
]),
|
||||
format!("\n{expected_text} "),
|
||||
),
|
||||
] {
|
||||
let item = json!({
|
||||
"type": "message", "role": "user", "id": "turn-1:user", "content": content
|
||||
});
|
||||
let canonical = serde_json::from_value(item.clone()).expect("canonical user item");
|
||||
assert_eq!(
|
||||
direct_codex_user_item_to_prompt(root.path(), &canonical)
|
||||
.expect("reference prompt"),
|
||||
expected_prompt
|
||||
);
|
||||
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
||||
.expect("reference history projection");
|
||||
assert_eq!(
|
||||
projected["content"].as_array().unwrap().len(),
|
||||
content.as_array().unwrap().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonempty_text_does_not_bypass_invalid_reference_validation() {
|
||||
let root = prompt_context_project();
|
||||
for (reference, expected_error) in [
|
||||
(
|
||||
json!({"type": "agc_resource_reference", "resourceId": " "}),
|
||||
"引用的素材 ID 无效,请移除后重新选择",
|
||||
),
|
||||
(
|
||||
json!({"type": "agc_resource_reference", "resourceId": "missing"}),
|
||||
"引用的素材已不存在,请移除后重新选择",
|
||||
),
|
||||
(
|
||||
json!({"type": "agc_runtime_region_reference", "label": " "}),
|
||||
"运行画面区域缺少名称",
|
||||
),
|
||||
(
|
||||
json!({"type": "agc_runtime_region_reference", "label": "主画面", "resourceIds": ["missing"]}),
|
||||
"引用的素材已不存在,请移除后重新选择",
|
||||
),
|
||||
] {
|
||||
let item = serde_json::from_value(json!({
|
||||
"type": "message", "role": "user", "id": "turn-1:user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "有真实文字"},
|
||||
reference,
|
||||
{"type": "input_text", "text": " "}
|
||||
]
|
||||
}))
|
||||
.expect("canonical user item");
|
||||
assert_eq!(
|
||||
validate_direct_codex_user_item(root.path(), &item),
|
||||
Err(expected_error.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -241,11 +241,72 @@ impl ProjectCommandTree {
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = child;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let Self::Group { pid, .. } = self;
|
||||
// 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。
|
||||
// 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。
|
||||
if !linux_project_command_group_has_live_members(*pid)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.request_owned_group_termination().map(|_| ())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_project_command_group_has_live_members(group: u32) -> Result<bool, String> {
|
||||
let inspect = || -> std::io::Result<bool> {
|
||||
let process_group = i32::try_from(group)
|
||||
.ok()
|
||||
.filter(|group| *group > 0)
|
||||
.ok_or_else(|| std::io::Error::other("受控命令进程组身份无效"))?;
|
||||
if unsafe { libc::kill(-process_group, 0) } != 0 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
return if error.raw_os_error() == Some(libc::ESRCH) {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(error)
|
||||
};
|
||||
}
|
||||
for entry in fs::read_dir("/proc")? {
|
||||
let entry = entry?;
|
||||
if entry.file_name().to_string_lossy().parse::<u32>().is_err() {
|
||||
continue;
|
||||
}
|
||||
let stat = match fs::read(entry.path().join("stat")) {
|
||||
Ok(stat) => stat,
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::NotFound
|
||||
|| error.raw_os_error() == Some(libc::ESRCH) =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let invalid_stat = || std::io::Error::other("无法解析 /proc 进程组状态");
|
||||
// comm 可以包含括号和非 UTF-8 字节;只解析最后一个分隔符后的 ASCII 字段。
|
||||
let end = stat
|
||||
.windows(2)
|
||||
.rposition(|pair| pair == b") ")
|
||||
.ok_or_else(invalid_stat)?;
|
||||
let tail = std::str::from_utf8(&stat[end + 2..]).map_err(|_| invalid_stat())?;
|
||||
let mut fields = tail.split_whitespace();
|
||||
let state = fields.next().ok_or_else(invalid_stat)?;
|
||||
let process_group = fields
|
||||
.nth(1)
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.ok_or_else(invalid_stat)?;
|
||||
if process_group == group && state != "Z" && state != "X" {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
};
|
||||
inspect().map_err(|error| format!("读取受控进程组存活状态失败:{error}"))
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
fn owned_project_command_group_identity_matches(
|
||||
expected: Option<&str>,
|
||||
@@ -2504,6 +2565,67 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn exited_group_accepts_orphan_zombies_but_rejects_live_members() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
const TEST: &str =
|
||||
"command_exec::tests::exited_group_accepts_orphan_zombies_but_rejects_live_members";
|
||||
const FIXTURE: &str = "AGC_COMMAND_ORPHAN_FIXTURE";
|
||||
if std::env::var_os(FIXTURE).is_none() {
|
||||
// subreaper 只影响隔离夹具,避免接管并行测试的子进程。
|
||||
let output = tokio::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", TEST, "--nocapture"])
|
||||
.env(FIXTURE, "1")
|
||||
.output()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
return;
|
||||
}
|
||||
assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0);
|
||||
let mut command = tokio::process::Command::new("/bin/sh");
|
||||
command
|
||||
.args(["-c", "sleep 60 & echo $!; read release"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped());
|
||||
command.as_std_mut().process_group(0);
|
||||
let mut child = command.spawn().unwrap();
|
||||
let tree = ProjectCommandTree::attach(&child).unwrap();
|
||||
let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
|
||||
let mut line = String::new();
|
||||
tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
|
||||
.await
|
||||
.unwrap();
|
||||
let descendant: i32 = line.trim().parse().unwrap();
|
||||
drop(child.stdin.take());
|
||||
child.wait().await.unwrap();
|
||||
let live_result = tree.after_main_exit(&mut child).await;
|
||||
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
|
||||
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
libc::waitid(
|
||||
libc::P_PID,
|
||||
descendant as u32,
|
||||
&mut info,
|
||||
libc::WEXITED | libc::WNOWAIT,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
let zombie_result = tree.after_main_exit(&mut child).await;
|
||||
assert_eq!(
|
||||
unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) },
|
||||
descendant
|
||||
);
|
||||
let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理");
|
||||
assert!(error.contains("leader 身份未确认"), "{error}");
|
||||
zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对");
|
||||
tree.after_main_exit(&mut child).await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_process_group_refuses_missing_or_reused_leader_identity() {
|
||||
assert!(owned_project_command_group_identity_matches(
|
||||
|
||||
@@ -652,7 +652,7 @@ pub(crate) fn import_local_godot_project(
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "project.create")?;
|
||||
if discover_local_godot_project_root(root)?.is_none() {
|
||||
return Err("所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string());
|
||||
return Err("所选工作区未在根目录或一层子目录发现 project.godot".to_string());
|
||||
}
|
||||
let _lock = acquire_project_write_lock(root, "project.create")?;
|
||||
import_local_godot_project_at(root, project_id.trim(), name.trim())
|
||||
|
||||
@@ -862,6 +862,48 @@ pub(crate) fn validate_game_creator_private_path_ancestors(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 客户端安装身份基线:`productName` / `identifier` 由构建期按渠道注入。
|
||||
///
|
||||
/// 默认渠道保持基线身份,其它渠道派生 `<基线>.<渠道>`,因此同一台设备上
|
||||
/// 不同渠道各自拥有独立的安装目录与 AppData 数据目录。
|
||||
#[cfg(windows)]
|
||||
const GAME_CREATOR_APP_IDENTIFIER: &str = "world.genarrative.ai-game-creator";
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_game_creator_packaged_app_data_leaf(name: &std::ffi::OsStr) -> bool {
|
||||
let Some(name) = name.to_str() else {
|
||||
return false;
|
||||
};
|
||||
let Some(remainder) = name.strip_prefix(GAME_CREATOR_APP_IDENTIFIER) else {
|
||||
return false;
|
||||
};
|
||||
if remainder.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// 渠道名是小写字母开头的 32 位以内小写字母、数字与连字符。
|
||||
remainder
|
||||
.strip_prefix('.')
|
||||
.is_some_and(|channel| !channel.is_empty() && channel.len() <= 32)
|
||||
}
|
||||
|
||||
/// 路径是否位于 `<平台配置根>/<安装身份目录>` 之内。提权助手是独立进程,
|
||||
/// 看不到父进程的配置目录覆盖,因此这里必须按目录名识别全部渠道身份。
|
||||
#[cfg(windows)]
|
||||
fn path_is_inside_game_creator_packaged_app_data(root: &Path, path: &Path) -> bool {
|
||||
let root = normalize_windows_policy_path(root);
|
||||
let path = normalize_windows_policy_path(path);
|
||||
let Ok(relative) = path.strip_prefix(&root) else {
|
||||
return false;
|
||||
};
|
||||
relative
|
||||
.components()
|
||||
.next()
|
||||
.is_some_and(|component| match component {
|
||||
std::path::Component::Normal(name) => is_game_creator_packaged_app_data_leaf(name),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Automatic ACL repair for managed paths is limited to objects AGC owns. A
|
||||
/// separate, explicit user-selected scope below covers native picker/project
|
||||
/// root results, including projects stored outside the current profile.
|
||||
@@ -900,21 +942,20 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
|
||||
// elevated helper runs in a fresh process, so the in-memory runtime
|
||||
// config-dir override is unavailable there; recognize the packaged
|
||||
// path from the user's profile as well.
|
||||
let packaged_app_data = home
|
||||
.join("AppData")
|
||||
.join("Local")
|
||||
.join("world.genarrative.ai-game-creator");
|
||||
if starts_with_path(&packaged_app_data) {
|
||||
#[cfg(windows)]
|
||||
if path_is_inside_game_creator_packaged_app_data(&home.join("AppData").join("Local"), &path)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
for environment_name in ["LOCALAPPDATA", "APPDATA"] {
|
||||
if let Some(root) = std::env::var_os(environment_name)
|
||||
.map(PathBuf::from)
|
||||
.filter(|candidate| candidate.is_absolute())
|
||||
{
|
||||
if starts_with_path(&root.join("world.genarrative.ai-game-creator")) {
|
||||
if path_is_inside_game_creator_packaged_app_data(&root, &path) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1096,10 +1137,9 @@ fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScop
|
||||
.filter(|candidate| candidate.is_absolute())
|
||||
{
|
||||
if is_builtin_root(home.join(".config").join("genarrative"))
|
||||
|| is_builtin_root(
|
||||
home.join("AppData")
|
||||
.join("Local")
|
||||
.join("world.genarrative.ai-game-creator"),
|
||||
|| path_is_inside_game_creator_packaged_app_data(
|
||||
&home.join("AppData").join("Local"),
|
||||
&path,
|
||||
)
|
||||
{
|
||||
return WindowsAclRepairScope::Managed;
|
||||
@@ -1110,7 +1150,7 @@ fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScop
|
||||
.map(PathBuf::from)
|
||||
.filter(|candidate| candidate.is_absolute())
|
||||
{
|
||||
if is_builtin_root(root.join("world.genarrative.ai-game-creator")) {
|
||||
if path_is_inside_game_creator_packaged_app_data(&root, &path) {
|
||||
return WindowsAclRepairScope::Managed;
|
||||
}
|
||||
}
|
||||
@@ -5044,18 +5084,38 @@ mod private_path_elevation_policy_tests {
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn verbatim_packaged_appdata_path_keeps_managed_repair_scope() {
|
||||
fn packaged_appdata_paths_keep_managed_repair_scope_for_every_channel() {
|
||||
let root = std::env::var_os("LOCALAPPDATA")
|
||||
.or_else(|| std::env::var_os("APPDATA"))
|
||||
.map(PathBuf::from)
|
||||
.expect("local appdata");
|
||||
let packaged = root.join("world.genarrative.ai-game-creator");
|
||||
let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display()));
|
||||
|
||||
assert!(game_creator_private_path_allows_auto_elevation(&verbatim));
|
||||
// 默认渠道是基线目录,其它渠道派生 `<基线>.<渠道>`;提权助手按目录名识别,
|
||||
// 两种身份都必须落在 managed 赋权范围内。
|
||||
for leaf in [
|
||||
"world.genarrative.ai-game-creator",
|
||||
"world.genarrative.ai-game-creator.release",
|
||||
"world.genarrative.ai-game-creator.beta-2",
|
||||
] {
|
||||
let packaged = root.join(leaf).join("diagnostics");
|
||||
let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display()));
|
||||
assert!(
|
||||
game_creator_private_path_allows_auto_elevation(&verbatim),
|
||||
"{leaf}"
|
||||
);
|
||||
assert_eq!(
|
||||
game_creator_runtime_config_repair_scope(&verbatim),
|
||||
WindowsAclRepairScope::Managed,
|
||||
"{leaf}"
|
||||
);
|
||||
}
|
||||
|
||||
// 相似前缀不是安装身份目录,不能落进 managed 赋权范围。
|
||||
let foreign = root.join("world.genarrative.ai-game-creator-backup");
|
||||
assert!(!game_creator_private_path_allows_auto_elevation(&foreign));
|
||||
assert_eq!(
|
||||
game_creator_runtime_config_repair_scope(&verbatim),
|
||||
WindowsAclRepairScope::Managed
|
||||
game_creator_runtime_config_repair_scope(&foreign),
|
||||
WindowsAclRepairScope::UserSelected
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2005,6 +2005,13 @@ fn show_startup_error_dialog(log_path: Option<&Path>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端产品名跟随构建期渠道身份:默认渠道是「陶泥儿」,其它渠道带渠道后缀
|
||||
/// (例如「陶泥儿 Release」)。同机并存的渠道客户端因此在窗口标题、任务栏与
|
||||
/// Alt-Tab 里可区分;默认渠道结果不变。
|
||||
pub(crate) fn game_creator_product_name(app: &tauri::AppHandle) -> String {
|
||||
app.package_info().name.clone()
|
||||
}
|
||||
|
||||
/// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir`
|
||||
/// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时
|
||||
/// `StartupLogSlot::fail` 仍然必须给出用户可见提示。
|
||||
@@ -2468,6 +2475,16 @@ fn main() {
|
||||
setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized");
|
||||
error
|
||||
})?;
|
||||
// 主窗口标题与产品名保持一致:配置里的标题来自基线配置,渠道后缀只
|
||||
// 由构建期身份决定,因此必须在这里按产品名覆盖。
|
||||
match app.get_webview_window("client") {
|
||||
Some(window) => {
|
||||
if let Err(error) = window.set_title(&game_creator_product_name(app.handle())) {
|
||||
app_log!("startup.window-title.failed: {error}");
|
||||
}
|
||||
}
|
||||
None => app_log!("startup.window-title.failed: 缺少 client 主窗口"),
|
||||
}
|
||||
spawn_project_snapshot_scheduler(app.handle().clone());
|
||||
if let Err(error) = builtin_plugins::initialize(&config_dir) {
|
||||
app_log!("startup.builtin-plugins.initialize.failed: {error}");
|
||||
|
||||
@@ -84,9 +84,15 @@ fn godot_metadata_is_link(metadata: &fs::Metadata) -> bool {
|
||||
metadata.file_type().is_symlink() || godot_metadata_is_reparse_point(metadata)
|
||||
}
|
||||
|
||||
/// Godot 工程标记:`project.godot` 解析为普通文件即命中。
|
||||
///
|
||||
/// 2026-09-21 解除“必须是普通文件”的限制:符号链接、Windows reparse point 与
|
||||
/// 硬链接一律跟随,不再因为 `project.godot` 本身是链接而拒绝整个工作区。判据只
|
||||
/// 保留“解析后仍是文件”,目录或悬空链接仍然不算命中。
|
||||
fn inspect_godot_project_marker(root: &Path) -> Result<bool, String> {
|
||||
let project_file = root.join("project.godot");
|
||||
let metadata = match fs::symlink_metadata(&project_file) {
|
||||
// `fs::metadata` 跟随符号链接 / reparse point,因此链接指向的真实对象才是判据。
|
||||
let metadata = match fs::metadata(&project_file) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => {
|
||||
@@ -96,64 +102,12 @@ fn inspect_godot_project_marker(root: &Path) -> Result<bool, String> {
|
||||
));
|
||||
}
|
||||
};
|
||||
if godot_metadata_is_link(&metadata) {
|
||||
return Err(format!(
|
||||
"Godot 项目文件不能是符号链接或 reparse point:{}",
|
||||
project_file.display()
|
||||
));
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
return Err(format!(
|
||||
"Godot 项目文件必须是普通文件:{}",
|
||||
"Godot 项目文件必须是文件:{}",
|
||||
project_file.display()
|
||||
));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if metadata.nlink() != 1 {
|
||||
return Err(format!(
|
||||
"Godot 项目文件不能是硬链接文件:{}",
|
||||
project_file.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
|
||||
};
|
||||
|
||||
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010;
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||||
let file = fs::File::open(&project_file).map_err(|error| {
|
||||
format!(
|
||||
"打开 Godot 项目文件失败:{}: {error}",
|
||||
project_file.display()
|
||||
)
|
||||
})?;
|
||||
// SAFETY: the structure is plain data initialized by GetFileInformationByHandle.
|
||||
let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
|
||||
// SAFETY: file owns a live handle and information is a valid output pointer.
|
||||
// 取不到句柄信息、目录与 reparse point 一律按拒绝处理,保持 fail-closed。
|
||||
if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0
|
||||
|| information.dwFileAttributes
|
||||
& (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)
|
||||
!= 0
|
||||
{
|
||||
return Err(format!(
|
||||
"读取 Godot 项目文件 Windows 身份失败:{}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
if information.nNumberOfLinks != 1 {
|
||||
return Err(format!(
|
||||
"Godot 项目文件不能是硬链接文件:{}",
|
||||
project_file.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -166,6 +120,62 @@ fn validate_godot_project_child_name(name: &std::ffi::OsStr) -> Result<String, S
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// 把模板自带的 Godot 工程显示名改成用户选择的项目名。
|
||||
///
|
||||
/// 只改 `[application]` 段里的 `config/name` 一行:Godot 用它当工程显示名与窗口标题,
|
||||
/// 工程身份仍是 `project.godot` 所在目录,改显示名不影响工程发现或相对根。找不到该行
|
||||
/// 时保持模板原样,不猜测插入位置;除这一行以外的字节逐字保留。
|
||||
pub(crate) fn apply_godot_project_display_name(root: &Path, name: &str) -> Result<(), String> {
|
||||
let path = root.join("project.godot");
|
||||
let text = match fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"读取 Godot 工程配置失败:{}: {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
|
||||
let ends_with_newline = text.ends_with('\n');
|
||||
let mut replaced = false;
|
||||
let mut lines = Vec::new();
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
if !replaced {
|
||||
if let Some(rest) = trimmed.strip_prefix("config/name") {
|
||||
let rest = rest.trim_start();
|
||||
if let Some(value) = rest.strip_prefix('=') {
|
||||
let value = value.trim();
|
||||
if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
|
||||
let indent = &line[..line.len() - trimmed.len()];
|
||||
lines.push(format!(
|
||||
"{indent}config/name=\"{}\"",
|
||||
escape_godot_project_string(name)
|
||||
));
|
||||
replaced = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(line.to_string());
|
||||
}
|
||||
if !replaced {
|
||||
return Ok(());
|
||||
}
|
||||
let mut updated = lines.join(newline);
|
||||
if ends_with_newline {
|
||||
updated.push_str(newline);
|
||||
}
|
||||
write_game_creator_private_file(&path, updated.as_bytes(), "Godot 工程配置")
|
||||
}
|
||||
|
||||
fn escape_godot_project_string(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
fn validate_manifest_godot_project_root(value: Option<&str>) -> Result<(), String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(());
|
||||
@@ -346,13 +356,9 @@ pub(crate) fn discover_local_godot_project_root(
|
||||
matches.push(validate_godot_project_child_name(&entry.file_name())?);
|
||||
}
|
||||
matches.sort();
|
||||
if matches.len() > 1 {
|
||||
return Err(format!(
|
||||
"工作区一层子目录中发现多个 Godot 项目:{}",
|
||||
matches.join("、")
|
||||
));
|
||||
}
|
||||
let Some(relative_root) = matches.pop() else {
|
||||
// 2026-09-21 解除“必须唯一”的限制:一层子目录出现多个 Godot 工程时按名称排序
|
||||
// 取第一个,结果确定且可复现,不再因为存在第二个工程而整体失败关闭。
|
||||
let Some(relative_root) = matches.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
@@ -694,9 +700,8 @@ pub(crate) fn import_local_godot_project_at(
|
||||
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
|
||||
return Err("Godot 工作区目录不存在或不是普通文件夹".to_string());
|
||||
}
|
||||
let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| {
|
||||
"所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()
|
||||
})?;
|
||||
let godot_project_root = discover_local_godot_project_root(root)?
|
||||
.ok_or_else(|| "所选工作区未在根目录或一层子目录发现 project.godot".to_string())?;
|
||||
if project_id.is_empty() {
|
||||
return Err("项目 ID 不能为空".to_string());
|
||||
}
|
||||
|
||||
@@ -133,17 +133,18 @@ fn root_godot_project_takes_priority_over_direct_child_projects() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_multiple_direct_child_godot_projects_before_writing_agent_metadata() {
|
||||
fn picks_the_first_direct_child_godot_project_in_name_order() {
|
||||
let workspace = godot_import_test_path("multiple-children");
|
||||
write_godot_project(&workspace.join("alpha"), "Alpha");
|
||||
write_godot_project(&workspace.join("beta"), "Beta");
|
||||
|
||||
let error = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous")
|
||||
.expect_err("multiple direct child Godot projects must fail");
|
||||
let result = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous")
|
||||
.expect("multiple direct child Godot projects must resolve deterministically");
|
||||
|
||||
assert!(error.contains("多个 Godot 项目"), "{error}");
|
||||
assert!(!workspace.join(".agent").exists());
|
||||
assert!(!workspace.join("alpha/.agent").exists());
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha"));
|
||||
assert_manifest_godot_root(&workspace, "alpha");
|
||||
assert!(workspace.join(".agent/manifest.json").is_file());
|
||||
// 未选中的候选工程不写任何 AGC 元数据。
|
||||
assert!(!workspace.join("beta/.agent").exists());
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
@@ -187,23 +188,21 @@ fn calibrates_existing_manifest_to_the_discovered_godot_root() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_layout_does_not_rewrite_an_existing_manifest() {
|
||||
fn ambiguous_layout_calibrates_an_existing_manifest_deterministically() {
|
||||
let workspace = godot_import_test_path("ambiguous-existing");
|
||||
init_local_game_project_at(&workspace, "existing-project", "Existing Project")
|
||||
.expect("initialize existing workspace");
|
||||
write_godot_project(&workspace.join("game"), "Game");
|
||||
write_godot_project(&workspace.join("other"), "Other");
|
||||
let manifest_path = workspace.join(".agent/manifest.json");
|
||||
let original = fs::read(&manifest_path).expect("read original manifest");
|
||||
|
||||
let error = import_local_godot_project_at(&workspace, "ignored", "Ignored")
|
||||
.expect_err("ambiguous existing workspace must fail");
|
||||
let result = import_local_godot_project_at(&workspace, "ignored", "Ignored")
|
||||
.expect("ambiguous existing workspace must calibrate to one candidate");
|
||||
|
||||
assert!(error.contains("多个 Godot 项目"), "{error}");
|
||||
assert_eq!(
|
||||
fs::read(&manifest_path).expect("read unchanged manifest"),
|
||||
original
|
||||
);
|
||||
assert_eq!(result.manifest.project_id, "existing-project");
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game"));
|
||||
assert_manifest_godot_root(&workspace, "game");
|
||||
assert!(!workspace.join("game/.agent").exists());
|
||||
assert!(!workspace.join("other/.agent").exists());
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
|
||||
@@ -286,7 +285,7 @@ fn manifest_read_rejects_unsafe_persisted_godot_project_root() {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() {
|
||||
fn accepts_symbolic_link_project_marker() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let workspace = godot_import_test_path("linked-marker");
|
||||
@@ -294,11 +293,51 @@ fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() {
|
||||
fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker");
|
||||
symlink("real.godot", workspace.join("project.godot")).expect("link project marker");
|
||||
|
||||
let error = import_local_godot_project_at(&workspace, "linked", "Linked")
|
||||
.expect_err("linked project.godot must fail");
|
||||
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
|
||||
.expect("linked project.godot must be accepted");
|
||||
|
||||
assert!(error.contains("符号链接"), "{error}");
|
||||
assert!(!workspace.join(".agent").exists());
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
|
||||
assert_manifest_godot_root(&workspace, ".");
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn accepts_windows_hard_link_project_marker() {
|
||||
let workspace = godot_import_test_path("windows-hard-link-marker");
|
||||
fs::create_dir_all(&workspace).expect("create hard link marker workspace");
|
||||
fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker");
|
||||
fs::hard_link(
|
||||
workspace.join("real.godot"),
|
||||
workspace.join("project.godot"),
|
||||
)
|
||||
.expect("hard link project marker");
|
||||
|
||||
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
|
||||
.expect("hard linked project.godot must be accepted");
|
||||
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
|
||||
assert_manifest_godot_root(&workspace, ".");
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn accepts_windows_reparse_project_marker() {
|
||||
let workspace = godot_import_test_path("windows-reparse-marker");
|
||||
fs::create_dir_all(&workspace).expect("create reparse marker workspace");
|
||||
fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker");
|
||||
if std::os::windows::fs::symlink_file("real.godot", workspace.join("project.godot")).is_err() {
|
||||
// 未开启开发者模式的机器创建文件符号链接需要额外权限,跳过而不是误报通过。
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
|
||||
.expect("reparse point project.godot must be accepted");
|
||||
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
|
||||
assert_manifest_godot_root(&workspace, ".");
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
|
||||
@@ -344,7 +383,7 @@ fn ignores_unrelated_symbolic_link_while_importing_a_unique_regular_child() {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_linked_marker_inside_a_regular_child_candidate() {
|
||||
fn accepts_linked_marker_inside_a_regular_child_candidate() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let workspace = godot_import_test_path("linked-child-marker");
|
||||
@@ -353,11 +392,12 @@ fn rejects_linked_marker_inside_a_regular_child_candidate() {
|
||||
fs::write(godot_root.join("real.godot"), "[application]\n").expect("write real marker");
|
||||
symlink("real.godot", godot_root.join("project.godot")).expect("link project marker");
|
||||
|
||||
let error = import_local_godot_project_at(&workspace, "linked", "Linked")
|
||||
.expect_err("linked marker in a regular child must fail");
|
||||
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
|
||||
.expect("linked marker in a regular child must be accepted");
|
||||
|
||||
assert!(error.contains("符号链接"), "{error}");
|
||||
assert!(!workspace.join(".agent").exists());
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game"));
|
||||
assert_manifest_godot_root(&workspace, "game");
|
||||
assert!(!godot_root.join(".agent").exists());
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
|
||||
@@ -397,6 +437,42 @@ fn rejects_non_godot_directory_without_writing_agent_metadata() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_only_the_godot_display_name_line() {
|
||||
let root = godot_import_test_path("display-name");
|
||||
fs::create_dir_all(&root).expect("create project root");
|
||||
fs::write(
|
||||
root.join("project.godot"),
|
||||
"config_version=5\n\n[application]\n\nconfig/name=\"模板名\"\nrun/main_scene=\"res://scenes/main.tscn\"\n",
|
||||
)
|
||||
.expect("write project.godot");
|
||||
|
||||
apply_godot_project_display_name(&root, "我的\"平台跳跃\"").expect("rewrite display name");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("project.godot")).expect("read project.godot"),
|
||||
"config_version=5\n\n[application]\n\nconfig/name=\"我的\\\"平台跳跃\\\"\"\nrun/main_scene=\"res://scenes/main.tscn\"\n"
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_a_godot_project_without_a_display_name_line_untouched() {
|
||||
let root = godot_import_test_path("no-display-name");
|
||||
fs::create_dir_all(&root).expect("create project root");
|
||||
let original =
|
||||
"config_version=5\n\n[application]\n\nrun/main_scene=\"res://scenes/main.tscn\"\n";
|
||||
fs::write(root.join("project.godot"), original).expect("write project.godot");
|
||||
|
||||
apply_godot_project_display_name(&root, "我的项目").expect("no display name is not an error");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("project.godot")).expect("read project.godot"),
|
||||
original
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
fn write_raw_manifest_fixture(workspace: &Path, payload: &serde_json::Value) -> (PathBuf, String) {
|
||||
let manifest_path = workspace.join(".agent/manifest.json");
|
||||
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
|
||||
|
||||
@@ -990,6 +990,16 @@ pub(crate) fn create_project_from_installed_template_at(
|
||||
&project_name,
|
||||
);
|
||||
}
|
||||
// Godot 模板同样按工程文件识别:走既有 Godot 导入流程,写入
|
||||
// `godotProjectRoot` 并按用户输入改写工程显示名,不生成 Web 占位入口。
|
||||
if discover_local_godot_project_root(&project_root)?.is_some() {
|
||||
apply_godot_project_display_name(&project_root, &project_name)?;
|
||||
return import_local_godot_project_at(
|
||||
&project_root,
|
||||
&format!("gameagent-{workspace_id}"),
|
||||
&project_name,
|
||||
);
|
||||
}
|
||||
init_local_game_project_at(
|
||||
&project_root,
|
||||
&format!("gameagent-{workspace_id}"),
|
||||
@@ -1492,6 +1502,49 @@ mod tests {
|
||||
fs::remove_dir_all(&projects_root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn godot_template_creates_native_project_with_relative_root_and_display_name() {
|
||||
let cache_root = tempfile::tempdir().expect("temp dir");
|
||||
let projects_root = unique_projects_root();
|
||||
let config_source = "config_version=5\n\n[application]\n\nconfig/name=\"Godot 模板\"\nrun/main_scene=\"res://scenes/main.tscn\"\n";
|
||||
let archive = build_archive(&[
|
||||
("project.godot", config_source.as_bytes()),
|
||||
("scenes/main.tscn", b"[gd_scene format=3]\n"),
|
||||
]);
|
||||
let mut summary = sample_summary();
|
||||
summary.id = "godot-fixture-template".to_string();
|
||||
summary.runtime = "godot".to_string();
|
||||
summary.entry = "project.godot".to_string();
|
||||
summary.zip_size_bytes = archive.len() as u64;
|
||||
summary.zip_sha256 = sha256_hex(&archive);
|
||||
let record = install_template_archive(cache_root.path(), &summary, &archive)
|
||||
.expect("install Godot template");
|
||||
|
||||
let result = create_project_from_installed_template_at(
|
||||
&projects_root,
|
||||
Path::new(&record.project_dir),
|
||||
Some("我的平台跳跃"),
|
||||
false,
|
||||
)
|
||||
.expect("create Godot project from template");
|
||||
|
||||
// Godot 模板走 Godot 导入:记录相对根,且不生成 Web 占位入口与并行目录。
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
|
||||
assert_eq!(result.manifest.name, "我的平台跳跃");
|
||||
let project_root = Path::new(&result.project_path);
|
||||
assert!(!project_root.join("game").exists());
|
||||
assert!(project_root.join("scenes/main.tscn").is_file());
|
||||
let config =
|
||||
fs::read_to_string(project_root.join("project.godot")).expect("read project.godot");
|
||||
assert!(config.contains("config/name=\"我的平台跳跃\""), "{config}");
|
||||
assert!(
|
||||
config.contains("run/main_scene=\"res://scenes/main.tscn\""),
|
||||
"只改显示名,其余行逐字保留:{config}"
|
||||
);
|
||||
assert!(!project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).exists());
|
||||
fs::remove_dir_all(&projects_root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_to_create_project_when_template_is_not_installed() {
|
||||
let projects_root = tempfile::tempdir().expect("temp dir");
|
||||
|
||||
@@ -1827,7 +1827,7 @@ async fn agent_runtime_command_exec_diagnostic_command_cannot_pass_verification_
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(observation.status, "ok");
|
||||
assert_observation_status(&observation, "ok");
|
||||
assert!(observation.summary.contains("只作为诊断结果"));
|
||||
assert!(observation
|
||||
.detail
|
||||
|
||||
@@ -2532,42 +2532,45 @@ fn project_directory_status_reports_workspace_relative_godot_root() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() {
|
||||
fn project_directory_status_resolves_ambiguous_direct_child_godot_projects() {
|
||||
let root = unique_project_path();
|
||||
for child in ["alpha", "beta"] {
|
||||
// 目录枚举顺序不可信:故意倒序创建,证明选择按名称而不是按创建顺序。
|
||||
for child in ["beta", "alpha"] {
|
||||
let godot_root = root.join(child);
|
||||
fs::create_dir_all(&godot_root).expect("create nested Godot project");
|
||||
fs::write(godot_root.join("project.godot"), "[application]\n")
|
||||
.expect("write nested project.godot");
|
||||
}
|
||||
|
||||
let error = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
|
||||
.expect_err("ambiguous Godot workspace must fail inspection");
|
||||
let status = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
|
||||
.expect("multiple direct child Godot projects must resolve deterministically");
|
||||
|
||||
assert!(error.contains("多个 Godot 项目"), "{error}");
|
||||
assert!(status.is_godot_project);
|
||||
assert_eq!(status.godot_project_root.as_deref(), Some("alpha"));
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn godot_import_command_rejects_ambiguity_before_creating_the_project_lock() {
|
||||
fn godot_import_command_resolves_child_ambiguity_and_releases_the_lock() {
|
||||
let root = unique_project_path();
|
||||
for child in ["alpha", "beta"] {
|
||||
for child in ["beta", "alpha"] {
|
||||
let godot_root = root.join(child);
|
||||
fs::create_dir_all(&godot_root).expect("create nested Godot project");
|
||||
fs::write(godot_root.join("project.godot"), "[application]\n")
|
||||
.expect("write nested project.godot");
|
||||
}
|
||||
|
||||
let error = import_local_godot_project(
|
||||
let result = import_local_godot_project(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"ambiguous".to_string(),
|
||||
"Ambiguous".to_string(),
|
||||
)
|
||||
.expect_err("ambiguous Godot workspace must fail before locking");
|
||||
.expect("ambiguous Godot workspace must import the first candidate deterministically");
|
||||
|
||||
assert!(error.contains("多个 Godot 项目"), "{error}");
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha"));
|
||||
assert!(root.join(".agent/manifest.json").is_file());
|
||||
assert!(!root.join("beta/.agent").exists());
|
||||
assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists());
|
||||
assert!(!root.join(".agent").exists());
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user