Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34b1ad5d5c | |||
| c5afc02e94 | |||
| b5e1c0f1d6 | |||
| 15e7d6065e | |||
| 2e23a54211 | |||
| 4863c8c066 | |||
| 33036b65fc | |||
| f4ebc45612 | |||
| 19ed1776f1 | |||
| 4d313a44eb | |||
| 01ed9fadc6 | |||
| 02a7abeb3f | |||
| 6845cdcbc5 | |||
| 4044ffd89b | |||
| 8d0c021126 | |||
| 1cb91f9444 | |||
| 2e31a485d8 | |||
| 4ad131d74d | |||
| 886bdc06ed | |||
| 562e1ba586 | |||
| e18067e028 | |||
| 613d62fdb2 | |||
| b4ce6877c8 | |||
| e53992fdc2 | |||
| a5ee06a4c7 | |||
| 7b57cba14d | |||
| 55cb047654 | |||
| beebcca4de | |||
| 9bf35e8745 | |||
| 90207bc1d5 | |||
| 2f248ea0c3 | |||
| 117d482f93 |
@@ -3,12 +3,15 @@ import { afterEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
createAdminAccount,
|
||||
executeAdminRechargeRefund,
|
||||
getAdminAgcTemplates,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
updateAdminAccount,
|
||||
updateAdminAgcTemplate,
|
||||
uploadAdminEditorShowcaseCampaignImage,
|
||||
upsertAdminFeatureGateConfig,
|
||||
upsertProfileWalletConfig,
|
||||
@@ -18,6 +21,95 @@ 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('模板批量导入走 multipart,不预设 JSON Content-Type', async () => {
|
||||
const imported = {
|
||||
revision: 'rev-2',
|
||||
writable: true,
|
||||
templates: [],
|
||||
imported: [
|
||||
{
|
||||
id: 'alpha',
|
||||
templateVersion: '0.1.0',
|
||||
zipSizeBytes: 4,
|
||||
zipSha256: 'a'.repeat(64),
|
||||
reusedObjects: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'manifest',
|
||||
JSON.stringify({ expectedRevision: 'rev-1', templates: [] }),
|
||||
);
|
||||
form.append(
|
||||
'zip_0',
|
||||
new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }),
|
||||
);
|
||||
|
||||
expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported);
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('/admin/api/agc-templates/import');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.body).toBe(form);
|
||||
expect(init.headers).not.toHaveProperty('Content-Type');
|
||||
expect(init.headers.Authorization).toBe('Bearer admin-token');
|
||||
});
|
||||
|
||||
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AdminAccountListResponse,
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||
AdminCreateAccountRequest,
|
||||
AdminCreateAccountResponse,
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
AdminExternalApiKeyListQuery,
|
||||
AdminExternalApiKeyListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminImportAgcTemplatesResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
@@ -47,6 +49,7 @@ import type {
|
||||
AdminTrackingEventListResponse,
|
||||
AdminUpdateAccountRequest,
|
||||
AdminUpdateAccountResponse,
|
||||
AdminUpdateAgcTemplateRequest,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
@@ -85,6 +88,8 @@ interface AdminRequestOptions {
|
||||
method?: string;
|
||||
token?: string;
|
||||
body?: unknown;
|
||||
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
|
||||
formData?: FormData;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
@@ -172,6 +177,8 @@ export async function request<T>(
|
||||
if (typeof options.body !== 'undefined') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(options.body);
|
||||
} else if (options.formData) {
|
||||
init.body = options.formData;
|
||||
}
|
||||
|
||||
const response = await fetch(buildRequestUrl(path), init);
|
||||
@@ -1176,3 +1183,33 @@ 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 },
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */
|
||||
export function importAdminAgcTemplates(token: string, formData: FormData) {
|
||||
return request<AdminImportAgcTemplatesResponse>(
|
||||
'/admin/api/agc-templates/import',
|
||||
{
|
||||
token,
|
||||
method: 'POST',
|
||||
formData,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1033,3 +1033,67 @@ 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;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplateItemPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
runtime: string;
|
||||
engine: string;
|
||||
engineVersion: string;
|
||||
templateVersion: string;
|
||||
entry: string;
|
||||
zipField: string;
|
||||
coverField: string;
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplatesManifest {
|
||||
expectedRevision: string;
|
||||
templates: AdminImportAgcTemplateItemPayload[];
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplateResult {
|
||||
id: string;
|
||||
templateVersion: string;
|
||||
zipSizeBytes: number;
|
||||
zipSha256: string;
|
||||
reusedObjects: boolean;
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplatesResponse
|
||||
extends AdminAgcTemplateLibraryResponse {
|
||||
imported: AdminImportAgcTemplateResult[];
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ 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';
|
||||
@@ -294,6 +295,12 @@ 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}
|
||||
|
||||
@@ -53,6 +53,7 @@ const routeIcons = {
|
||||
'project-snapshots': FolderArchive,
|
||||
accounts: Users,
|
||||
'agc-models': ListChecks,
|
||||
'agc-templates': Images,
|
||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||
|
||||
export function AdminShell({
|
||||
|
||||
@@ -147,3 +147,30 @@ 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);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export type AdminRouteId =
|
||||
| 'editor-assets'
|
||||
| 'project-snapshots'
|
||||
| 'agc-models'
|
||||
| 'agc-templates'
|
||||
| 'accounts';
|
||||
|
||||
export type AdminTabPermission = Exclude<
|
||||
@@ -53,6 +54,7 @@ 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,3 +1,4 @@
|
||||
import '@genarrative/shared/styles.css';
|
||||
import './styles/admin.css';
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
attachCoverFiles,
|
||||
buildTemplateImportFormData,
|
||||
deriveTemplateUploadRow,
|
||||
parseTemplateTags,
|
||||
sanitizeTemplateId,
|
||||
TEMPLATE_IMPORT_MAX_BATCH,
|
||||
type TemplateUploadRow,
|
||||
validateTemplateUploadRows,
|
||||
} from './adminAgcTemplateUploadModel';
|
||||
|
||||
function zipFile(name: string) {
|
||||
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
|
||||
type: 'application/zip',
|
||||
});
|
||||
}
|
||||
|
||||
function coverFile(name: string) {
|
||||
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
|
||||
type: 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
function row(zipName: string, patch: Partial<TemplateUploadRow> = {}) {
|
||||
return { ...deriveTemplateUploadRow(zipFile(zipName)), ...patch };
|
||||
}
|
||||
|
||||
describe('sanitizeTemplateId', () => {
|
||||
it('lowercases, keeps whitelisted characters and drops traversal', () => {
|
||||
expect(sanitizeTemplateId('Cocos Empty 2D.zip')).toBe('cocos-empty-2d');
|
||||
expect(sanitizeTemplateId('../../evil.zip')).toBe('evil');
|
||||
expect(sanitizeTemplateId('a..b.zip')).toBe('a.b');
|
||||
expect(sanitizeTemplateId('__hidden__')).toBe('hidden__');
|
||||
expect(sanitizeTemplateId(`${'x'.repeat(90)}.zip`)).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveTemplateUploadRow', () => {
|
||||
it('starts from safe defaults so a batch only needs covers attached', () => {
|
||||
const derived = row('my-template.zip');
|
||||
expect(derived.id).toBe('my-template');
|
||||
expect(derived.title).toBe('my-template');
|
||||
expect(derived.runtime).toBe('html');
|
||||
expect(derived.templateVersion).toBe('0.1.0');
|
||||
expect(derived.entry).toBe('index.html');
|
||||
expect(derived.coverFile).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachCoverFiles', () => {
|
||||
it('matches covers by template id and ignores non-image files', () => {
|
||||
const rows = [row('alpha.zip'), row('beta.zip')];
|
||||
const covers = [
|
||||
coverFile('alpha.png'),
|
||||
coverFile('beta.webp'),
|
||||
coverFile('notes.txt'),
|
||||
];
|
||||
|
||||
const next = attachCoverFiles(rows, covers);
|
||||
|
||||
expect(next[0]?.coverFile?.name).toBe('alpha.png');
|
||||
expect(next[1]?.coverFile?.name).toBe('beta.webp');
|
||||
});
|
||||
|
||||
it('keeps an already matched cover when the new selection has no partner', () => {
|
||||
const rows = [row('alpha.zip', { coverFile: coverFile('alpha.png') })];
|
||||
const next = attachCoverFiles(rows, [coverFile('other.png')]);
|
||||
expect(next[0]?.coverFile?.name).toBe('alpha.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTemplateUploadRows', () => {
|
||||
it('accepts a complete batch', () => {
|
||||
const rows = [
|
||||
row('alpha.zip', { coverFile: coverFile('alpha.png') }),
|
||||
row('beta.zip', { coverFile: coverFile('beta.png'), runtime: 'cocos' }),
|
||||
];
|
||||
expect(validateTemplateUploadRows(rows)).toEqual({});
|
||||
});
|
||||
|
||||
it('reports missing cover, duplicate id and invalid fields per row', () => {
|
||||
const rows = [
|
||||
row('alpha.zip'),
|
||||
row('alpha.zip', { coverFile: coverFile('alpha.png'), key: 'second' }),
|
||||
row('gamma.zip', {
|
||||
coverFile: coverFile('gamma.png'),
|
||||
runtime: 'docker',
|
||||
templateVersion: 'Bad Version',
|
||||
entry: '../escape.js',
|
||||
title: ' ',
|
||||
}),
|
||||
];
|
||||
const errors = validateTemplateUploadRows(rows);
|
||||
expect(errors['alpha.zip']).toContain('缺少封面');
|
||||
expect(errors.second).toContain('ID 在本批次内重复');
|
||||
expect(errors['gamma.zip']).toContain('名称必须是 1-80 个字符');
|
||||
});
|
||||
|
||||
it('rejects a batch larger than the server limit', () => {
|
||||
const rows = Array.from(
|
||||
{ length: TEMPLATE_IMPORT_MAX_BATCH + 1 },
|
||||
(_, index) =>
|
||||
row(`template-${index}.zip`, {
|
||||
coverFile: coverFile(`template-${index}.png`),
|
||||
}),
|
||||
);
|
||||
const errors = validateTemplateUploadRows(rows);
|
||||
expect(Object.keys(errors)).toHaveLength(TEMPLATE_IMPORT_MAX_BATCH + 1);
|
||||
expect(errors['template-0.zip']).toContain('单批最多上传');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTemplateImportFormData', () => {
|
||||
it('writes manifest field names and attaches zip / cover per row', () => {
|
||||
const rows = [
|
||||
row('alpha.zip', {
|
||||
coverFile: coverFile('alpha.png'),
|
||||
tags: '起步, 起步 2d',
|
||||
title: ' Alpha ',
|
||||
}),
|
||||
row('beta.zip', { coverFile: coverFile('beta.png') }),
|
||||
];
|
||||
|
||||
const form = buildTemplateImportFormData('a'.repeat(64), rows);
|
||||
const manifest = JSON.parse(String(form.get('manifest')));
|
||||
|
||||
expect(manifest.expectedRevision).toBe('a'.repeat(64));
|
||||
expect(manifest.templates).toHaveLength(2);
|
||||
expect(manifest.templates[0]).toMatchObject({
|
||||
id: 'alpha',
|
||||
title: 'Alpha',
|
||||
tags: ['起步', '2d'],
|
||||
zipField: 'zip_0',
|
||||
coverField: 'cover_0',
|
||||
});
|
||||
expect(manifest.templates[1]).toMatchObject({
|
||||
id: 'beta',
|
||||
zipField: 'zip_1',
|
||||
coverField: 'cover_1',
|
||||
});
|
||||
expect((form.get('zip_0') as File).name).toBe('alpha.zip');
|
||||
expect((form.get('cover_1') as File).name).toBe('beta.png');
|
||||
});
|
||||
|
||||
it('parses tags with dedupe and caps them at the server limit', () => {
|
||||
expect(parseTemplateTags(' a, a,b c ')).toEqual(['a', 'b', 'c']);
|
||||
const many = Array.from({ length: 20 }, (_, index) => `t${index}`).join(
|
||||
',',
|
||||
);
|
||||
expect(parseTemplateTags(many)).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import type {
|
||||
AdminImportAgcTemplateItemPayload,
|
||||
AdminImportAgcTemplatesManifest,
|
||||
} from '../api/adminApiTypes';
|
||||
|
||||
/** 与服务端 module-assets 的导入上限保持一致;超出请分批或改走 CLI。 */
|
||||
export const TEMPLATE_IMPORT_MAX_BATCH = 20;
|
||||
export const TEMPLATE_UPLOAD_RUNTIMES = [
|
||||
'html',
|
||||
'unity',
|
||||
'godot',
|
||||
'cocos',
|
||||
] as const;
|
||||
const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
||||
const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u;
|
||||
const ENTRY_PATTERN = /^(?![\\/:])(?!.*\.\.)[^\s\\:]+$/u;
|
||||
const COVER_EXTENSION_PATTERN = /\.(png|jpe?g|webp)$/iu;
|
||||
|
||||
export interface TemplateUploadRow {
|
||||
/** 稳定行标识:用 ZIP 文件名,重选文件后不会串行。 */
|
||||
key: string;
|
||||
zipFile: File;
|
||||
coverFile: File | null;
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string;
|
||||
runtime: string;
|
||||
engine: string;
|
||||
engineVersion: string;
|
||||
templateVersion: string;
|
||||
entry: string;
|
||||
}
|
||||
|
||||
/** 文件名 → 模板 ID:小写、只留白名单字符,并去掉 `..` 与开头的非字母数字。 */
|
||||
export function sanitizeTemplateId(value: string) {
|
||||
return value
|
||||
.replace(/\.zip$/iu, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\.{2,}/gu, '.')
|
||||
.replace(/[^a-z0-9._-]+/gu, '-')
|
||||
.replace(/^[^a-z0-9]+/u, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
export function deriveTemplateUploadRow(zipFile: File): TemplateUploadRow {
|
||||
const id = sanitizeTemplateId(zipFile.name);
|
||||
return {
|
||||
key: zipFile.name,
|
||||
zipFile,
|
||||
coverFile: null,
|
||||
id,
|
||||
title: id,
|
||||
summary: '',
|
||||
tags: '',
|
||||
runtime: 'html',
|
||||
engine: '',
|
||||
engineVersion: '',
|
||||
templateVersion: '0.1.0',
|
||||
entry: 'index.html',
|
||||
};
|
||||
}
|
||||
|
||||
/** 封面按「与模板 ID 同名的图片」匹配,一次多选即可覆盖整批。 */
|
||||
export function attachCoverFiles(
|
||||
rows: TemplateUploadRow[],
|
||||
coverFiles: File[],
|
||||
): TemplateUploadRow[] {
|
||||
const covers = new Map<string, File>();
|
||||
for (const file of coverFiles) {
|
||||
if (!COVER_EXTENSION_PATTERN.test(file.name)) continue;
|
||||
const stem = sanitizeTemplateId(
|
||||
file.name.replace(COVER_EXTENSION_PATTERN, ''),
|
||||
);
|
||||
if (!covers.has(stem)) covers.set(stem, file);
|
||||
}
|
||||
return rows.map((row) => {
|
||||
const matched = covers.get(row.id.trim().toLowerCase()) ?? null;
|
||||
return matched ? { ...row, coverFile: matched } : row;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseTemplateTags(value: string) {
|
||||
const seen = new Set<string>();
|
||||
const tags: string[] = [];
|
||||
for (const raw of value.split(/[,,\s]+/u)) {
|
||||
const tag = raw.trim();
|
||||
if (!tag || seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
tags.push(tag);
|
||||
}
|
||||
return tags.slice(0, 16);
|
||||
}
|
||||
|
||||
/** 逐行校验:返回 row.key → 错误文案;空对象表示整批可以提交。 */
|
||||
export function validateTemplateUploadRows(
|
||||
rows: TemplateUploadRow[],
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
const fail = (row: TemplateUploadRow, message: string) => {
|
||||
if (!errors[row.key]) errors[row.key] = message;
|
||||
};
|
||||
if (rows.length === 0) return errors;
|
||||
if (rows.length > TEMPLATE_IMPORT_MAX_BATCH) {
|
||||
for (const row of rows) {
|
||||
fail(row, `单批最多上传 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
for (const row of rows) {
|
||||
const id = row.id.trim();
|
||||
if (!TEMPLATE_ID_PATTERN.test(id)) {
|
||||
fail(
|
||||
row,
|
||||
'ID 必须是 1-64 位小写字母、数字、点、下划线或连字符,且以字母数字开头',
|
||||
);
|
||||
} else if (seen.has(id)) {
|
||||
fail(row, 'ID 在本批次内重复');
|
||||
} else {
|
||||
seen.add(id);
|
||||
}
|
||||
if (!row.title.trim() || row.title.trim().length > 80) {
|
||||
fail(row, '名称必须是 1-80 个字符');
|
||||
}
|
||||
if (row.summary.trim().length > 1000) {
|
||||
fail(row, '简介最多 1000 个字符');
|
||||
}
|
||||
if (!TEMPLATE_VERSION_PATTERN.test(row.templateVersion.trim())) {
|
||||
fail(row, '版本号必须是 1-32 位小写字母、数字、点、下划线或连字符');
|
||||
}
|
||||
if (
|
||||
!TEMPLATE_UPLOAD_RUNTIMES.includes(
|
||||
row.runtime as (typeof TEMPLATE_UPLOAD_RUNTIMES)[number],
|
||||
)
|
||||
) {
|
||||
fail(row, `运行时只能是 ${TEMPLATE_UPLOAD_RUNTIMES.join(' / ')}`);
|
||||
}
|
||||
if (!ENTRY_PATTERN.test(row.entry.trim())) {
|
||||
fail(row, 'entry 必须是模板包内的相对路径');
|
||||
}
|
||||
if (!row.coverFile) {
|
||||
fail(row, '缺少封面:请上传与模板 ID 同名的 PNG / JPEG / WebP');
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function buildTemplateImportManifest(
|
||||
expectedRevision: string,
|
||||
rows: TemplateUploadRow[],
|
||||
): AdminImportAgcTemplatesManifest {
|
||||
return {
|
||||
expectedRevision,
|
||||
templates: rows.map((row, index) => {
|
||||
const item: AdminImportAgcTemplateItemPayload = {
|
||||
id: row.id.trim(),
|
||||
title: row.title.trim(),
|
||||
summary: row.summary.trim(),
|
||||
tags: parseTemplateTags(row.tags),
|
||||
runtime: row.runtime,
|
||||
engine: row.engine.trim(),
|
||||
engineVersion: row.engineVersion.trim(),
|
||||
templateVersion: row.templateVersion.trim(),
|
||||
entry: row.entry.trim(),
|
||||
zipField: `zip_${index}`,
|
||||
coverField: `cover_${index}`,
|
||||
};
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTemplateImportFormData(
|
||||
expectedRevision: string,
|
||||
rows: TemplateUploadRow[],
|
||||
): FormData {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'manifest',
|
||||
JSON.stringify(buildTemplateImportManifest(expectedRevision, rows)),
|
||||
);
|
||||
rows.forEach((row, index) => {
|
||||
form.append(`zip_${index}`, row.zipFile, row.zipFile.name);
|
||||
if (row.coverFile) {
|
||||
form.append(`cover_${index}`, row.coverFile, row.coverFile.name);
|
||||
}
|
||||
});
|
||||
return form;
|
||||
}
|
||||
@@ -13,6 +13,137 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-dialog {
|
||||
border-radius: 10px;
|
||||
max-width: min(1180px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-pickers {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-picker {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-picker input[type='file'] {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 上传行数多时表格自己滚动,窄屏不撑坏整页布局。 */
|
||||
.admin-agc-template-upload-scroll {
|
||||
max-height: min(52vh, 520px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-file {
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-row-error {
|
||||
margin-top: 4px;
|
||||
color: #b3261e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.admin-agc-template-filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-dialog {
|
||||
max-width: calc(100vw - 12px);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev-stack": "node scripts/start-dev-stack.mjs",
|
||||
"build": "node scripts/build-release.mjs",
|
||||
"release:upload": "node scripts/release-upload.mjs",
|
||||
"nsis:prepare": "node scripts/ensure-nsis-toolset.mjs",
|
||||
"skill-pack:check": "node scripts/check-skill-pack.mjs",
|
||||
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
|
||||
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
|
||||
@@ -75,6 +76,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/three": "^0.184.1",
|
||||
"jszip": "^3.10.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.8.2",
|
||||
"vitest": "^0.34.6"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
generateUpdateManifest,
|
||||
prepareReleaseVersion,
|
||||
resolveManifestPlatformKeys,
|
||||
resolveReleaseContext,
|
||||
resolveReleasePartition,
|
||||
runTauriBuild,
|
||||
@@ -20,10 +21,13 @@ import {
|
||||
} from './verify-updater-signature.mjs';
|
||||
|
||||
/**
|
||||
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 universal 包 → 双架构 smoke → 生成 universal DMG
|
||||
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 arm64 单架构包 → arm64 隔离 smoke → 生成 arm64 DMG
|
||||
* → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。
|
||||
*
|
||||
* 边界:
|
||||
* - 只出 Apple Silicon(arm64)单架构:清单只登记 `darwin-aarch64`。Intel 侧要可用,前提是随包 Node
|
||||
* 也能按架构各带一份(`stage-node-runtime.mjs` 对 universal 目标失败关闭);在实现之前**不得**
|
||||
* 把 arm64 产物登记成 `darwin-x86_64`,否则 Intel 客户端会装到跑不起来的包。
|
||||
* - Apple 签名与公证暂缺:本入口剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,但**不能传
|
||||
* `--no-sign`** —— 该标志同时会跳过 updater 的 minisign 签名,产物就没有 `.sig`;
|
||||
* 未签名 + 未公证必须显式记录而非静默通过;
|
||||
@@ -94,11 +98,14 @@ 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');
|
||||
const context = resolveReleaseContext(['--target=universal-apple-darwin']);
|
||||
// 单架构目标:清单侧 `resolveManifestPlatformKeys` 只为它登记 darwin-aarch64。
|
||||
const macTarget = 'aarch64-apple-darwin';
|
||||
const context = resolveReleaseContext([`--target=${macTarget}`]);
|
||||
const partition = resolveReleasePartition(context.channel, context.target);
|
||||
const version = await prepareReleaseVersion(context);
|
||||
// 首装包名必须保持 `<产品名>_<版本>_universal.dmg`:清单侧按该后缀唯一匹配本次产物。
|
||||
const firstInstallName = `${productName}_${version}_universal.dmg`;
|
||||
// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`,
|
||||
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
|
||||
const firstInstallName = `${productName}_${version}_aarch64.dmg`;
|
||||
|
||||
// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则
|
||||
// 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中);
|
||||
@@ -117,7 +124,7 @@ for (const stale of [
|
||||
}
|
||||
|
||||
const args = [
|
||||
'--target=universal-apple-darwin',
|
||||
`--target=${macTarget}`,
|
||||
'--bundles',
|
||||
'app',
|
||||
'--ci',
|
||||
@@ -132,16 +139,13 @@ const command = (binary, argv, options = {}) =>
|
||||
runTauriBuild(args, context);
|
||||
|
||||
const app = path.join(context.bundleRoot, 'macos', appBundleName);
|
||||
for (const architecture of ['arm64', 'x86_64']) {
|
||||
command(process.execPath, [
|
||||
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
|
||||
app,
|
||||
architecture,
|
||||
'--universal',
|
||||
]);
|
||||
}
|
||||
command(process.execPath, [
|
||||
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
|
||||
app,
|
||||
'arm64',
|
||||
]);
|
||||
|
||||
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_universal.dmg`。
|
||||
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_aarch64.dmg`。
|
||||
const dmgDirectory = path.join(context.bundleRoot, 'macos');
|
||||
fs.mkdirSync(dmgDirectory, { recursive: true });
|
||||
const dmg = path.join(dmgDirectory, firstInstallName);
|
||||
@@ -170,7 +174,7 @@ const release = await generateUpdateManifest(context);
|
||||
assert.equal(
|
||||
path.resolve(release.downloadArtifact),
|
||||
path.resolve(dmg),
|
||||
'首装包必须锁定本次生成的 universal DMG',
|
||||
'首装包必须锁定本次生成的 arm64 DMG',
|
||||
);
|
||||
|
||||
// 上传前门禁:用产物里烘焙的公钥复核更新包签名。验不过就停在这里,绝不写 OSS。
|
||||
@@ -266,8 +270,9 @@ fs.writeFileSync(
|
||||
firstInstallSha256: dmgHash,
|
||||
manifest: 'latest.json',
|
||||
},
|
||||
smokes: ['arm64', 'x86_64'],
|
||||
intelSmoke: process.arch === 'arm64' ? 'Rosetta' : 'native',
|
||||
// 单架构发布:只跑 arm64 隔离 smoke;Intel 未支持(清单里没有 darwin-x86_64 键)。
|
||||
smokes: ['arm64'],
|
||||
manifestPlatformKeys: resolveManifestPlatformKeys(context.target),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
defaultEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
} from './cargo-features.mjs';
|
||||
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
|
||||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -877,14 +878,19 @@ export async function buildRelease(
|
||||
args = [],
|
||||
{
|
||||
prepareVersion = prepareReleaseVersion,
|
||||
prepareToolset = prepareNsisToolsetForRelease,
|
||||
build = runTauriBuild,
|
||||
generateManifest = generateUpdateManifest,
|
||||
} = {},
|
||||
) {
|
||||
const context = resolveReleaseContext(args);
|
||||
if (!args.includes('--no-bundle')) await prepareVersion(context);
|
||||
const bundling = !args.includes('--no-bundle');
|
||||
if (bundling) await prepareVersion(context);
|
||||
// Tauri bundler 下载 NSIS 工具链时不重试,网络截断会直接毁掉整次打包;
|
||||
// 因此打包前先在 Windows 目标上预置(详见 nsis-toolset.mjs)。
|
||||
if (bundling) await prepareToolset(context, { bundling });
|
||||
build(args, context);
|
||||
if (!args.includes('--no-bundle')) return generateManifest(context);
|
||||
if (bundling) return generateManifest(context);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -590,6 +590,71 @@ test('no-bundle smoke skips version writes and manifest generation', async () =>
|
||||
assert.deepEqual(steps, ['dev']);
|
||||
});
|
||||
|
||||
test('Windows 打包在 Tauri 构建前预置 NSIS 工具链', async () => {
|
||||
const events = [];
|
||||
await buildRelease(['--target', windowsTarget], {
|
||||
prepareVersion: () => {
|
||||
events.push('version');
|
||||
},
|
||||
prepareToolset: (context, options) => {
|
||||
events.push(`toolset:${context.target}:${options.bundling}`);
|
||||
},
|
||||
build: () => {
|
||||
events.push('build');
|
||||
},
|
||||
generateManifest: () => {
|
||||
events.push('manifest');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
'version',
|
||||
`toolset:${windowsTarget}:true`,
|
||||
'build',
|
||||
'manifest',
|
||||
]);
|
||||
});
|
||||
|
||||
test('NSIS 工具链预置失败即失败关闭,不进入 Tauri 构建', async () => {
|
||||
const events = [];
|
||||
await assert.rejects(
|
||||
buildRelease(['--target', windowsTarget], {
|
||||
prepareVersion: () => {
|
||||
events.push('version');
|
||||
},
|
||||
prepareToolset: () => {
|
||||
throw new Error('NSIS 工具链预置失败:下载 nsis-3.11.zip 失败');
|
||||
},
|
||||
build: () => {
|
||||
events.push('build');
|
||||
},
|
||||
generateManifest: () => {
|
||||
events.push('manifest');
|
||||
},
|
||||
}),
|
||||
/NSIS 工具链预置失败/u,
|
||||
);
|
||||
assert.deepEqual(events, ['version']);
|
||||
});
|
||||
|
||||
test('--no-bundle 不预置 NSIS 工具链', async () => {
|
||||
const steps = [];
|
||||
await buildRelease(['--no-bundle', '--target', windowsTarget], {
|
||||
prepareVersion: () => {
|
||||
steps.push('version');
|
||||
},
|
||||
prepareToolset: () => {
|
||||
steps.push('toolset');
|
||||
},
|
||||
build: () => {
|
||||
steps.push('build');
|
||||
},
|
||||
generateManifest: () => {
|
||||
steps.push('manifest');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(steps, ['build']);
|
||||
});
|
||||
|
||||
test('release stages Node before Tauri and injects its resource mapping only for bundles', () => {
|
||||
const context = resolveReleaseContext(['--target', windowsTarget]);
|
||||
const events = [];
|
||||
|
||||
@@ -287,6 +287,13 @@ 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(
|
||||
@@ -294,8 +301,7 @@ try {
|
||||
/(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test(
|
||||
file,
|
||||
) ||
|
||||
(/(^|\/)node_modules(\/|$)/.test(file) &&
|
||||
!file.startsWith('game-runtime/node/node_modules/npm')),
|
||||
(/(^|\/)node_modules(\/|$)/.test(file) && !allowedNodeModules(file)),
|
||||
),
|
||||
);
|
||||
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Jenkins Windows 预检入口:在数分钟的 Rust 编译之前完成 NSIS 工具链预置。
|
||||
//
|
||||
// 预置失败必须在此之前失败关闭,避免 bundler 用 `io: unexpected end of file`
|
||||
// 把网络问题伪装成打包问题。
|
||||
|
||||
import { ensureNsisToolset, LOG_PREFIX } from './nsis-toolset.mjs';
|
||||
|
||||
// Jenkins 阶段用 `$ErrorActionPreference = 'Stop'` 执行 Powershell:重试告警走
|
||||
// stderr 时可能被 PowerShell 当成终止错误,因此重试与进度一律写 stdout,只有
|
||||
// 最终失败才写 stderr 并以退出码 1 失败关闭。
|
||||
const logger = {
|
||||
log: (message) => console.log(message),
|
||||
warn: (message) => console.log(`${message}(将重试)`),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await ensureNsisToolset({ logger });
|
||||
console.log(`${LOG_PREFIX} NSIS 工具链目录:${result.nsisDir}`);
|
||||
console.log(`${LOG_PREFIX} NSIS 原始归档缓存:${result.cacheDir}`);
|
||||
console.log(
|
||||
result.reused
|
||||
? `${LOG_PREFIX} NSIS 工具链复用已有目录,未访问网络`
|
||||
: `${LOG_PREFIX} NSIS 工具链本次预置:${result.downloaded.join('、')}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`${LOG_PREFIX} NSIS 工具链预置失败:${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// Tauri Windows bundler 的 NSIS 工具链预置。
|
||||
//
|
||||
// 背景:`tauri build` 打 Windows NSIS 包时会现场从 GitHub 下载 `nsis-3.11.zip`
|
||||
// 与 `nsis_tauri_utils.dll`(见 tauri-bundler `bundle/windows/nsis/mod.rs`)。
|
||||
// 构建机每个检出(`git clean -fdx`)都会丢掉 `target/.tauri` 缓存,于是每次
|
||||
// 发布都要重新下载;响应一旦被截断,bundler 只会报 `io: unexpected end of file`,
|
||||
// 整条流水线在 Rust 编译数分钟之后才失败。
|
||||
//
|
||||
// 这里在打包前用固定哈希 + 重试预置同一份工具链目录:bundler 检查到必需文件齐全
|
||||
// 且 `nsis_tauri_utils.dll` 哈希一致后就不会再自行下载。原始归档(两个文件)
|
||||
// 额外缓存在工作区之外,构建机重复构建时不再依赖 GitHub 连通性。
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export const LOG_PREFIX = '[ai-game-creator-shell]';
|
||||
|
||||
/** bundler 把工具链解到 `<tools>/.tauri/NSIS`(`bundle.useLocalToolsDir: true`)。 */
|
||||
export const NSIS_TOOLSET_DIR_NAME = 'NSIS';
|
||||
|
||||
export const NSIS_ARCHIVE_ASSET_NAME = 'nsis-3.11.zip';
|
||||
export const NSIS_ARCHIVE_URL =
|
||||
'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip';
|
||||
export const NSIS_ARCHIVE_SHA1 = 'ef7ff767e5cbd9edd22add3a32c9b8f4500bb10d';
|
||||
export const NSIS_ARCHIVE_TOP_LEVEL_DIR = 'nsis-3.11';
|
||||
|
||||
export const NSIS_TAURI_UTILS_ASSET_NAME = 'nsis_tauri_utils.dll';
|
||||
export const NSIS_TAURI_UTILS_URL =
|
||||
'https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll';
|
||||
export const NSIS_TAURI_UTILS_SHA1 = '75197fee3c6a814fe035788d1c34ead39349b860';
|
||||
export const NSIS_TAURI_UTILS_REQUIRED_FILE =
|
||||
'Plugins/x86-unicode/additional/nsis_tauri_utils.dll';
|
||||
|
||||
/**
|
||||
* 与 tauri-bundler 2.9.x 的 `NSIS_REQUIRED_FILES` 逐条对齐:少一条 bundler 就会
|
||||
* 删掉整个目录重新下载,等于预置失效。升级 `@tauri-apps/cli` 时要同步核对。
|
||||
*/
|
||||
export const NSIS_REQUIRED_FILES = [
|
||||
'makensis.exe',
|
||||
'Bin/makensis.exe',
|
||||
'Stubs/lzma-x86-unicode',
|
||||
'Stubs/lzma_solid-x86-unicode',
|
||||
NSIS_TAURI_UTILS_REQUIRED_FILE,
|
||||
'Include/MUI2.nsh',
|
||||
'Include/FileFunc.nsh',
|
||||
'Include/x64.nsh',
|
||||
'Include/nsDialogs.nsh',
|
||||
'Include/WinMessages.nsh',
|
||||
'Include/Win/COM.nsh',
|
||||
'Include/Win/Propkey.nsh',
|
||||
'Include/Win/RestartManager.nsh',
|
||||
];
|
||||
|
||||
/** 需要预置的原始归档;测试可注入同结构描述替换其中的地址与哈希。 */
|
||||
export const NSIS_ASSETS = [
|
||||
{
|
||||
assetName: NSIS_ARCHIVE_ASSET_NAME,
|
||||
url: NSIS_ARCHIVE_URL,
|
||||
sha1: NSIS_ARCHIVE_SHA1,
|
||||
},
|
||||
{
|
||||
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
|
||||
url: NSIS_TAURI_UTILS_URL,
|
||||
sha1: NSIS_TAURI_UTILS_SHA1,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_DOWNLOAD_ATTEMPTS = 4;
|
||||
const DEFAULT_RETRY_DELAY_MS = 3000;
|
||||
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 180_000;
|
||||
|
||||
export function defaultAppRoot() {
|
||||
return fileURLToPath(new URL('..', import.meta.url));
|
||||
}
|
||||
|
||||
export function resolveTauriToolsDir(appRoot = defaultAppRoot()) {
|
||||
// 必须与 `src-tauri/tauri.windows.conf.json` 的 `bundle.useLocalToolsDir: true`
|
||||
// 保持一致,否则预置的文件不在 bundler 的查找路径上。
|
||||
return path.join(appRoot, 'src-tauri', 'target', '.tauri');
|
||||
}
|
||||
|
||||
export function resolveNsisCacheDir(
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
) {
|
||||
const explicit = env.AGC_TAURI_NSIS_CACHE_DIR?.trim();
|
||||
if (explicit) return path.resolve(explicit);
|
||||
// Jenkins Windows 节点以 SYSTEM 运行,ProgramData 稳定可写且不受工作区清理影响;
|
||||
// 缓存里只有待解压的原始归档,不会从该目录执行任何程序。
|
||||
if (platform === 'win32') {
|
||||
const programData = env.ProgramData?.trim() || 'C:\\ProgramData';
|
||||
return path.join(programData, 'genarrative', 'tauri-nsis-cache');
|
||||
}
|
||||
return path.join(os.homedir(), '.cache', 'genarrative', 'tauri-nsis-cache');
|
||||
}
|
||||
|
||||
/** 与 tauri-bundler 相同的镜像开关语义,便于构建机绕过不可达的 GitHub。 */
|
||||
export function resolveDownloadUrl(url, env = process.env) {
|
||||
if (!url.startsWith('https://github.com/')) return url;
|
||||
const template = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE?.trim();
|
||||
const match =
|
||||
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/releases\/download\/([^/]+)\/(.+)$/u.exec(
|
||||
url,
|
||||
);
|
||||
if (template && match) {
|
||||
return template
|
||||
.replaceAll('<owner>', match[1])
|
||||
.replaceAll('<repo>', match[2])
|
||||
.replaceAll('<version>', match[3])
|
||||
.replaceAll('<asset>', match[4]);
|
||||
}
|
||||
const base = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR?.trim();
|
||||
if (base) return `${base.replace(/\/+$/u, '')}/${url}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
export function sha1Of(data) {
|
||||
return createHash('sha1').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function sha1OfFile(filePath) {
|
||||
try {
|
||||
return sha1Of(fs.readFileSync(filePath));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyNsisToolset(
|
||||
nsisDir,
|
||||
{ utilsSha1 = NSIS_TAURI_UTILS_SHA1 } = {},
|
||||
) {
|
||||
const missing = NSIS_REQUIRED_FILES.filter(
|
||||
(relativePath) => !fs.existsSync(path.join(nsisDir, relativePath)),
|
||||
);
|
||||
const hashMismatch =
|
||||
missing.length === 0 &&
|
||||
sha1OfFile(path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE)) !==
|
||||
utilsSha1;
|
||||
return { ok: missing.length === 0 && !hashMismatch, missing, hashMismatch };
|
||||
}
|
||||
|
||||
/** 解析 zip 条目落盘位置,并拒绝 `../` 这类越界路径。 */
|
||||
export function resolveArchiveEntryTarget(rootDir, entryName) {
|
||||
const root = path.resolve(rootDir);
|
||||
const target = path.resolve(root, entryName);
|
||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||
throw new Error(`NSIS 归档包含越界路径:${entryName}`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadBuffer(url, { fetchImpl, timeoutMs }) {
|
||||
const response = await fetchImpl(url, {
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} ${response.statusText}`.trim());
|
||||
}
|
||||
const data = Buffer.from(await response.arrayBuffer());
|
||||
if (data.length === 0) throw new Error('响应为空');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function downloadVerifiedAsset({
|
||||
assetName,
|
||||
url,
|
||||
sha1,
|
||||
env,
|
||||
fetchImpl,
|
||||
attempts,
|
||||
retryDelayMs,
|
||||
timeoutMs,
|
||||
logger,
|
||||
}) {
|
||||
const downloadUrl = resolveDownloadUrl(url, env);
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
const data = await downloadBuffer(downloadUrl, { fetchImpl, timeoutMs });
|
||||
const actual = sha1Of(data);
|
||||
if (actual !== sha1) {
|
||||
throw new Error(`SHA1 不匹配(期望 ${sha1},实际 ${actual})`);
|
||||
}
|
||||
logger.log(
|
||||
`${LOG_PREFIX} NSIS 工具链:已下载 ${assetName}(${data.length} 字节,第 ${attempt} 次尝试)`,
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
logger.warn(
|
||||
`${LOG_PREFIX} NSIS 工具链:下载 ${assetName} 失败(第 ${attempt}/${attempts} 次):${error.message}`,
|
||||
);
|
||||
if (attempt < attempts) await sleep(retryDelayMs * attempt);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`下载 ${assetName} 失败(已重试 ${attempts} 次):${lastError?.message ?? '未知错误'}\n` +
|
||||
`下载地址:${downloadUrl}\n` +
|
||||
`可先把该文件放入缓存目录(AGC_TAURI_NSIS_CACHE_DIR)或配置 ` +
|
||||
`TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE 后重试。`,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureCachedAsset(options) {
|
||||
const { assetName, sha1, cacheDir, logger } = options;
|
||||
const cachePath = path.join(cacheDir, assetName);
|
||||
if (sha1OfFile(cachePath) === sha1) {
|
||||
logger.log(`${LOG_PREFIX} NSIS 工具链:命中缓存 ${cachePath}`);
|
||||
return cachePath;
|
||||
}
|
||||
if (fs.existsSync(cachePath)) {
|
||||
logger.warn(
|
||||
`${LOG_PREFIX} NSIS 工具链:缓存文件校验失败,重新下载 ${cachePath}`,
|
||||
);
|
||||
}
|
||||
const data = await downloadVerifiedAsset(options);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const tempPath = `${cachePath}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(tempPath, data);
|
||||
fs.rmSync(cachePath, { force: true });
|
||||
fs.renameSync(tempPath, cachePath);
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
export async function extractNsisArchive(archivePath, destinationDir) {
|
||||
const archive = await JSZip.loadAsync(fs.readFileSync(archivePath));
|
||||
for (const [entryName, entry] of Object.entries(archive.files)) {
|
||||
if (entry.dir) continue;
|
||||
const target = resolveArchiveEntryTarget(destinationDir, entryName);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, await entry.async('nodebuffer'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预置 `target/.tauri/NSIS`:已就绪时零网络直接返回,否则用缓存或重试下载补齐。
|
||||
* 返回结构用于测试与日志,不参与发布产物。
|
||||
*/
|
||||
export async function ensureNsisToolset({
|
||||
appRoot = defaultAppRoot(),
|
||||
toolsDir = resolveTauriToolsDir(appRoot),
|
||||
cacheDir = resolveNsisCacheDir(process.env),
|
||||
env = process.env,
|
||||
fetchImpl = globalThis.fetch,
|
||||
assets = NSIS_ASSETS,
|
||||
attempts = DEFAULT_DOWNLOAD_ATTEMPTS,
|
||||
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
||||
timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS,
|
||||
logger = console,
|
||||
} = {}) {
|
||||
const nsisDir = path.join(toolsDir, NSIS_TOOLSET_DIR_NAME);
|
||||
// 生产路径下这里恒等于 bundler 固定的 `nsis_tauri_utils.dll` SHA1;测试注入
|
||||
// 自己的归档描述时,校验口径必须与被注入的资产一致。
|
||||
const missingAsset = [
|
||||
NSIS_ARCHIVE_ASSET_NAME,
|
||||
NSIS_TAURI_UTILS_ASSET_NAME,
|
||||
].find((assetName) => !assets.some((asset) => asset.assetName === assetName));
|
||||
if (missingAsset) throw new Error(`NSIS 资产描述缺少 ${missingAsset}`);
|
||||
const utilsSha1 =
|
||||
assets.find((asset) => asset.assetName === NSIS_TAURI_UTILS_ASSET_NAME)
|
||||
?.sha1 ?? NSIS_TAURI_UTILS_SHA1;
|
||||
const existing = verifyNsisToolset(nsisDir, { utilsSha1 });
|
||||
if (existing.ok) {
|
||||
logger.log(`${LOG_PREFIX} NSIS 工具链已就绪:${nsisDir}`);
|
||||
return { nsisDir, toolsDir, cacheDir, reused: true, downloaded: [] };
|
||||
}
|
||||
logger.log(
|
||||
`${LOG_PREFIX} NSIS 工具链需要预置:${nsisDir}` +
|
||||
(existing.missing.length > 0
|
||||
? `(缺少 ${existing.missing.length} 个文件)`
|
||||
: '(哈希不符)'),
|
||||
);
|
||||
|
||||
const downloadOptions = {
|
||||
env,
|
||||
fetchImpl,
|
||||
attempts,
|
||||
retryDelayMs,
|
||||
timeoutMs,
|
||||
cacheDir,
|
||||
logger,
|
||||
};
|
||||
const assetPaths = {};
|
||||
for (const asset of assets) {
|
||||
assetPaths[asset.assetName] = await ensureCachedAsset({
|
||||
...asset,
|
||||
...downloadOptions,
|
||||
});
|
||||
}
|
||||
|
||||
fs.rmSync(nsisDir, { recursive: true, force: true });
|
||||
await extractNsisArchive(assetPaths[NSIS_ARCHIVE_ASSET_NAME], toolsDir);
|
||||
const extractedDir = path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR);
|
||||
if (!fs.existsSync(extractedDir)) {
|
||||
throw new Error(
|
||||
`NSIS 归档结构不符合预期:${assetPaths[NSIS_ARCHIVE_ASSET_NAME]} 未解出 ${NSIS_ARCHIVE_TOP_LEVEL_DIR}`,
|
||||
);
|
||||
}
|
||||
fs.renameSync(extractedDir, nsisDir);
|
||||
|
||||
const utilsTarget = path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE);
|
||||
fs.mkdirSync(path.dirname(utilsTarget), { recursive: true });
|
||||
fs.copyFileSync(assetPaths[NSIS_TAURI_UTILS_ASSET_NAME], utilsTarget);
|
||||
|
||||
const installed = verifyNsisToolset(nsisDir, { utilsSha1 });
|
||||
if (!installed.ok) {
|
||||
throw new Error(
|
||||
`NSIS 工具链预置不完整:缺少 ${installed.missing.join(', ') || '无'};` +
|
||||
`哈希不符=${installed.hashMismatch}`,
|
||||
);
|
||||
}
|
||||
logger.log(`${LOG_PREFIX} NSIS 工具链预置完成:${nsisDir}`);
|
||||
return {
|
||||
nsisDir,
|
||||
toolsDir,
|
||||
cacheDir,
|
||||
reused: false,
|
||||
downloaded: assets.map((asset) => asset.assetName),
|
||||
};
|
||||
}
|
||||
|
||||
/** `buildRelease` 用:只在 Windows 目标且需要打包时预置 NSIS 工具链。 */
|
||||
export async function prepareNsisToolsetForRelease(
|
||||
context,
|
||||
{ bundling = true, ...deps } = {},
|
||||
) {
|
||||
if (!bundling || !context.target.includes('windows')) return null;
|
||||
return ensureNsisToolset(deps);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
|
||||
import {
|
||||
ensureNsisToolset,
|
||||
extractNsisArchive,
|
||||
NSIS_ARCHIVE_ASSET_NAME,
|
||||
NSIS_ARCHIVE_TOP_LEVEL_DIR,
|
||||
NSIS_REQUIRED_FILES,
|
||||
NSIS_TAURI_UTILS_ASSET_NAME,
|
||||
NSIS_TOOLSET_DIR_NAME,
|
||||
prepareNsisToolsetForRelease,
|
||||
resolveArchiveEntryTarget,
|
||||
resolveDownloadUrl,
|
||||
resolveNsisCacheDir,
|
||||
resolveTauriToolsDir,
|
||||
sha1Of,
|
||||
verifyNsisToolset,
|
||||
} from './nsis-toolset.mjs';
|
||||
|
||||
const appRoot = path.resolve(
|
||||
path.dirname(new URL(import.meta.url).pathname),
|
||||
'..',
|
||||
);
|
||||
const silentLogger = { log() {}, warn() {} };
|
||||
|
||||
function createSandbox() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'agc-nsis-toolset-'));
|
||||
}
|
||||
|
||||
/** 与真实归档同构的最小 zip:只保留 bundler 必需文件。 */
|
||||
async function createArchiveFixture(extraEntries = {}) {
|
||||
const zip = new JSZip();
|
||||
for (const relativePath of NSIS_REQUIRED_FILES) {
|
||||
zip.file(
|
||||
`${NSIS_ARCHIVE_TOP_LEVEL_DIR}/${relativePath}`,
|
||||
`fixture:${relativePath}`,
|
||||
);
|
||||
}
|
||||
for (const [name, contents] of Object.entries(extraEntries)) {
|
||||
zip.file(name, contents);
|
||||
}
|
||||
return zip.generateAsync({ type: 'nodebuffer' });
|
||||
}
|
||||
|
||||
function fixtureAssets({ archive, utils }) {
|
||||
return [
|
||||
{
|
||||
assetName: NSIS_ARCHIVE_ASSET_NAME,
|
||||
url: `https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/${NSIS_ARCHIVE_ASSET_NAME}`,
|
||||
sha1: sha1Of(archive),
|
||||
},
|
||||
{
|
||||
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
|
||||
url: `https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/${NSIS_TAURI_UTILS_ASSET_NAME}`,
|
||||
sha1: sha1Of(utils),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function fixtureFetch({ archive, utils, failures = 0 }) {
|
||||
let remainingFailures = failures;
|
||||
const calls = [];
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(url);
|
||||
if (remainingFailures > 0) {
|
||||
remainingFailures -= 1;
|
||||
throw new Error('network truncated');
|
||||
}
|
||||
const body = url.includes(NSIS_TAURI_UTILS_ASSET_NAME) ? utils : archive;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
arrayBuffer: async () => body,
|
||||
};
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
test('NSIS 工具链目录与 Tauri useLocalToolsDir 配置保持一致', () => {
|
||||
const config = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(appRoot, 'src-tauri', 'tauri.windows.conf.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(config.bundle.useLocalToolsDir, true);
|
||||
assert.equal(
|
||||
resolveTauriToolsDir(appRoot),
|
||||
path.join(appRoot, 'src-tauri', 'target', '.tauri'),
|
||||
);
|
||||
});
|
||||
|
||||
test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => {
|
||||
assert.equal(
|
||||
resolveNsisCacheDir(
|
||||
{ AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' },
|
||||
'linux',
|
||||
),
|
||||
'/tmp/agc-cache',
|
||||
);
|
||||
assert.equal(
|
||||
resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'),
|
||||
path.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'),
|
||||
);
|
||||
assert.ok(
|
||||
resolveNsisCacheDir({}, 'linux').endsWith(
|
||||
path.join('.cache', 'genarrative', 'tauri-nsis-cache'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('下载地址支持 tauri bundler 的两套 GitHub 镜像开关', () => {
|
||||
const url =
|
||||
'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip';
|
||||
assert.equal(resolveDownloadUrl(url, {}), url);
|
||||
assert.equal(
|
||||
resolveDownloadUrl(url, {
|
||||
TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE:
|
||||
'https://mirror.example.com/<owner>/<repo>/<version>/<asset>',
|
||||
}),
|
||||
'https://mirror.example.com/tauri-apps/binary-releases/nsis-3.11/nsis-3.11.zip',
|
||||
);
|
||||
assert.equal(
|
||||
resolveDownloadUrl(url, {
|
||||
TAURI_BUNDLER_TOOLS_GITHUB_MIRROR: 'https://mirror.example.com/',
|
||||
}),
|
||||
`https://mirror.example.com/${url}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('工具链已就绪时零下载复用', async () => {
|
||||
const sandbox = createSandbox();
|
||||
const toolsDir = path.join(sandbox, '.tauri');
|
||||
const cacheDir = path.join(sandbox, 'cache');
|
||||
const archive = await createArchiveFixture();
|
||||
const utils = Buffer.from('nsis-tauri-utils-dll');
|
||||
const assets = fixtureAssets({ archive, utils });
|
||||
|
||||
await ensureNsisToolset({
|
||||
toolsDir,
|
||||
cacheDir,
|
||||
assets,
|
||||
fetchImpl: fixtureFetch({ archive, utils }).fetchImpl,
|
||||
logger: silentLogger,
|
||||
});
|
||||
|
||||
let fetchCalls = 0;
|
||||
const reused = await ensureNsisToolset({
|
||||
toolsDir,
|
||||
cacheDir,
|
||||
assets,
|
||||
fetchImpl: async () => {
|
||||
fetchCalls += 1;
|
||||
throw new Error('工具链已就绪时不应访问网络');
|
||||
},
|
||||
logger: silentLogger,
|
||||
});
|
||||
assert.equal(reused.reused, true);
|
||||
assert.deepEqual(reused.downloaded, []);
|
||||
assert.equal(fetchCalls, 0);
|
||||
assert.equal(reused.nsisDir, path.join(toolsDir, NSIS_TOOLSET_DIR_NAME));
|
||||
});
|
||||
|
||||
test('冷启动时下载、校验、解压并落缓存,重跑走缓存', async () => {
|
||||
const sandbox = createSandbox();
|
||||
const toolsDir = path.join(sandbox, '.tauri');
|
||||
const cacheDir = path.join(sandbox, 'cache');
|
||||
const archive = await createArchiveFixture();
|
||||
const utils = Buffer.from('nsis-tauri-utils-dll');
|
||||
const assets = fixtureAssets({ archive, utils });
|
||||
const { fetchImpl, calls } = fixtureFetch({ archive, utils });
|
||||
|
||||
const result = await ensureNsisToolset({
|
||||
toolsDir,
|
||||
cacheDir,
|
||||
assets,
|
||||
fetchImpl,
|
||||
logger: silentLogger,
|
||||
});
|
||||
assert.deepEqual(calls.length, 2);
|
||||
assert.deepEqual(result.downloaded, [
|
||||
NSIS_ARCHIVE_ASSET_NAME,
|
||||
NSIS_TAURI_UTILS_ASSET_NAME,
|
||||
]);
|
||||
assert.equal(
|
||||
verifyNsisToolset(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), {
|
||||
utilsSha1: sha1Of(utils),
|
||||
}).ok,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
toolsDir,
|
||||
NSIS_TOOLSET_DIR_NAME,
|
||||
'Plugins/x86-unicode/additional/nsis_tauri_utils.dll',
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
'nsis-tauri-utils-dll',
|
||||
);
|
||||
|
||||
// 第二次构建:清空工作区工具目录后仍应零下载恢复(模拟 Jenkins git clean -fdx)。
|
||||
fs.rmSync(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
const offline = await ensureNsisToolset({
|
||||
toolsDir,
|
||||
cacheDir,
|
||||
assets,
|
||||
fetchImpl: async () => {
|
||||
throw new Error('命中缓存时不应访问网络');
|
||||
},
|
||||
logger: silentLogger,
|
||||
});
|
||||
assert.equal(
|
||||
verifyNsisToolset(offline.nsisDir, { utilsSha1: sha1Of(utils) }).ok,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('下载失败按次数重试,最终成功', async () => {
|
||||
const sandbox = createSandbox();
|
||||
const archive = await createArchiveFixture();
|
||||
const utils = Buffer.from('dll');
|
||||
const { fetchImpl, calls } = fixtureFetch({ archive, utils, failures: 2 });
|
||||
const result = await ensureNsisToolset({
|
||||
toolsDir: path.join(sandbox, '.tauri'),
|
||||
cacheDir: path.join(sandbox, 'cache'),
|
||||
assets: fixtureAssets({ archive, utils }),
|
||||
fetchImpl,
|
||||
attempts: 3,
|
||||
retryDelayMs: 1,
|
||||
logger: silentLogger,
|
||||
});
|
||||
assert.equal(result.downloaded.length, 2);
|
||||
assert.equal(calls.length, 4);
|
||||
});
|
||||
|
||||
test('哈希不匹配时报错并给出可操作提示', async () => {
|
||||
const sandbox = createSandbox();
|
||||
const { fetchImpl } = fixtureFetch({
|
||||
archive: Buffer.from('corrupted'),
|
||||
utils: Buffer.from('corrupted'),
|
||||
});
|
||||
await assert.rejects(
|
||||
ensureNsisToolset({
|
||||
toolsDir: path.join(sandbox, '.tauri'),
|
||||
cacheDir: path.join(sandbox, 'cache'),
|
||||
assets: [
|
||||
{
|
||||
assetName: NSIS_ARCHIVE_ASSET_NAME,
|
||||
url: 'https://github.com/a/b/releases/download/1/n.zip',
|
||||
sha1: 'deadbeef',
|
||||
},
|
||||
{
|
||||
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
|
||||
url: 'https://github.com/a/b/releases/download/1/n.dll',
|
||||
sha1: 'deadbeef',
|
||||
},
|
||||
],
|
||||
fetchImpl,
|
||||
attempts: 2,
|
||||
retryDelayMs: 1,
|
||||
logger: silentLogger,
|
||||
}),
|
||||
/SHA1 不匹配/u,
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(sandbox, 'cache', NSIS_ARCHIVE_ASSET_NAME)),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('归档越界路径与缺失必需文件都会失败关闭', async () => {
|
||||
const sandbox = createSandbox();
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveArchiveEntryTarget(path.join(sandbox, 'extract'), '../escape.txt'),
|
||||
/越界路径/u,
|
||||
);
|
||||
assert.equal(
|
||||
resolveArchiveEntryTarget(path.join(sandbox, 'extract'), 'nsis-3.11/a/b'),
|
||||
path.resolve(sandbox, 'extract', 'nsis-3.11/a/b'),
|
||||
);
|
||||
|
||||
const emptyArchive = new JSZip()
|
||||
.file(`${NSIS_ARCHIVE_TOP_LEVEL_DIR}/makensis.exe`, 'only-one')
|
||||
.generateAsync({ type: 'nodebuffer' });
|
||||
const emptyPath = path.join(sandbox, 'incomplete.zip');
|
||||
fs.writeFileSync(emptyPath, await emptyArchive);
|
||||
const toolsDir = path.join(sandbox, 'incomplete-tools');
|
||||
await extractNsisArchive(emptyPath, toolsDir);
|
||||
const status = verifyNsisToolset(
|
||||
path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR),
|
||||
);
|
||||
assert.equal(status.ok, false);
|
||||
assert.ok(status.missing.includes('Bin/makensis.exe'));
|
||||
});
|
||||
|
||||
test('非 Windows 目标或 --no-bundle 不预置工具链', async () => {
|
||||
let called = 0;
|
||||
const deps = {
|
||||
ensure: async () => {
|
||||
called += 1;
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
await prepareNsisToolsetForRelease(
|
||||
{ target: 'x86_64-pc-windows-msvc' },
|
||||
{ bundling: false, ...deps },
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
await prepareNsisToolsetForRelease(
|
||||
{ target: 'aarch64-apple-darwin' },
|
||||
deps,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
@@ -178,8 +178,12 @@ 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('_${version}_universal.dmg'),
|
||||
'首装包名必须保留清单侧唯一匹配所需的后缀',
|
||||
entry.includes("const macTarget = 'aarch64-apple-darwin'"),
|
||||
'macOS 发布入口必须固定单架构目标',
|
||||
);
|
||||
assert.ok(
|
||||
entry.includes('_${version}_aarch64.dmg'),
|
||||
'首装包名必须保留清单侧单架构分支唯一匹配所需的后缀(Tauri 口径 aarch64)',
|
||||
);
|
||||
|
||||
const smoke = fs.readFileSync(
|
||||
|
||||
@@ -65,6 +65,17 @@ 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}`);
|
||||
return { platform: value[0], arch: value[1] };
|
||||
|
||||
@@ -281,7 +281,11 @@ test('native target and macOS dynamic dependency policy reject nonportable Node'
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
});
|
||||
assert.throws(() => targetRuntime('universal-apple-darwin'), /不支持/u);
|
||||
// universal 必须失败关闭:只带宿主架构那一份运行时,Intel 上不可执行。
|
||||
assert.throws(
|
||||
() => targetRuntime('universal-apple-darwin'),
|
||||
/universal-apple-darwin/u,
|
||||
);
|
||||
assertPortableMacNode(
|
||||
'/node:\n\t/usr/lib/libSystem.B.dylib (compatibility version 1)\n',
|
||||
);
|
||||
|
||||
@@ -1006,16 +1006,58 @@ 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}失败:{error}"))?;
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"{action}失败:{}",
|
||||
describe_external_request_failure(&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}响应失败:{error}"))
|
||||
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(" → "))
|
||||
}
|
||||
|
||||
/// Keep provider validation details useful to the operator without copying an
|
||||
@@ -1443,10 +1485,14 @@ 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")
|
||||
))
|
||||
@@ -8535,6 +8581,28 @@ 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)
|
||||
@@ -8843,7 +8911,8 @@ mod canvas_generation_tests {
|
||||
}),
|
||||
);
|
||||
false
|
||||
} else if request.starts_with("GET /api/external/v1/editor/projects ") {
|
||||
} else if request.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|
||||
{
|
||||
write_test_json_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
@@ -9213,7 +9282,7 @@ mod canvas_generation_tests {
|
||||
.expect("write concurrent generation png body");
|
||||
return;
|
||||
}
|
||||
if request.starts_with("GET /api/external/v1/editor/projects ") {
|
||||
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
|
||||
// 项目绑定已由 `install_test_external_project_binding` 预置,远端只需回认同一组身份。
|
||||
write_test_json_response(
|
||||
stream,
|
||||
@@ -9687,7 +9756,7 @@ mod canvas_generation_tests {
|
||||
"Bearer token-b" => "b",
|
||||
unexpected => panic!("unexpected authorization {unexpected}"),
|
||||
};
|
||||
let response = if request.starts_with("GET /api/editor/projects ") {
|
||||
let response = if request.starts_with("GET /api/editor/projects?view=summary ") {
|
||||
let projects = if project_created.get(account).copied().unwrap_or(false) {
|
||||
vec![serde_json::json!({
|
||||
"projectId": format!("remote-project-{account}"),
|
||||
@@ -9853,7 +9922,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 ") {
|
||||
let response = if request.starts_with("GET /api/editor/projects?view=summary ") {
|
||||
let (state_lock, ready) = &*state;
|
||||
let mut state = state_lock.lock().expect("lock project fixture state");
|
||||
if !state.project_created {
|
||||
@@ -9988,7 +10057,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 ") {
|
||||
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
|
||||
write_test_json_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
@@ -10122,7 +10191,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 ") {
|
||||
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
|
||||
write_test_json_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
@@ -10247,23 +10316,24 @@ 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 ") {
|
||||
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?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}");
|
||||
};
|
||||
write_test_json_response(&mut stream, "200 OK", &response);
|
||||
}
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user