Files
Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts
T
k88936 aed2bb9c34 AGC认证失败按新变体分流并补上下文
- AuthenticatedClient 的三个 catch 列全新变体:无字段变体直接给固定文案,带载荷变体先 as 再读 serverMessage
- 系统变体逐个列出后原样抛出,default 仍用 expectNever 把漏接变体卡在编译期
- clientAuth/ClientAuthErrorWrapper 注释去掉对 message 字段的假设
2026-10-02 02:25:02 +08:00

182 lines
5.8 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 type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import { resolveTauriInvoke } from '../app/tauri';
import { ClientAuthErrorWrapper } from './clientAuthErrorWrapper';
import type { ClientAuthError } from './generated/ClientAuthError';
import { subscribeTauriEvent } from './tauriEventSubscription';
/** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */
export const CLIENT_AUTH_STATE_CHANGED_EVENT = 'agc-client-auth-state-changed';
export type ClientAuthState =
| { status: 'authenticated'; user: AuthUser; apiBaseUrl: string }
| { status: 'unauthenticated' };
export type ClientAuthRefreshResult =
| { status: 'refreshed'; user: AuthUser }
| { status: 'unauthenticated' }
| { status: 'stale' };
export type ClientLoginCodeResult = {
cooldownSeconds: number;
expiresInSeconds: number;
};
export function normalizeAuthPhoneInput(phone: string) {
const compactPhone = phone.replace(/[^\d+]/gu, '').trim();
const mainlandChinaInternationalPhone =
compactPhone.match(/^\+?86(1\d{10})$/u);
return mainlandChinaInternationalPhone?.[1] ?? compactPhone;
}
function requireInvoke() {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在 Tauri App 内登录');
}
return invoke;
}
/**
* 认证命令的统一入口:把 Tauri 的拒绝原样装进已有的 `ClientAuthErrorWrapper`。
*
* **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是 ts-rs 生成的
* `ClientAuthError` 判别联合;出现别的形状属于 Tauri / Rust 侧缺陷,调用方 `switch` 的
* `default` 分支仍会把它抛出去上报。包装本身不读变体字段、
* 不注入上下文、不拼用户可见文案。
*/
async function invokeClientAuth<T>(
command: string,
args?: Record<string, unknown>,
): Promise<T> {
// 认证桥未安装是我们自己的失败关闭错误,不是命令拒绝:放在 try 之外,原样抛出。
const invoke = requireInvoke();
try {
// 不带参数时保持 `invoke(command)` 的单参调用形态,别给命令多塞一个 undefined。
return args === undefined
? await invoke<T>(command)
: await invoke<T>(command, args);
} catch (error) {
// 薄包装:原样把 Rust 的拒绝装成 JS Error;不读字段、不加字段。
throw new ClientAuthErrorWrapper(error as ClientAuthError);
}
}
/** Rust 认证态投影,与 `ClientAuthStateView` 一一对应。 */
type RustAuthStateView =
| { status: 'authenticated'; user: AuthUser; apiBaseUrl: string }
| { status: 'unauthenticated' };
/** Rust 续期结果投影,与 `ClientAuthRefreshView` 一一对应。 */
type RustAuthRefreshView =
| { status: 'refreshed'; user: AuthUser }
| { status: 'unauthenticated' }
| { status: 'stale' };
/**
* 恢复登录态。
*
* 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;读状态失败就是命令失败,
* 由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`(`error` 是判别联合),不再有第三态投影。
*/
export async function readClientAuthState(
expectedApiBaseUrl?: string,
): Promise<ClientAuthState> {
const view = await invokeClientAuth<RustAuthStateView>(
'read_client_auth_state',
{
expectedApiBaseUrl: expectedApiBaseUrl?.trim() || null,
},
);
if (view.status === 'authenticated') {
return {
status: 'authenticated',
user: view.user,
apiBaseUrl: view.apiBaseUrl,
};
}
return { status: 'unauthenticated' };
}
export async function sendClientPhoneLoginCode(
phone: string,
apiBaseUrl: string,
): Promise<ClientLoginCodeResult> {
const result = await invokeClientAuth<{
cooldownSeconds?: number;
expiresInSeconds?: number;
}>('send_client_phone_login_code', {
apiBaseUrl,
phone: normalizeAuthPhoneInput(phone),
});
return {
cooldownSeconds: Number(result?.cooldownSeconds ?? 0),
expiresInSeconds: Number(result?.expiresInSeconds ?? 0),
};
}
export async function loginClientWithPassword(
phone: string,
password: string,
apiBaseUrl: string,
): Promise<AuthUser> {
return invokeClientAuth<AuthUser>('login_client_with_password', {
apiBaseUrl,
phone: normalizeAuthPhoneInput(phone),
password: password.trim(),
});
}
export async function loginClientWithPhoneCode(
phone: string,
code: string,
apiBaseUrl: string,
): Promise<AuthUser> {
return invokeClientAuth<AuthUser>('login_client_with_phone_code', {
apiBaseUrl,
phone: normalizeAuthPhoneInput(phone),
code: code.trim(),
});
}
/** 登出:Rust 负责服务端撤销、凭据清除与本机运行时会话清理。 */
export async function logoutClientAuthSession(): Promise<void> {
await invokeClientAuth('logout_client_session');
}
export async function refreshClientAuthSession(
expectedUserId?: string,
): Promise<ClientAuthRefreshResult> {
const view = await invokeClientAuth<RustAuthRefreshView>(
'refresh_client_auth_session',
{ expectedUserId: expectedUserId?.trim() || null },
);
if (view.status === 'refreshed') {
return { status: 'refreshed', user: view.user };
}
if (view.status === 'unauthenticated') {
return { status: 'unauthenticated' };
}
return { status: 'stale' };
}
/** 订阅 Rust 认证态事件,返回幂等释放函数。 */
export function subscribeClientAuthState(
listener: (state: ClientAuthState) => void,
): Promise<() => void> {
return subscribeTauriEvent<RustAuthStateView>(
CLIENT_AUTH_STATE_CHANGED_EVENT,
(event) => {
const view = event.payload;
if (view.status === 'authenticated') {
listener({
status: 'authenticated',
user: view.user,
apiBaseUrl: view.apiBaseUrl,
});
return;
}
listener({ status: 'unauthenticated' });
},
).catch(() => () => {});
}