Files
Genarrative/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx
T
kdletters 991b2a1104
Project CI / Repository checks (push) Successful in 3m43s
Project CI / Frontend tests (push) Successful in 3m54s
Project CI / Backend tests (push) Successful in 6m16s
Project CI / Native shell tests (push) Successful in 20m0s
修复 AGC 异步操作恢复闭环 (#346)
## 变更内容

AGC 登录、Runner 会话、最近项目检查和首页自动创建原先各自维护异步状态,响应体卡住、单目录变慢或切页会导致按钮长期 busy、项目列表整体不可用或重复创建项目。本 PR 将这些入口收口到可恢复的生命周期边界:

- 认证响应体读取增加独立超时,refresh singleflight 在失败后释放;
- Runner 会话安装/清除移到 blocking worker,登录/退出增加 45 秒 UI fence;
- 最近项目逐项检查并设置单项目超时,已完成行不受其它慢目录阻塞;
- 首页自动创建锁提升到 WorkspaceLauncher,切页后仍防重并保留状态;
- 新增异步闭环技术方案、body 卡住测试、逐行项目测试和跨页创建测试。

## 验证

- `npm --prefix apps/ai-game-creator-shell run typecheck`
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/clientHttp.test.ts tests/clientApi.test.ts tests/recentProjectsModel.test.ts --reporter=dot`
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/appSurface.test.ts --reporter=dot`
- `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`
- `npm run check:doc-index`
- `npm run check:encoding`
- `git diff --check`

`appSurface.test.ts` 390/390 通过;保留仓库既有 act/jsdom media warning。本 PR 未触发真实 Provider 或发布安装包。

Reviewed-on: #346
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-14 13:19:13 +08:00

725 lines
23 KiB
TypeScript

import {
Component,
type ErrorInfo,
type FormEvent,
type ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice';
import {
clearStoredAuthAccessToken,
getClientAuthErrorMessage,
getCurrentClientAuthUser,
getStoredAuthAccessToken,
isClientAuthRecoverableCheckError,
loginClientWithPassword,
loginClientWithPhoneCode,
logoutClientAuthSession,
normalizeAuthPhoneInput,
sendClientPhoneLoginCode,
} from '../services/clientAuth';
import {
type ClientServerPreset,
type ClientServerSelection,
getClientServerBaseUrl,
getClientServerSelection,
normalizeClientServerBaseUrl,
setClientServerSelection,
} from '../services/clientHttp';
import {
captureClientError,
installWebviewLogBridge,
shouldCaptureClientError,
} from '../services/errorReporting';
import {
beginPlatformSessionClearTransition,
beginPlatformSessionTransition,
clearCommittedPlatformSession,
commitAuthenticatedPlatformSession,
currentPlatformSessionApiBaseUrl,
currentPlatformSessionGeneration,
refreshPlatformSessionForGeneration,
subscribePlatformSessionRefresh,
} from '../services/platformSession';
type ClientRuntimeErrorBoundaryProps = {
children: ReactNode;
onLogout: () => void;
};
type ClientRuntimeErrorBoundaryState = {
errorMessage: string;
};
type AuthCheckStage = 'token' | 'refresh' | 'me' | 'runner';
const AUTH_CHECK_STAGE_LABELS: Record<AuthCheckStage, string> = {
token: '读取本地登录凭据',
refresh: '刷新登录状态',
me: '确认当前用户',
runner: '连接本地运行时',
};
const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000;
// Existing endpoint probes can consume the 10s IPC budget before Runner's
// 30s startup deadline. Keep the UI fence slightly above that worst case.
const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000;
function withAuthCheckTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
message: string,
) {
void promise.catch(() => undefined);
let timeoutId: number | undefined;
const timeout = new Promise<T>((_, reject) => {
timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
});
}
export class ClientRuntimeErrorBoundary extends Component<
ClientRuntimeErrorBoundaryProps,
ClientRuntimeErrorBoundaryState
> {
state: ClientRuntimeErrorBoundaryState = { errorMessage: '' };
static getDerivedStateFromError(error: unknown) {
return {
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '登录后页面发生未知错误',
};
}
componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
console.error('AGC authenticated client render failed', error, errorInfo);
void captureClientError(error, { source: 'react-render' });
}
render() {
if (!this.state.errorMessage) {
return this.props.children;
}
return (
<main
className="client-auth-shell platform-theme platform-theme--light"
aria-label="客户端页面加载失败"
>
<section className="client-auth-panel">
<img className="client-auth-logo" src={brandIcon} alt="陶泥儿" />
<div>
<h1>客户端页面加载失败</h1>
<p className="client-auth-status">{this.state.errorMessage}</p>
</div>
<button type="button" onClick={this.props.onLogout}>
返回登录
</button>
</section>
</main>
);
}
}
export function AuthenticatedClient({
children,
}: {
children: (session: { user: AuthUser; logout: () => void }) => ReactNode;
}) {
const [authStatus, setAuthStatus] = useState<
'checking' | 'authenticated' | 'unauthenticated'
>('checking');
const [authUser, setAuthUser] = useState<AuthUser | null>(null);
const [authCheckStage, setAuthCheckStage] = useState<AuthCheckStage>('token');
const [authCheckElapsedSeconds, setAuthCheckElapsedSeconds] = useState(0);
const [authCheckError, setAuthCheckError] = useState('');
const [authCheckRetryKey, setAuthCheckRetryKey] = useState(0);
const authCheckRunRef = useRef(0);
const [loginMode, setLoginMode] = useState<'code' | 'password'>('code');
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [loginStatus, setLoginStatus] = useState('请登录后继续');
const [loginBusy, setLoginBusy] = useState(false);
const [codeBusy, setCodeBusy] = useState(false);
const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0);
const initialServerSelection = getClientServerSelection();
const [serverSelection, setServerSelection] = useState<ClientServerSelection>(
initialServerSelection,
);
const [customServerUrl, setCustomServerUrl] = useState(
initialServerSelection.customBaseUrl,
);
useEffect(() => {
const uninstallWebviewLogBridge = installWebviewLogBridge();
const handleError = (event: ErrorEvent) => {
void captureClientError(event.error ?? event.message, {
source: 'window.onerror',
});
};
const handleRejection = (event: PromiseRejectionEvent) => {
void captureClientError(event.reason, { source: 'unhandledrejection' });
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleRejection);
return () => {
uninstallWebviewLogBridge();
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleRejection);
};
}, []);
function persistServerSelection() {
try {
const next = setClientServerSelection({
preset: serverSelection.preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
return next;
} catch (error) {
void captureClientError(error, {
source: 'auth-hydrate',
action: 'restore-session',
});
setLoginStatus(error instanceof Error ? error.message : String(error));
return null;
}
}
function handleServerPresetChange(preset: ClientServerPreset) {
if (preset === 'custom') {
setServerSelection((current) => ({ ...current, preset }));
return;
}
const next = setClientServerSelection({
preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
setLoginStatus(`已选择 ${preset} 服务器`);
}
useEffect(() => {
let disposed = false;
async function hydrateAuth() {
const runId = ++authCheckRunRef.current;
const isActiveRun = () => !disposed && authCheckRunRef.current === runId;
const hydrationGeneration = currentPlatformSessionGeneration();
const hydrationApiBaseUrl = getClientServerBaseUrl();
setAuthCheckError('');
setAuthCheckStage('token');
try {
if (!getStoredAuthAccessToken()) {
setAuthCheckStage('refresh');
const refreshed = await withAuthCheckTimeout(
refreshPlatformSessionForGeneration(
hydrationGeneration,
hydrationApiBaseUrl,
),
AUTH_CHECK_REQUEST_TIMEOUT_MS,
'刷新登录状态超时,请检查服务器地址和网络后重试',
);
if (!refreshed) {
if (isActiveRun()) {
setAuthStatus('unauthenticated');
}
return;
}
}
if (!isActiveRun()) return;
setAuthCheckStage('me');
const user = await withAuthCheckTimeout(
getCurrentClientAuthUser(hydrationApiBaseUrl),
AUTH_CHECK_REQUEST_TIMEOUT_MS,
'读取当前用户超时,请检查服务器地址和网络后重试',
);
if (!isActiveRun()) {
return;
}
if (user) {
setAuthCheckStage('runner');
const committedGeneration = await withAuthCheckTimeout(
commitAuthenticatedPlatformSession(
user,
hydrationGeneration,
hydrationApiBaseUrl,
),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
);
if (committedGeneration === null) {
return;
}
if (!isActiveRun()) {
return;
}
setAuthUser(user);
setAuthCheckError('');
setAuthStatus('authenticated');
return;
}
clearStoredAuthAccessToken();
setAuthStatus('unauthenticated');
} catch (error) {
if (!isActiveRun()) {
return;
}
if (
getStoredAuthAccessToken() &&
isClientAuthRecoverableCheckError(error)
) {
const message = getClientAuthErrorMessage(
error,
'登录服务暂时不可用,请稍后重试',
);
setAuthCheckError(message);
setLoginStatus(message);
setAuthStatus('unauthenticated');
return;
}
if (getStoredAuthAccessToken()) {
try {
if (!isActiveRun()) return;
setAuthCheckStage('refresh');
const refreshed = await withAuthCheckTimeout(
refreshPlatformSessionForGeneration(
hydrationGeneration,
hydrationApiBaseUrl,
),
AUTH_CHECK_REQUEST_TIMEOUT_MS,
'刷新登录状态超时,请检查服务器地址和网络后重试',
);
if (!refreshed) {
if (isActiveRun()) {
setAuthStatus('unauthenticated');
}
return;
}
if (!isActiveRun()) return;
setAuthCheckStage('me');
const user = await withAuthCheckTimeout(
getCurrentClientAuthUser(hydrationApiBaseUrl),
AUTH_CHECK_REQUEST_TIMEOUT_MS,
'读取当前用户超时,请检查服务器地址和网络后重试',
);
if (!isActiveRun()) {
return;
}
if (user) {
if (!isActiveRun()) return;
setAuthCheckStage('runner');
const committedGeneration = await withAuthCheckTimeout(
commitAuthenticatedPlatformSession(
user,
hydrationGeneration,
hydrationApiBaseUrl,
),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
);
if (committedGeneration === null) {
return;
}
if (!isActiveRun()) {
return;
}
setAuthCheckError('');
setAuthUser(user);
setAuthStatus('authenticated');
return;
}
} catch (retryError) {
if (!isActiveRun()) {
return;
}
if (
getStoredAuthAccessToken() &&
isClientAuthRecoverableCheckError(retryError)
) {
const message = getClientAuthErrorMessage(
retryError,
'登录服务暂时不可用,请稍后重试',
);
setAuthCheckError(message);
setLoginStatus(message);
setAuthStatus('unauthenticated');
return;
}
}
}
if (!isActiveRun()) {
return;
}
if (isClientAuthRecoverableCheckError(error)) {
const message = getClientAuthErrorMessage(
error,
'登录服务暂时不可用,请稍后重试',
);
setAuthCheckError(message);
setLoginStatus(message);
}
clearStoredAuthAccessToken();
setAuthStatus('unauthenticated');
}
}
void hydrateAuth();
return () => {
disposed = true;
};
}, [authCheckRetryKey]);
useEffect(() => {
if (authStatus !== 'checking') {
return;
}
setAuthCheckElapsedSeconds(0);
const timer = window.setInterval(() => {
setAuthCheckElapsedSeconds((current) => current + 1);
}, 1000);
return () => window.clearInterval(timer);
}, [authStatus, authCheckRetryKey]);
useEffect(
() =>
subscribePlatformSessionRefresh((result) => {
if (result.status === 'refreshed') {
setAuthUser((current) =>
current?.id === result.user.id ? result.user : current,
);
return;
}
if (result.status === 'failed') {
clearStoredAuthAccessToken();
setAuthUser(null);
setAuthStatus('unauthenticated');
setLoginStatus('登录已失效,请重新登录');
}
}),
[],
);
useEffect(() => {
if (codeCooldownSeconds <= 0) {
return;
}
const timer = window.setInterval(() => {
setCodeCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearInterval(timer);
}, [codeCooldownSeconds]);
async function handleSendCode() {
if (codeBusy || codeCooldownSeconds > 0) {
return;
}
const persistedSelection = persistServerSelection();
if (!persistedSelection) {
return;
}
const apiBaseUrl = getClientServerBaseUrl(persistedSelection);
const normalizedPhone = normalizeAuthPhoneInput(phone);
if (!normalizedPhone) {
setLoginStatus('请输入手机号');
return;
}
setCodeBusy(true);
setLoginStatus('正在发送验证码');
try {
const response = await sendClientPhoneLoginCode(
normalizedPhone,
apiBaseUrl,
);
setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds)));
setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`);
} catch (error) {
if (shouldCaptureClientError(error))
void captureClientError(error, {
source: 'auth',
action: 'send-login-code',
});
setLoginStatus(error instanceof Error ? error.message : String(error));
} finally {
setCodeBusy(false);
}
}
async function handleLoginSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (loginBusy) {
return;
}
const normalizedPhone = normalizeAuthPhoneInput(phone);
if (!normalizedPhone) {
setLoginStatus('请输入手机号');
return;
}
if (loginMode === 'code' && !code.trim()) {
setLoginStatus('请输入验证码');
return;
}
if (loginMode === 'password' && !password.trim()) {
setLoginStatus('请输入密码');
return;
}
const persistedSelection = persistServerSelection();
if (!persistedSelection) {
return;
}
const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection);
setLoginBusy(true);
setLoginStatus('正在登录');
const loginGeneration = beginPlatformSessionTransition();
try {
const user =
loginMode === 'code'
? await loginClientWithPhoneCode(
normalizedPhone,
code,
loginApiBaseUrl,
)
: await loginClientWithPassword(
normalizedPhone,
password,
loginApiBaseUrl,
);
const committedGeneration = await withAuthCheckTimeout(
commitAuthenticatedPlatformSession(
user,
loginGeneration,
loginApiBaseUrl,
),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
);
if (committedGeneration === null) {
return;
}
setAuthUser(user);
setAuthCheckError('');
setAuthStatus('authenticated');
setCode('');
setPassword('');
} catch (error) {
if (shouldCaptureClientError(error))
void captureClientError(error, { source: 'auth', action: 'login' });
setLoginStatus(error instanceof Error ? error.message : String(error));
} finally {
setLoginBusy(false);
}
}
async function logout() {
const logoutApiBaseUrl = currentPlatformSessionApiBaseUrl();
const logoutGeneration = beginPlatformSessionClearTransition();
let nativeClearError: unknown = null;
try {
await logoutClientAuthSession(logoutApiBaseUrl);
} catch {
clearStoredAuthAccessToken();
}
try {
await withAuthCheckTimeout(
clearCommittedPlatformSession(logoutGeneration),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'清理本地运行时超时,请重启客户端后再登录',
);
} catch (error) {
nativeClearError = error;
}
setAuthUser(null);
setAuthCheckError('');
setAuthStatus('unauthenticated');
setLoginStatus(
nativeClearError
? '已退出登录;本地运行时登录态同步失败,请重启客户端后再登录'
: '已退出登录',
);
}
if (authStatus === 'checking') {
const stageLabel = AUTH_CHECK_STAGE_LABELS[authCheckStage];
return (
<main
className="client-auth-shell platform-theme platform-theme--light"
aria-label="登录状态检查"
>
<section className="client-auth-panel">
<img className="client-auth-logo" src={brandIcon} alt="陶泥儿" />
<h1>正在检查登录状态</h1>
<p className="client-auth-status" aria-live="polite">
{stageLabel} · 已等待 {authCheckElapsedSeconds} 秒
</p>
<p className="client-auth-status" aria-live="polite">
{authCheckElapsedSeconds >= 8
? '检查时间较长,请确认服务器可访问;若持续无响应可稍后重试'
: '正在恢复会话,请稍候'}
</p>
</section>
</main>
);
}
if (!authUser) {
return (
<main
className="client-auth-shell platform-theme platform-theme--light"
aria-label="登录"
>
<form className="client-auth-panel" onSubmit={handleLoginSubmit}>
<img className="client-auth-logo" src={brandIcon} alt="陶泥儿" />
<div>
<h1>登录陶泥儿 GameAgent</h1>
<p>登录后进入首页和本地项目工作区</p>
</div>
{authCheckError ? (
<div role="alert" className="client-auth-status">
<p>{authCheckError}</p>
<button
type="button"
onClick={() => {
beginPlatformSessionTransition();
setAuthCheckError('');
setAuthStatus('checking');
setAuthCheckRetryKey((current) => current + 1);
}}
disabled={loginBusy || codeBusy}
>
重试登录状态检查
</button>
</div>
) : null}
<label>
服务器
<select
aria-label="服务器"
disabled={loginBusy || codeBusy}
value={serverSelection.preset}
onChange={(event) =>
handleServerPresetChange(
event.currentTarget.value as ClientServerPreset,
)
}
>
<option value="release">release</option>
<option value="dev">dev</option>
<option value="custom">custom</option>
</select>
</label>
{serverSelection.preset === 'custom' ? (
<label>
自定义服务器地址
<input
aria-label="自定义服务器地址"
disabled={loginBusy || codeBusy}
inputMode="url"
placeholder="https://example.com"
value={customServerUrl}
onChange={(event) =>
setCustomServerUrl(event.currentTarget.value)
}
onBlur={() => {
if (customServerUrl.trim()) {
try {
normalizeClientServerBaseUrl(customServerUrl);
persistServerSelection();
} catch (error) {
setLoginStatus(
error instanceof Error ? error.message : String(error),
);
}
}
}}
/>
</label>
) : null}
<div className="client-auth-tabs" role="group" aria-label="登录方式">
<button
type="button"
className={loginMode === 'code' ? 'is-active' : ''}
onClick={() => setLoginMode('code')}
>
验证码登录
</button>
<button
type="button"
className={loginMode === 'password' ? 'is-active' : ''}
onClick={() => setLoginMode('password')}
>
密码登录
</button>
</div>
<label>
手机号
<input
autoComplete="tel"
inputMode="tel"
value={phone}
onChange={(event) => setPhone(event.currentTarget.value)}
/>
</label>
{loginMode === 'code' ? (
<div className="client-auth-code-row">
<label>
验证码
<input
autoComplete="one-time-code"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.currentTarget.value)}
/>
</label>
<button
type="button"
onClick={handleSendCode}
disabled={codeBusy || codeCooldownSeconds > 0}
>
{codeBusy
? '发送中'
: codeCooldownSeconds > 0
? `${codeCooldownSeconds}s`
: '获取验证码'}
</button>
</div>
) : (
<label>
密码
<input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.currentTarget.value)}
/>
</label>
)}
<button type="submit" disabled={loginBusy}>
{loginBusy ? '登录中' : '登录'}
</button>
{!authCheckError ? (
<p className="client-auth-status">{loginStatus}</p>
) : null}
</form>
</main>
);
}
return (
<>
<ClientRuntimeErrorBoundary onLogout={logout}>
{children({ user: authUser, logout })}
</ClientRuntimeErrorBoundary>
<ErrorReportNotice />
</>
);
}