Files
Genarrative/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx
T
k88936 394b8de9d2 重构:DirectProject 回合错误去前缀、拆分 wire 模块、终态改判别联合
- 三条错误表去冗余前缀:DirectTurnError→TurnError、DirectTurnEnqueueError→EnqueueError、DirectTurnFailure→TurnFailure、DirectTurnDeadline→Deadline、DirectCodexFailureStage→FailureStage、DirectCodexNativeKind→NativeKind、DirectModelCallKind→ModelCallKind
- 用户条目去前缀:DirectCodexUserItem→UserItem 等六个类型;agent/direct_turn_error.rs 移到 codex_app_server/turn_error.rs
- thread_manager/wire.rs 拆为 wire/{mod,clock,items,turn,failure,tests}.rs;direct_turn_failure.rs 的失败载荷并入 wire/failure.rs
- 宿主侧回合终态从 struct TurnTerminal 改为 enum TurnCompletion{Completed,Interrupted,Aborted,Failed},从 wire 移到 thread_manager/turn_completion.rs,status 由变体反推
- 过期回合释放兜底改走 TurnCompletion::Aborted.event
- 同步生成绑定、前端调用点与测试;修正终态单测里过时的 TransportClosed 断言
- 对 src-tauri 全量 cargo fmt,满足 pre-commit 的 rustfmt 门禁
2026-10-02 18:10:17 +08:00

1140 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
loginClientWithPassword,
loginClientWithPhoneCode,
logoutClientAuthSession,
normalizeAuthPhoneInput,
readClientAuthState,
sendClientPhoneLoginCode,
subscribeClientAuthState,
} from '../services/clientAuth';
import { ClientAuthErrorWrapper } from '../services/clientAuthErrorWrapper';
import {
type ClientServerPreset,
type ClientServerSelection,
clientServerSelectionEnabled,
getClientServerBaseUrl,
getClientServerSelection,
hydrateClientServerSelection,
normalizeClientServerBaseUrl,
setClientServerSelection,
} from '../services/clientHttp';
import {
captureClientError,
installWebviewLogBridge,
} from '../services/errorReporting';
import type { AuthNetworkFailure } from '../services/generated/AuthNetworkFailure';
import type { AuthResponseInvalid } from '../services/generated/AuthResponseInvalid';
import type { AuthServiceUnavailable } from '../services/generated/AuthServiceUnavailable';
import type { PasswordLoginRejected } from '../services/generated/PasswordLoginRejected';
import type { PhoneCodeLoginRejected } from '../services/generated/PhoneCodeLoginRejected';
import type { SendCodeRejected } from '../services/generated/SendCodeRejected';
import type { ServerAddressRejected } from '../services/generated/ServerAddressRejected';
import type { UnexpectedRejection } from '../services/generated/UnexpectedRejection';
import {
beginPlatformSessionClearTransition,
beginPlatformSessionTransition,
commitAuthenticatedPlatformSession,
currentPlatformSessionGeneration,
} from '../services/platformSession';
import { expectNever } from './expectNever';
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);
/**
* 登录尝试代次。UI 的 45s 围栏只约束"等待":底层 native 提交仍在队列里跑,所以围栏超时后
* 仍要有人接手这次提交的结果。代次确保只有最近一次登录尝试的迟到结果能改变界面。
*/
const loginAttemptRef = 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,
);
function persistServerSelection() {
try {
const next = setClientServerSelection({
preset: serverSelection.preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
return true;
} catch (error) {
setLoginStatus(error instanceof Error ? error.message : String(error));
return false;
}
}
function handleServerPresetChange(preset: ClientServerPreset) {
if (preset === 'custom') {
setServerSelection((current) => ({ ...current, preset }));
return;
}
const next = setClientServerSelection({
preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
setLoginStatus(`已选择 ${preset} 服务器`);
}
// 服务器选择是 Rust 客户端偏好:本地 debug 启动时读一次,打包产物始终用渠道默认值。
useEffect(() => {
if (!clientServerSelectionEnabled) return;
let disposed = false;
void hydrateClientServerSelection().then((selection) => {
if (disposed) return;
setServerSelection(selection);
setCustomServerUrl(selection.customBaseUrl);
});
return () => {
disposed = true;
};
}, []);
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);
};
}, []);
/** 恢复登录态:凭据续期、用户复核与原生会话安装都在 Rust 内完成。 */
useEffect(() => {
let disposed = false;
async function hydrateAuth() {
const runId = ++authCheckRunRef.current;
const isActiveRun = () => !disposed && authCheckRunRef.current === runId;
setAuthCheckError('');
setAuthCheckStage('refresh');
try {
const state = await withAuthCheckTimeout(
readClientAuthState(getClientServerBaseUrl()),
AUTH_CHECK_REQUEST_TIMEOUT_MS,
'检查登录状态超时,请检查服务器地址和网络后重试',
);
if (!isActiveRun()) return;
if (state.status !== 'authenticated') {
setAuthStatus('unauthenticated');
return;
}
setAuthCheckStage('runner');
const committedGeneration = await withAuthCheckTimeout(
commitAuthenticatedPlatformSession(
state.user,
currentPlatformSessionGeneration(),
state.apiBaseUrl,
),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
);
if (committedGeneration === null || !isActiveRun()) return;
setAuthUser(state.user);
setAuthCheckError('');
setAuthStatus('authenticated');
} catch (error) {
if (!isActiveRun()) return;
// 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。
setAuthStatus('unauthenticated');
// 启动检查超时等我们自己的失败:改动前会显示原因 + 重试按钮,这里恢复同样的可见反馈。
if (!(error instanceof ClientAuthErrorWrapper)) {
const text = error instanceof Error ? error.message : String(error);
setAuthCheckError(text);
setLoginStatus(text);
throw error;
}
const failure = error.error;
switch (failure.type) {
// 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。
case 'serverAddressRejected': {
const payload = failure as ServerAddressRejected;
let text: string;
switch (payload.reason) {
case 'emptyOrTooLong':
text = '服务器地址非法: 不能为空或超长';
break;
case 'notAUrl':
text = '服务器地址非法: 不是合法地址';
break;
case 'hasCredentials':
text = '服务器地址非法: 不能包含用户名或密码';
break;
case 'hasPathOrQueryOrFragment':
text = '服务器地址非法: 不能带路径、查询或片段';
break;
case 'notHttps':
text = '服务器地址非法: 远程地址必须使用 https';
break;
case 'unsupportedScheme':
text = '服务器地址非法: 只支持 http 或 https';
break;
case 'outsideChannel':
text = '服务器地址非法: 不在当前构建渠道范围内';
break;
default: {
expectNever(payload.reason);
text = '服务器地址非法';
}
}
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'phoneNumberInvalid': {
const text = '手机号无效: 需为纯数字且不超过 32 位';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'passwordMissing': {
const text = '密码无效: 不能为空';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'loginCodeMissing': {
const text = '验证码无效: 不能为空';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'passwordLoginRejected': {
const payload = failure as PasswordLoginRejected;
const text = `登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`;
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'phoneOrPasswordMismatch': {
const text = '手机号或密码错误';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'sendCodeRejected': {
const payload = failure as SendCodeRejected;
const text = `发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`;
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'smsCodeThrottled': {
const text = '发送验证码过于频繁,请稍后再试';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'phoneCodeLoginRejected': {
const payload = failure as PhoneCodeLoginRejected;
const text = `验证码登录失败:${payload.serverMessage ?? '服务端拒绝了本次请求'}`;
setAuthCheckError(text);
setLoginStatus(text);
break;
}
// 会话权威失效:Rust 已按未登录处理;走到这里也只按未登录呈现。
case 'sessionInvalidated':
case 'permissionDenied':
break;
// 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。
case 'authNetworkFailure': {
const payload = failure as AuthNetworkFailure;
const text =
payload.reason === 'timeout'
? '登录服务响应超时,请检查服务器地址和网络后重试'
: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试';
setAuthCheckError(text);
setLoginStatus(text);
throw error;
}
case 'authServiceUnavailable': {
const payload = failure as AuthServiceUnavailable;
const text = `登录服务暂时不可用(HTTP ${payload.status})`;
setAuthCheckError(text);
setLoginStatus(text);
throw error;
}
case 'unexpectedRejection': {
const payload = failure as UnexpectedRejection;
const text = payload.serverMessage
? `登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}`
: `登录服务请求失败(HTTP ${payload.status})`;
setAuthCheckError(text);
setLoginStatus(text);
throw error;
}
case 'authResponseInvalid': {
const payload = failure as AuthResponseInvalid;
let text: string;
switch (payload.reason) {
case 'notJson':
text = '登录服务响应不是合法 JSON';
break;
case 'invalidBody':
text = '登录服务响应结构不符合契约';
break;
case 'missingRefreshCookie':
text = '登录服务未返回新的续期凭据';
break;
case 'missingUserIdentity':
text = '登录服务未返回用户身份';
break;
case 'serverRejected':
text = payload.serverMessage
? `登录服务拒绝了本次请求:${payload.serverMessage}`
: '登录服务拒绝了本次请求';
break;
default: {
expectNever(payload.reason);
text = '登录服务响应不符合契约';
}
}
setAuthCheckError(text);
setLoginStatus(text);
throw error;
}
case 'clientSessionPersistFailed': {
const text = '本机登录凭据读写失败';
setAuthCheckError(text);
setLoginStatus(text);
throw error;
}
case 'runtimeSessionInstallFailed': {
const text = '本机运行时会话安装失败';
setAuthCheckError(text);
setLoginStatus(text);
throw error;
}
case 'authClientInitFailed': {
const text = '创建登录网络客户端失败';
setAuthCheckError(text);
setLoginStatus(text);
throw error;
}
default: {
expectNever(failure);
// 结构化映射外(Tauri 级失败等):仍然原样抛出上报,但界面给一行泛化提示,别停在空白状态。
const text = '登录服务返回了未识别的错误,请重试或提交错误报告';
setAuthCheckError(text);
setLoginStatus(text);
throw error;
}
}
}
}
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]);
// Rust 是认证态事实源:另一个窗口登录/登出、或 Rust 内部判定登录失效时同步界面。
useEffect(() => {
let disposed = false;
let unsubscribe: (() => void) | null = null;
void subscribeClientAuthState((state) => {
if (disposed) return;
if (state.status === 'authenticated') {
setAuthUser((current) =>
current?.id === state.user.id ? state.user : state.user,
);
setAuthCheckError('');
setAuthStatus('authenticated');
return;
}
setAuthUser(null);
setAuthStatus('unauthenticated');
setLoginStatus('登录已失效,请重新登录');
})
.then((release) => {
if (disposed) release();
else unsubscribe = release;
})
.catch(() => undefined);
return () => {
disposed = true;
unsubscribe?.();
};
}, []);
useEffect(() => {
if (codeCooldownSeconds <= 0) {
return;
}
const timer = window.setInterval(() => {
setCodeCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearInterval(timer);
}, [codeCooldownSeconds]);
/**
* 认证失败的分流判据直接写在每个 catch 里:
*
* - 承载:命令失败由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`,`error` 就是判别联合,
* `failure.type` 是唯一分流键;
* - 业务 / 会话变体:无载荷变体用本 catch 的固定文案;带载荷变体先 `as` 取具名类型,
* 可枚举的细分再用 `switch (payload.reason)` 在类型化 reason 上分流并拼上下文
* (Rust 不预拼用户可见文案),永不进错误池;
* - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池;
* - `default: expectNever` 保证 Rust 新增变体时这里编译失败。
*
* 不把判据抽成函数:判定必须发生在 catch 里。
*/
async function handleSendCode() {
if (codeBusy || codeCooldownSeconds > 0) {
return;
}
if (!persistServerSelection()) return;
const apiBaseUrl = getClientServerBaseUrl();
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 (!(error instanceof ClientAuthErrorWrapper)) {
setLoginStatus(error instanceof Error ? error.message : String(error));
throw error;
}
const failure = error.error;
switch (failure.type) {
// 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。
case 'serverAddressRejected': {
const payload = failure as ServerAddressRejected;
let text: string;
switch (payload.reason) {
case 'emptyOrTooLong':
text = '服务器地址非法: 不能为空或超长';
break;
case 'notAUrl':
text = '服务器地址非法: 不是合法地址';
break;
case 'hasCredentials':
text = '服务器地址非法: 不能包含用户名或密码';
break;
case 'hasPathOrQueryOrFragment':
text = '服务器地址非法: 不能带路径、查询或片段';
break;
case 'notHttps':
text = '服务器地址非法: 远程地址必须使用 https';
break;
case 'unsupportedScheme':
text = '服务器地址非法: 只支持 http 或 https';
break;
case 'outsideChannel':
text = '服务器地址非法: 不在当前构建渠道范围内';
break;
default: {
expectNever(payload.reason);
text = '服务器地址非法';
}
}
setLoginStatus(text);
break;
}
case 'phoneNumberInvalid': {
setLoginStatus('手机号无效: 需为纯数字且不超过 32 位');
break;
}
case 'passwordMissing': {
setLoginStatus('密码无效: 不能为空');
break;
}
case 'loginCodeMissing': {
setLoginStatus('验证码无效: 不能为空');
break;
}
case 'passwordLoginRejected': {
const payload = failure as PasswordLoginRejected;
setLoginStatus(
`登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`,
);
break;
}
case 'phoneOrPasswordMismatch': {
setLoginStatus('手机号或密码错误');
break;
}
case 'sendCodeRejected': {
const payload = failure as SendCodeRejected;
setLoginStatus(
`发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`,
);
break;
}
case 'smsCodeThrottled': {
setLoginStatus('发送验证码过于频繁,请稍后再试');
break;
}
case 'phoneCodeLoginRejected': {
const payload = failure as PhoneCodeLoginRejected;
setLoginStatus(
`验证码登录失败:${payload.serverMessage ?? '服务端拒绝了本次请求'}`,
);
break;
}
case 'sessionInvalidated': {
setLoginStatus('登录状态已失效,请重新登录');
break;
}
case 'permissionDenied': {
setLoginStatus('当前账号没有权限执行此操作');
break;
}
// 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。
case 'authNetworkFailure': {
const payload = failure as AuthNetworkFailure;
setLoginStatus(
payload.reason === 'timeout'
? '发送验证码失败:登录服务响应超时,请检查服务器地址和网络后重试'
: '发送验证码失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
);
throw error;
}
case 'authServiceUnavailable': {
const payload = failure as AuthServiceUnavailable;
setLoginStatus(
`发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`,
);
throw error;
}
case 'unexpectedRejection': {
const payload = failure as UnexpectedRejection;
setLoginStatus(
payload.serverMessage
? `发送验证码失败:登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}`
: `发送验证码失败:登录服务请求失败(HTTP ${payload.status})`,
);
throw error;
}
case 'authResponseInvalid': {
const payload = failure as AuthResponseInvalid;
let text: string;
switch (payload.reason) {
case 'notJson':
text = '发送验证码失败:登录服务响应不是合法 JSON';
break;
case 'invalidBody':
text = '发送验证码失败:登录服务响应结构不符合契约';
break;
case 'missingRefreshCookie':
text = '发送验证码失败:登录服务未返回新的续期凭据';
break;
case 'missingUserIdentity':
text = '发送验证码失败:登录服务未返回用户身份';
break;
case 'serverRejected':
text = payload.serverMessage
? `发送验证码失败:登录服务拒绝了本次请求:${payload.serverMessage}`
: '发送验证码失败:登录服务拒绝了本次请求';
break;
default: {
expectNever(payload.reason);
text = '发送验证码失败:登录服务响应不符合契约';
}
}
setLoginStatus(text);
throw error;
}
case 'clientSessionPersistFailed': {
setLoginStatus('发送验证码失败:本机登录凭据读写失败');
throw error;
}
case 'runtimeSessionInstallFailed': {
setLoginStatus('发送验证码失败:本机运行时会话安装失败');
throw error;
}
case 'authClientInitFailed': {
setLoginStatus('发送验证码失败:创建登录网络客户端失败');
throw error;
}
default: {
expectNever(failure);
setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告');
throw 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;
}
if (!persistServerSelection()) return;
const loginApiBaseUrl = getClientServerBaseUrl();
const loginAttempt = (loginAttemptRef.current += 1);
setLoginBusy(true);
setLoginStatus('正在登录');
const loginGeneration = beginPlatformSessionTransition();
try {
// Rust 登录命令内部完成:凭据持久化 → 本机运行时会话安装 → Runner 传播 → 事件。
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;
}
if (loginAttemptRef.current !== loginAttempt) {
return;
}
setAuthUser(user);
setAuthCheckError('');
setAuthStatus('authenticated');
setCode('');
setPassword('');
} catch (error) {
// 本地运行时超时等我们自己的失败也要离开「正在登录」并给出原因,改动前就是如此。
if (!(error instanceof ClientAuthErrorWrapper)) {
setLoginStatus(error instanceof Error ? error.message : String(error));
throw error;
}
const failure = error.error;
switch (failure.type) {
// 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。
case 'serverAddressRejected': {
const payload = failure as ServerAddressRejected;
let text: string;
switch (payload.reason) {
case 'emptyOrTooLong':
text = '服务器地址非法: 不能为空或超长';
break;
case 'notAUrl':
text = '服务器地址非法: 不是合法地址';
break;
case 'hasCredentials':
text = '服务器地址非法: 不能包含用户名或密码';
break;
case 'hasPathOrQueryOrFragment':
text = '服务器地址非法: 不能带路径、查询或片段';
break;
case 'notHttps':
text = '服务器地址非法: 远程地址必须使用 https';
break;
case 'unsupportedScheme':
text = '服务器地址非法: 只支持 http 或 https';
break;
case 'outsideChannel':
text = '服务器地址非法: 不在当前构建渠道范围内';
break;
default: {
expectNever(payload.reason);
text = '服务器地址非法';
}
}
setLoginStatus(text);
break;
}
case 'phoneNumberInvalid': {
setLoginStatus('手机号无效: 需为纯数字且不超过 32 位');
break;
}
case 'passwordMissing': {
setLoginStatus('密码无效: 不能为空');
break;
}
case 'loginCodeMissing': {
setLoginStatus('验证码无效: 不能为空');
break;
}
case 'passwordLoginRejected': {
const payload = failure as PasswordLoginRejected;
setLoginStatus(
`登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`,
);
break;
}
case 'phoneOrPasswordMismatch': {
setLoginStatus('手机号或密码错误');
break;
}
case 'sendCodeRejected': {
const payload = failure as SendCodeRejected;
setLoginStatus(
`发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`,
);
break;
}
case 'smsCodeThrottled': {
setLoginStatus('发送验证码过于频繁,请稍后再试');
break;
}
case 'phoneCodeLoginRejected': {
const payload = failure as PhoneCodeLoginRejected;
setLoginStatus(
`验证码登录失败:${payload.serverMessage ?? '服务端拒绝了本次请求'}`,
);
break;
}
case 'sessionInvalidated': {
setLoginStatus('登录状态已失效,请重新登录');
break;
}
case 'permissionDenied': {
setLoginStatus('当前账号没有权限执行此操作');
break;
}
// 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。
case 'authNetworkFailure': {
const payload = failure as AuthNetworkFailure;
setLoginStatus(
payload.reason === 'timeout'
? '登录失败:登录服务响应超时,请检查服务器地址和网络后重试'
: '登录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
);
throw error;
}
case 'authServiceUnavailable': {
const payload = failure as AuthServiceUnavailable;
setLoginStatus(
`登录失败:登录服务暂时不可用(HTTP ${payload.status})`,
);
throw error;
}
case 'unexpectedRejection': {
const payload = failure as UnexpectedRejection;
setLoginStatus(
payload.serverMessage
? `登录失败:登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}`
: `登录失败:登录服务请求失败(HTTP ${payload.status})`,
);
throw error;
}
case 'authResponseInvalid': {
const payload = failure as AuthResponseInvalid;
let text: string;
switch (payload.reason) {
case 'notJson':
text = '登录失败:登录服务响应不是合法 JSON';
break;
case 'invalidBody':
text = '登录失败:登录服务响应结构不符合契约';
break;
case 'missingRefreshCookie':
text = '登录失败:登录服务未返回新的续期凭据';
break;
case 'missingUserIdentity':
text = '登录失败:登录服务未返回用户身份';
break;
case 'serverRejected':
text = payload.serverMessage
? `登录失败:登录服务拒绝了本次请求:${payload.serverMessage}`
: '登录失败:登录服务拒绝了本次请求';
break;
default: {
expectNever(payload.reason);
text = '登录失败:登录服务响应不符合契约';
}
}
setLoginStatus(text);
throw error;
}
case 'clientSessionPersistFailed': {
setLoginStatus('登录失败:本机登录凭据读写失败');
throw error;
}
case 'runtimeSessionInstallFailed': {
setLoginStatus('登录失败:本机运行时会话安装失败');
throw error;
}
case 'authClientInitFailed': {
setLoginStatus('登录失败:创建登录网络客户端失败');
throw error;
}
default: {
expectNever(failure);
setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告');
throw error;
}
}
} finally {
setLoginBusy(false);
}
}
async function logout() {
beginPlatformSessionClearTransition();
let nativeClearError: unknown = null;
try {
await withAuthCheckTimeout(
logoutClientAuthSession(),
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>
{clientServerSelectionEnabled ? (
<>
<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()) return;
try {
normalizeClientServerBaseUrl(customServerUrl);
persistServerSelection();
} catch (error) {
setLoginStatus(
error instanceof Error
? error.message
: String(error),
);
}
}}
/>
</label>
) : null}
</>
) : null}
{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}
<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 />
</>
);
}