import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { resolveTauriInvoke } from '../app/tauri'; import { getCurrentClientAuthUser, getStoredAuthAccessToken, refreshClientAuthAccessToken, } from './clientAuth'; import { getClientServerBaseUrl } from './clientHttp'; import { type ClientOperation, createClientOperation, transitionClientOperation, } from './clientOperation'; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; type CommittedPlatformSession = { user: AuthUser; accessToken: string; apiBaseUrl: string; generation: number; }; export type PlatformSessionRefreshResult = | { status: 'refreshed'; user: AuthUser; generation: number } | { status: 'stale' } | { status: 'failed'; error: unknown }; type PlatformSessionRefreshListener = ( result: PlatformSessionRefreshResult, ) => void; type PlatformSessionGenerationListener = (generation: number) => void; let platformAuthGeneration = 0; let platformNativeGeneration = 0; let platformNativeGenerationFloorPromise: Promise | null = null; let committedPlatformSession: CommittedPlatformSession | null = null; let desiredPlatformSession: CommittedPlatformSession | null = null; let platformSessionRefreshPromise: Promise | null = null; let platformSessionOperation: ClientOperation< 'auth-transition', { userId: string | null } > | null = null; /** * 本地会话写入的排队闸门。 * * Rust 侧 `install_platform_session_in` / `clear_platform_session_in` 按 generation 单调校验: * 更旧的 install 与更旧的 clear 都会被拒绝。因此渲染层必须保证的只有"新 generation 不被旧调用 * 无限挡住",而不需要让队列永远等下去。这里给闸门加一个上限:底层 invoke 迟迟不返回(例如 * Runner 卡住、IPC 不回调)时,后续登录/退出仍能继续推进,迟到的旧写入由 Rust 按代次拒绝。 */ const PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS = 60_000; let platformSessionNativeMutationGate: Promise = Promise.resolve(); const platformSessionRefreshListeners = new Set(); const platformSessionGenerationListeners = new Set(); export function getPlatformSessionOperation() { return platformSessionOperation; } function restoreCommittedAccessToken() { if (committedPlatformSession?.accessToken) { window.localStorage.setItem( ACCESS_TOKEN_STORAGE_KEY, committedPlatformSession.accessToken, ); return; } window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); } function restoreCurrentRendererAccessToken() { if (!desiredPlatformSession) { window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); return; } restoreCommittedAccessToken(); } function notifyPlatformSessionRefresh(result: PlatformSessionRefreshResult) { for (const listener of platformSessionRefreshListeners) { listener(result); } } function notifyPlatformSessionGeneration() { for (const listener of platformSessionGenerationListeners) { listener(platformAuthGeneration); } } async function installNativePlatformSession( session: CommittedPlatformSession, generation: number, ) { const invoke = resolveTauriInvoke(); if (!invoke) return; await invoke('install_platform_account_session', { userId: session.user.id, accessToken: session.accessToken, apiBaseUrl: session.apiBaseUrl, generation, }); } async function clearNativePlatformSession(generation: number) { const invoke = resolveTauriInvoke(); if (!invoke) return; await invoke('clear_platform_account_session', { generation }); } function waitForNativeMutationAbandonment( settled: Promise, timeoutMs: number, ) { let timerId: number | undefined; const abandoned = new Promise((resolve) => { timerId = window.setTimeout(resolve, timeoutMs); }); return Promise.race([settled, abandoned]).finally(() => { if (timerId !== undefined) { window.clearTimeout(timerId); } }); } function enqueuePlatformSessionNativeMutation( operation: () => Promise, abandonmentMs = PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS, ): Promise { const pending = platformSessionNativeMutationGate .catch(() => undefined) .then(operation); platformSessionNativeMutationGate = waitForNativeMutationAbandonment( pending.then( () => undefined, () => undefined, ), abandonmentMs, ); return pending; } async function readNativePlatformSessionGenerationFloor() { const invoke = resolveTauriInvoke(); if (!invoke) return 0; const floor = await invoke( 'read_platform_account_session_generation', ); // Browser/unit-test adapters commonly expose a no-op invoke that returns null // for native-only read commands. They have no surviving Rust generation floor. if (floor === null) return 0; if (!Number.isSafeInteger(floor) || floor < 0) { throw new Error('本地运行时登录态 generation 无效,请重启客户端后重试'); } return floor; } async function reserveNativePlatformSessionGeneration() { platformNativeGenerationFloorPromise ??= readNativePlatformSessionGenerationFloor(); let nativeGenerationFloor: number; try { nativeGenerationFloor = await platformNativeGenerationFloorPromise; } catch (error) { // 一次瞬时失败(IPC 抖动、Runner 刚重启)不能被缓存成"永久失败":否则本次渲染进程 // 内的后续登录/退出都会在同一个已 reject 的 promise 上失败,用户重试也不会重新读取。 platformNativeGenerationFloorPromise = null; throw error; } platformNativeGeneration = Math.max( platformNativeGeneration + 1, platformAuthGeneration, nativeGenerationFloor + 1, ); return platformNativeGeneration; } async function reconcileNativePlatformSessionToCurrentAuthority() { for (;;) { const authoritativeGeneration = platformAuthGeneration; const authoritativeSession = desiredPlatformSession ? { ...desiredPlatformSession } : null; const reconciliationGeneration = await reserveNativePlatformSessionGeneration(); restoreCurrentRendererAccessToken(); try { if (authoritativeSession) { await installNativePlatformSession( authoritativeSession, reconciliationGeneration, ); } else { await clearNativePlatformSession(reconciliationGeneration); } } catch (error) { if (platformAuthGeneration === authoritativeGeneration) { committedPlatformSession = null; desiredPlatformSession = null; restoreCommittedAccessToken(); notifyPlatformSessionGeneration(); } throw error; } if (platformAuthGeneration !== authoritativeGeneration) { continue; } committedPlatformSession = authoritativeSession ? { ...authoritativeSession, generation: authoritativeGeneration, } : null; desiredPlatformSession = committedPlatformSession ? { ...committedPlatformSession } : null; restoreCommittedAccessToken(); notifyPlatformSessionGeneration(); return; } } function resolvePlatformApiBaseUrl() { return getClientServerBaseUrl(); } async function commitPlatformSession( user: AuthUser, accessToken: string, apiBaseUrl: string, expectedGeneration: number, ): Promise { if (platformAuthGeneration !== expectedGeneration) { restoreCurrentRendererAccessToken(); return null; } const candidate: CommittedPlatformSession = { user, accessToken, apiBaseUrl, generation: expectedGeneration + 1, }; // Reserve a new generation so older refresh/login work becomes stale, but keep the previous // committed identity authoritative until Rust and Runner have accepted the candidate. platformAuthGeneration = candidate.generation; desiredPlatformSession = { ...candidate }; notifyPlatformSessionGeneration(); restoreCommittedAccessToken(); const nativeGeneration = await reserveNativePlatformSessionGeneration(); try { await installNativePlatformSession(candidate, nativeGeneration); } catch (error) { if (platformAuthGeneration === candidate.generation) { desiredPlatformSession = committedPlatformSession ? { ...committedPlatformSession } : null; } await reconcileNativePlatformSessionToCurrentAuthority(); if (platformAuthGeneration !== candidate.generation) return null; throw error; } if (platformAuthGeneration !== candidate.generation) { await reconcileNativePlatformSessionToCurrentAuthority(); return null; } committedPlatformSession = candidate; desiredPlatformSession = { ...candidate }; restoreCommittedAccessToken(); notifyPlatformSessionGeneration(); return candidate; } export function currentPlatformSessionGeneration() { return platformAuthGeneration; } export function currentPlatformSessionApiBaseUrl() { return committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl(); } export function beginPlatformSessionTransition() { platformAuthGeneration += 1; desiredPlatformSession = committedPlatformSession ? { ...committedPlatformSession } : null; notifyPlatformSessionGeneration(); return platformAuthGeneration; } export function beginPlatformSessionClearTransition() { platformAuthGeneration += 1; desiredPlatformSession = null; notifyPlatformSessionGeneration(); return platformAuthGeneration; } export async function commitAuthenticatedPlatformSession( user: AuthUser, expectedGeneration: number, apiBaseUrl = resolvePlatformApiBaseUrl(), ) { const accessToken = getStoredAuthAccessToken(); if (!accessToken) { throw new Error('陶泥儿登录凭据缺失,请重新登录'); } const operation = createClientOperation( 'auth-transition', { userId: user.id }, { scope: { apiBaseUrl, sessionGeneration: expectedGeneration }, deadlineMs: 45_000, cancellable: false, }, ); platformSessionOperation = transitionClientOperation(operation, 'runner'); return enqueuePlatformSessionNativeMutation(async () => { try { const session = await commitPlatformSession( user, accessToken, apiBaseUrl, expectedGeneration, ); if (!session) { platformSessionOperation = transitionClientOperation( operation, 'unknown', ); return null; } platformSessionOperation = transitionClientOperation( operation, 'success', { scope: { sessionGeneration: session.generation } }, ); return session.generation; } catch (error) { platformSessionOperation = transitionClientOperation( operation, 'retryable-failure', ); throw error; } }); } export async function refreshPlatformSessionForGeneration( expectedGeneration: number, apiBaseUrl = resolvePlatformApiBaseUrl(), ) { const token = await refreshClientAuthAccessToken(apiBaseUrl); if (platformAuthGeneration !== expectedGeneration) { restoreCurrentRendererAccessToken(); return null; } return token; } export function requestPlatformSessionRefresh(expectedUserId?: string) { if (platformSessionRefreshPromise) return platformSessionRefreshPromise; const expectedGeneration = platformAuthGeneration; const apiBaseUrl = committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl(); const expectedSessionUserId = expectedUserId?.trim() || committedPlatformSession?.user.id || ''; platformSessionRefreshPromise = (async (): Promise => { try { const refreshed = await refreshPlatformSessionForGeneration( expectedGeneration, apiBaseUrl, ); if (!refreshed) return { status: 'stale' }; const user = await getCurrentClientAuthUser(apiBaseUrl); if ( platformAuthGeneration !== expectedGeneration || !user || (expectedSessionUserId && user.id !== expectedSessionUserId) ) { restoreCurrentRendererAccessToken(); return { status: 'stale' }; } const committedGeneration = await commitAuthenticatedPlatformSession( user, expectedGeneration, apiBaseUrl, ); if (committedGeneration === null) return { status: 'stale' }; return { status: 'refreshed', user, generation: committedGeneration, }; } catch (error) { if (platformAuthGeneration !== expectedGeneration) { restoreCurrentRendererAccessToken(); return { status: 'stale' }; } let failure = error; const currentOwnerUserId = committedPlatformSession?.user.id || ''; if ( currentOwnerUserId && currentOwnerUserId !== expectedSessionUserId ) { restoreCurrentRendererAccessToken(); return { status: 'stale' }; } if ( !currentOwnerUserId || currentOwnerUserId === expectedSessionUserId ) { const clearGeneration = beginPlatformSessionClearTransition(); try { await clearCommittedPlatformSession(clearGeneration); } catch (clearError) { failure = clearError; } } return { status: 'failed', error: failure }; } })().then((result) => { notifyPlatformSessionRefresh(result); return result; }); platformSessionRefreshPromise.finally(() => { platformSessionRefreshPromise = null; }); return platformSessionRefreshPromise; } export function subscribePlatformSessionRefresh( listener: PlatformSessionRefreshListener, ) { platformSessionRefreshListeners.add(listener); return () => { platformSessionRefreshListeners.delete(listener); }; } export function subscribePlatformSessionGeneration( listener: PlatformSessionGenerationListener, ) { platformSessionGenerationListeners.add(listener); return () => { platformSessionGenerationListeners.delete(listener); }; } export async function clearCommittedPlatformSession(generation: number) { const operation = createClientOperation( 'auth-transition', { userId: null }, { scope: { sessionGeneration: generation }, deadlineMs: 45_000, cancellable: false, }, ); platformSessionOperation = transitionClientOperation(operation, 'runner'); return enqueuePlatformSessionNativeMutation(async () => { try { if (platformAuthGeneration !== generation) { await reconcileNativePlatformSessionToCurrentAuthority(); platformSessionOperation = transitionClientOperation( operation, 'unknown', ); return; } desiredPlatformSession = null; const nativeGeneration = await reserveNativePlatformSessionGeneration(); try { await clearNativePlatformSession(nativeGeneration); } catch { if (platformAuthGeneration === generation) { desiredPlatformSession = null; } await reconcileNativePlatformSessionToCurrentAuthority(); platformSessionOperation = transitionClientOperation( operation, 'retryable-failure', ); return; } if (platformAuthGeneration !== generation) { await reconcileNativePlatformSessionToCurrentAuthority(); platformSessionOperation = transitionClientOperation( operation, 'unknown', ); return; } committedPlatformSession = null; desiredPlatformSession = null; restoreCommittedAccessToken(); notifyPlatformSessionGeneration(); platformSessionOperation = transitionClientOperation( operation, 'success', ); } catch (error) { platformSessionOperation = transitionClientOperation( operation, 'retryable-failure', ); throw error; } }); } export function resetPlatformSessionStateForTests() { platformAuthGeneration = 0; platformNativeGeneration = 0; platformNativeGenerationFloorPromise = null; committedPlatformSession = null; desiredPlatformSession = null; platformSessionRefreshPromise = null; platformSessionOperation = null; platformSessionNativeMutationGate = Promise.resolve(); platformSessionRefreshListeners.clear(); platformSessionGenerationListeners.clear(); }