Merge remote-tracking branch 'origin/master' into feat/tribo3d-integeration
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m38s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m4s
Project CI / Backend tests (pull_request) Successful in 5m5s
Project CI / Native shell tests (pull_request) Successful in 6m21s
Project CI / Frontend tests (pull_request) Successful in 2m20s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m11s
Project CI / Repository checks (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m13s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m29s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m38s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m4s
Project CI / Backend tests (pull_request) Successful in 5m5s
Project CI / Native shell tests (pull_request) Successful in 6m21s
Project CI / Frontend tests (pull_request) Successful in 2m20s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m11s
Project CI / Repository checks (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m13s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m29s
# Conflicts: # docs/project-memory/shared-memory/decision-log.md # docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
@@ -7,6 +7,7 @@ import { StrictMode, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { FloatingFeedbackEntry } from './components/common/FloatingFeedbackEntry';
|
||||
import { MaintenanceNotice } from './components/common/MaintenanceNotice';
|
||||
import { stabilizeMobileViewportKeyboardFocus } from './mobileViewportKeyboardFocus';
|
||||
import { lockMobileViewportZoom } from './mobileViewportZoomLock';
|
||||
import { resolveAppRoute } from './routing/activeAppRoutes';
|
||||
@@ -48,6 +49,7 @@ void refreshNativeAppHostRuntime();
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<Suspense fallback={null}>{routeElement}</Suspense>
|
||||
<MaintenanceNotice />
|
||||
{route.kind === 'platform' ? <FloatingFeedbackEntry /> : null}
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { MAINTENANCE_EVENT } from '../../services/apiClient';
|
||||
import { PlatformActionButton } from './PlatformActionButton';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
type MaintenanceEventDetail = {
|
||||
code?: string;
|
||||
status?: number;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
function isMaintenanceEvent(
|
||||
event: Event,
|
||||
): event is CustomEvent<MaintenanceEventDetail> {
|
||||
return event.type === MAINTENANCE_EVENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 维护态的唯一客户端出口。请求层识别到维护响应后广播事件,页面不再各自展示业务兜底错误。
|
||||
*/
|
||||
export function MaintenanceNotice() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMaintenance = (event: Event) => {
|
||||
if (isMaintenanceEvent(event)) {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener(MAINTENANCE_EVENT, handleMaintenance);
|
||||
return () =>
|
||||
window.removeEventListener(MAINTENANCE_EVENT, handleMaintenance);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title="系统维护中"
|
||||
description="服务正在维护,当前操作暂时无法完成。请稍后再试。"
|
||||
onClose={() => setOpen(false)}
|
||||
size="lg"
|
||||
closeOnBackdrop={false}
|
||||
showCloseButton={false}
|
||||
portalTheme="light"
|
||||
panelClassName="min-h-[min(42vh,28rem)]"
|
||||
bodyClassName="flex flex-1 items-center justify-center px-6 py-10 sm:px-12 sm:py-16"
|
||||
footerClassName="justify-center px-6 py-5 sm:px-12"
|
||||
footer={
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
tone="primary"
|
||||
size="md"
|
||||
shape="pill"
|
||||
className="min-w-36"
|
||||
>
|
||||
我知道了
|
||||
</PlatformActionButton>
|
||||
}
|
||||
>
|
||||
<div className="text-center text-base leading-7 text-[var(--platform-text-base)] sm:text-lg">
|
||||
为避免维护期间出现不一致,相关页面错误会统一收口到此提示。
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeGameEntryUrl } from './gameDistributionGuards';
|
||||
|
||||
const GAME_ID = `game_${'a'.repeat(32)}`;
|
||||
const GAME_ENTRY_PATH = `/games/${GAME_ID}/`;
|
||||
|
||||
describe('normalizeGameEntryUrl', () => {
|
||||
it('resolves a release gateway relative path against the current origin', () => {
|
||||
expect(normalizeGameEntryUrl(GAME_ENTRY_PATH)).toBe(
|
||||
new URL(GAME_ENTRY_PATH, window.location.origin).href,
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the trailing slash required for relative game assets', () => {
|
||||
expect(normalizeGameEntryUrl(`/games/${GAME_ID}`)).toBe(
|
||||
new URL(GAME_ENTRY_PATH, window.location.origin).href,
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes a same-origin absolute release gateway URL', () => {
|
||||
expect(
|
||||
normalizeGameEntryUrl(`${window.location.origin}/games/${GAME_ID}`),
|
||||
).toBe(new URL(GAME_ENTRY_PATH, window.location.origin).href);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`/games/${GAME_ID}/assets/x.js`,
|
||||
'/games/detail',
|
||||
'/creation',
|
||||
'/',
|
||||
'javascript:alert(1)',
|
||||
'https://user:pass@x/y',
|
||||
'http://evil.test/x',
|
||||
])('rejects an invalid or unsafe entry URL: %s', (entryUrl) => {
|
||||
expect(normalizeGameEntryUrl(entryUrl)).toBeNull();
|
||||
});
|
||||
|
||||
it('passes through an absolute HTTPS URL on another origin', () => {
|
||||
const entryUrl = 'https://play.example.test/releases/version-1/index.html';
|
||||
|
||||
expect(normalizeGameEntryUrl(entryUrl)).toBe(entryUrl);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,12 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
const MAX_GAME_ID_LENGTH = 128;
|
||||
const MAX_ENTRY_URL_LENGTH = 4096;
|
||||
const GAME_ENTRY_PATH_PATTERN = /^\/games\/(game_[0-9a-f]{32})\/?$/;
|
||||
|
||||
function normalizeGameEntryPath(pathname: string) {
|
||||
const match = GAME_ENTRY_PATH_PATTERN.exec(pathname);
|
||||
return match ? `/games/${match[1]}/` : null;
|
||||
}
|
||||
|
||||
function containsControlCharacter(value: string) {
|
||||
for (const character of value) {
|
||||
@@ -45,13 +51,18 @@ export function normalizeGameEntryUrl(value: string | null | undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentOrigin =
|
||||
typeof window === 'undefined' ? null : window.location.origin;
|
||||
if (normalized.startsWith('/')) {
|
||||
if (!currentOrigin) {
|
||||
return null;
|
||||
}
|
||||
const normalizedPath = normalizeGameEntryPath(normalized);
|
||||
return normalizedPath ? new URL(normalizedPath, currentOrigin).href : null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(
|
||||
normalized,
|
||||
typeof window === 'undefined'
|
||||
? 'http://localhost'
|
||||
: window.location.origin,
|
||||
);
|
||||
const url = new URL(normalized);
|
||||
const isLocalDevelopmentHttp =
|
||||
url.protocol === 'http:' &&
|
||||
(url.hostname === 'localhost' ||
|
||||
@@ -60,13 +71,16 @@ export function normalizeGameEntryUrl(value: string | null | undefined) {
|
||||
if (
|
||||
(url.protocol !== 'https:' && !isLocalDevelopmentHttp) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
(typeof window !== 'undefined' &&
|
||||
url.origin === window.location.origin &&
|
||||
!import.meta.env.DEV)
|
||||
url.password
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (currentOrigin && url.origin === currentOrigin) {
|
||||
const normalizedPath = normalizeGameEntryPath(url.pathname);
|
||||
return normalizedPath
|
||||
? new URL(normalizedPath, currentOrigin).href
|
||||
: null;
|
||||
}
|
||||
return url.href;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import JSZip from 'jszip';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { prepareGamePackage } from './gameZipPackage';
|
||||
import { GAME_PACKAGE_MAX_BYTES, prepareGamePackage } from './gameZipPackage';
|
||||
|
||||
async function buildZip(files: Record<string, string>) {
|
||||
const zip = new JSZip();
|
||||
@@ -52,4 +52,12 @@ describe('prepareGamePackage', () => {
|
||||
),
|
||||
).rejects.toThrow('发行包不是有效的 ZIP');
|
||||
});
|
||||
|
||||
it('超过上限时在读取字节之前就失败关闭', async () => {
|
||||
// 只伪造体积,不真的分配 200 MiB;预检必须在读字节之前拦下。
|
||||
const oversized = { size: GAME_PACKAGE_MAX_BYTES + 1 } as unknown as File;
|
||||
await expect(prepareGamePackage(oversized)).rejects.toThrow(
|
||||
'发行包不能超过 200 MiB',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export const GAME_PACKAGE_MAX_BYTES = 100 * 1024 * 1024;
|
||||
export const GAME_PACKAGE_MAX_BYTES = 200 * 1024 * 1024;
|
||||
export const GAME_PACKAGE_MAX_FILE_COUNT = 10_000;
|
||||
|
||||
export type PreparedGamePackage = {
|
||||
@@ -59,7 +59,7 @@ export async function prepareGamePackage(
|
||||
throw new Error('请选择非空的发行包 ZIP');
|
||||
}
|
||||
if (file.size > GAME_PACKAGE_MAX_BYTES) {
|
||||
throw new Error('发行包不能超过 100 MiB');
|
||||
throw new Error('发行包不能超过 200 MiB');
|
||||
}
|
||||
const bytes = await readGamePackageBytes(file);
|
||||
let archive: JSZip;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getHostRuntime } from './host-bridge/hostBridge';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
||||
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
|
||||
export const MAINTENANCE_EVENT = 'genarrative-maintenance-detected';
|
||||
const REQUEST_ID_HEADER = 'x-request-id';
|
||||
const API_VERSION_HEADER = 'x-api-version';
|
||||
const ROUTE_VERSION_HEADER = 'x-route-version';
|
||||
@@ -505,6 +506,38 @@ function shouldRetryResponse(
|
||||
);
|
||||
}
|
||||
|
||||
export function isMaintenanceApiError(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiClientError &&
|
||||
(error.code.trim().toUpperCase() === 'MAINTENANCE' ||
|
||||
(error.status === 503 && error.message.includes('维护')))
|
||||
);
|
||||
}
|
||||
|
||||
export function emitMaintenanceNotice(error?: unknown) {
|
||||
if (typeof globalThis.dispatchEvent !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error !== undefined && !isMaintenanceApiError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail =
|
||||
error instanceof ApiClientError
|
||||
? {
|
||||
code: error.code,
|
||||
status: error.status,
|
||||
requestId: error.meta.requestId,
|
||||
}
|
||||
: undefined;
|
||||
const event =
|
||||
typeof CustomEvent === 'function'
|
||||
? new CustomEvent(MAINTENANCE_EVENT, { detail })
|
||||
: new Event(MAINTENANCE_EVENT);
|
||||
globalThis.dispatchEvent(event);
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
@@ -1001,7 +1034,7 @@ async function buildApiClientError(
|
||||
undefined;
|
||||
const baseMessage = parseApiErrorMessage(responseText, fallbackMessage);
|
||||
|
||||
return new ApiClientError({
|
||||
const error = new ApiClientError({
|
||||
message: requestId
|
||||
? `${baseMessage}(requestId: ${requestId})`
|
||||
: baseMessage,
|
||||
@@ -1024,6 +1057,10 @@ async function buildApiClientError(
|
||||
},
|
||||
responseText,
|
||||
});
|
||||
if (isMaintenanceApiError(error)) {
|
||||
emitMaintenanceNotice(error);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function requestJson<T>(
|
||||
|
||||
Reference in New Issue
Block a user