aed2bb9c34
- AuthenticatedClient 的三个 catch 列全新变体:无字段变体直接给固定文案,带载荷变体先 as 再读 serverMessage - 系统变体逐个列出后原样抛出,default 仍用 expectNever 把漏接变体卡在编译期 - clientAuth/ClientAuthErrorWrapper 注释去掉对 message 字段的假设
182 lines
5.8 KiB
TypeScript
182 lines
5.8 KiB
TypeScript
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(() => () => {});
|
||
}
|