Files
Genarrative/apps/ai-game-creator-shell/src/services/platformSession.ts
T
kdletters e3682fd06f
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m31s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m33s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m40s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m21s
Project CI / AI game creator shell Rust crates (push) Successful in 3m11s
Project CI / Native shell tests (push) Successful in 16m15s
Project CI / Frontend tests (push) Successful in 14m57s
Project CI / Repository checks (push) Successful in 14m48s
Project CI / Backend tests (push) Successful in 20m7s
Project CI / AI game creator shell web tests (push) Successful in 6m20s
固定客户端dev服务并支持官网多平台下载
移除服务器选择并按来源隔离登录凭据
新增官网客户端下载入口和匿名平台聚合接口
根据发布清单自动展示Windows与macOS首装包
补齐Mac首装元数据和上传顺序校验
同步定向测试与下载发布规范
2026-09-19 16:19:28 +08:00

659 lines
21 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 {
clearStoredAuthAccessToken,
getCurrentClientAuthUser,
getStoredAuthAccessToken,
isClientAuthAuthorityFailure,
refreshClientAuthAccessToken,
setStoredAuthAccessToken,
} from './clientAuth';
import { getClientServerBaseUrl } from './clientHttp';
import {
type ClientOperation,
createClientOperation,
transitionClientOperation,
} from './clientOperation';
function readStoredAccessTokenOrThrow(apiBaseUrl: string) {
const accessToken = getStoredAuthAccessToken(apiBaseUrl);
if (!accessToken) {
throw new Error('陶泥儿登录凭据缺失,请重新登录');
}
return accessToken;
}
type CommittedPlatformSession = {
user: AuthUser;
accessToken: string;
apiBaseUrl: string;
generation: number;
};
/** 原生写入:身份代次表达主体归属,revision 只表达写入顺序。 */
type PlatformNativeSessionWrite = {
identityGeneration: number;
revision: number;
};
export type PlatformSessionRefreshResult =
| { status: 'refreshed'; user: AuthUser; generation: number }
| { status: 'stale' }
| {
status: 'failed';
error: unknown;
/**
* 只有服务端明确否认当前身份(401/403,且收敛重试后仍失败)才为 true。
* 网络错误、5xx、网关错误和响应契约异常必须保留既有会话与 access token,
* 调用方不得据此把用户登出。
*/
authoritative: boolean;
};
type PlatformSessionRefreshListener = (
result: PlatformSessionRefreshResult,
) => void;
type PlatformSessionGenerationListener = (generation: number) => void;
let platformAuthGeneration = 0;
/** 原生写入 revision:每次安装 / 清除都推进,用于拒绝迟到写入。 */
let platformNativeRevision = 0;
/** 原生身份代次:只在登录、切号、登出或新 authority epoch 推进,续期保持不变。 */
let platformNativeIdentityGeneration = 0;
let platformNativeSessionFloorPromise: Promise<{
identityGeneration: number;
revision: number;
}> | null = null;
let committedPlatformSession: CommittedPlatformSession | null = null;
let desiredPlatformSession: CommittedPlatformSession | null = null;
let platformSessionRefreshPromise: Promise<PlatformSessionRefreshResult> | 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<void> = Promise.resolve();
const platformSessionRefreshListeners =
new Set<PlatformSessionRefreshListener>();
const platformSessionGenerationListeners =
new Set<PlatformSessionGenerationListener>();
export function getPlatformSessionOperation() {
return platformSessionOperation;
}
function restoreCommittedAccessToken() {
if (committedPlatformSession?.accessToken) {
setStoredAuthAccessToken(
committedPlatformSession.accessToken,
committedPlatformSession.apiBaseUrl,
);
return;
}
clearStoredAuthAccessToken();
}
function restoreCurrentRendererAccessToken() {
if (!desiredPlatformSession) {
clearStoredAuthAccessToken();
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,
write: PlatformNativeSessionWrite,
) {
const invoke = resolveTauriInvoke();
if (!invoke) return;
await invoke('install_platform_account_session', {
userId: session.user.id,
accessToken: session.accessToken,
apiBaseUrl: session.apiBaseUrl,
identityGeneration: write.identityGeneration,
revision: write.revision,
});
}
async function clearNativePlatformSession(write: PlatformNativeSessionWrite) {
const invoke = resolveTauriInvoke();
if (!invoke) return;
await invoke('clear_platform_account_session', {
identityGeneration: write.identityGeneration,
revision: write.revision,
});
}
function waitForNativeMutationAbandonment(
settled: Promise<void>,
timeoutMs: number,
) {
let timerId: number | undefined;
const abandoned = new Promise<void>((resolve) => {
timerId = window.setTimeout(resolve, timeoutMs);
});
return Promise.race([settled, abandoned]).finally(() => {
if (timerId !== undefined) {
window.clearTimeout(timerId);
}
});
}
function enqueuePlatformSessionNativeMutation<T>(
operation: () => Promise<T>,
abandonmentMs = PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS,
): Promise<T> {
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 { identityGeneration: 0, revision: 0 };
const state = await invoke<{
identityGeneration?: unknown;
revision?: unknown;
} | null>('read_platform_account_session_state');
// 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 (state === null || state === undefined) {
return { identityGeneration: 0, revision: 0 };
}
const identityGeneration = Number(state.identityGeneration ?? 0);
const revision = Number(state.revision ?? 0);
if (
!Number.isSafeInteger(identityGeneration) ||
identityGeneration < 0 ||
!Number.isSafeInteger(revision) ||
revision < 0
) {
throw new Error('本地运行时登录态写入下限无效,请重启客户端后重试');
}
return { identityGeneration, revision };
}
async function reserveNativePlatformSessionWrite(options: {
identityChange: boolean;
}): Promise<PlatformNativeSessionWrite> {
platformNativeSessionFloorPromise ??=
readNativePlatformSessionGenerationFloor();
let floor: { identityGeneration: number; revision: number };
try {
floor = await platformNativeSessionFloorPromise;
} catch (error) {
// 一次瞬时失败(IPC 抖动、Runner 刚重启)不能被缓存成"永久失败":否则本次渲染进程
// 内的后续登录/退出都会在同一个已 reject 的 promise 上失败,用户重试也不会重新读取。
platformNativeSessionFloorPromise = null;
throw error;
}
platformNativeRevision = Math.max(
platformNativeRevision + 1,
platformAuthGeneration,
floor.revision + 1,
);
// 同一账号的凭据续期必须复用当前身份代次;只有登录、切号、登出或新 authority epoch
// 才允许推进它,否则在途生成 operation 会被自己的续期判成"旧账号请求"。
platformNativeIdentityGeneration = options.identityChange
? Math.max(
platformNativeIdentityGeneration + 1,
floor.identityGeneration + 1,
)
: Math.max(platformNativeIdentityGeneration, floor.identityGeneration);
return {
identityGeneration: platformNativeIdentityGeneration,
revision: platformNativeRevision,
};
}
async function reconcileNativePlatformSessionToCurrentAuthority() {
for (;;) {
const authoritativeGeneration = platformAuthGeneration;
const authoritativeSession = desiredPlatformSession
? { ...desiredPlatformSession }
: null;
// 只有权威会话与上一次已提交会话不是同一身份时才推进身份代次:同账号续期后的对账
// 仍然算同一身份,不得让在途 operation 失效。
const identityChange =
!committedPlatformSession ||
!authoritativeSession ||
committedPlatformSession.user.id !== authoritativeSession.user.id ||
committedPlatformSession.apiBaseUrl !== authoritativeSession.apiBaseUrl;
const write = await reserveNativePlatformSessionWrite({ identityChange });
restoreCurrentRendererAccessToken();
try {
if (authoritativeSession) {
await installNativePlatformSession(authoritativeSession, write);
} else {
await clearNativePlatformSession(write);
}
} 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 commitNativePlatformSession(
candidate: CommittedPlatformSession,
authorityGeneration: number,
options: { identityChange: boolean },
): Promise<CommittedPlatformSession | null> {
const write = await reserveNativePlatformSessionWrite({
identityChange: options.identityChange,
});
try {
await installNativePlatformSession(candidate, write);
} catch (error) {
if (platformAuthGeneration === authorityGeneration) {
desiredPlatformSession = committedPlatformSession
? { ...committedPlatformSession }
: null;
}
await reconcileNativePlatformSessionToCurrentAuthority();
if (platformAuthGeneration !== authorityGeneration) return null;
throw error;
}
if (platformAuthGeneration !== authorityGeneration) {
await reconcileNativePlatformSessionToCurrentAuthority();
return null;
}
committedPlatformSession = candidate;
desiredPlatformSession = { ...candidate };
restoreCommittedAccessToken();
if (options.identityChange) {
notifyPlatformSessionGeneration();
}
return candidate;
}
async function commitPlatformSession(
user: AuthUser,
accessToken: string,
apiBaseUrl: string,
expectedGeneration: number,
): Promise<CommittedPlatformSession | null> {
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();
return commitNativePlatformSession(candidate, candidate.generation, {
identityChange: true,
});
}
/**
* 同一身份的凭据续期:只替换 access token 与 native 写入 revision,保持身份代次不变,
* 因此在途生成、编辑、上传、确认和下载 operation 不会被自己的续期判成旧账号请求。
*/
async function commitPlatformCredentialRefresh(
user: AuthUser,
accessToken: string,
apiBaseUrl: string,
expectedGeneration: number,
): Promise<CommittedPlatformSession | null> {
if (platformAuthGeneration !== expectedGeneration) {
restoreCurrentRendererAccessToken();
return null;
}
const current = committedPlatformSession;
if (!current) {
restoreCurrentRendererAccessToken();
return null;
}
if (current.user.id !== user.id || current.apiBaseUrl !== apiBaseUrl) {
// 身份已经变化:按换号路径重新提交,不能复用旧身份代次。
return commitPlatformSession(
user,
accessToken,
apiBaseUrl,
expectedGeneration,
);
}
const candidate: CommittedPlatformSession = {
user,
accessToken,
apiBaseUrl,
generation: current.generation,
};
desiredPlatformSession = { ...candidate };
restoreCurrentRendererAccessToken();
return commitNativePlatformSession(candidate, expectedGeneration, {
identityChange: false,
});
}
export function currentPlatformSessionGeneration() {
return platformAuthGeneration;
}
export function currentPlatformSessionApiBaseUrl() {
return committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl();
}
/** 仅供测试断言:同一账号续期不得推进这个身份代次。 */
export function currentPlatformNativeIdentityGenerationForTests() {
return platformNativeIdentityGeneration;
}
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 = readStoredAccessTokenOrThrow(apiBaseUrl);
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<PlatformSessionRefreshResult> => {
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' };
}
// 同一账号的续期只更新凭据:身份代次保持不变,因此在途生成 operation 不会被
// 自己的续期判成旧账号请求。
const committed = await enqueuePlatformSessionNativeMutation(() =>
commitPlatformCredentialRefresh(
user,
readStoredAccessTokenOrThrow(apiBaseUrl),
apiBaseUrl,
expectedGeneration,
),
);
if (committed === null) return { status: 'stale' };
return {
status: 'refreshed',
user,
generation: committed.generation,
};
} 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' };
}
// 只有服务端明确否认当前身份才算权威失效。网络错误、5xx、网关错误和响应契约
// 异常必须保留既有会话与 access token,否则一次后台保活抖动就会把用户登出。
const authoritative = isClientAuthAuthorityFailure(error);
if (
authoritative &&
(!currentOwnerUserId || currentOwnerUserId === expectedSessionUserId)
) {
const clearGeneration = beginPlatformSessionClearTransition();
try {
await clearCommittedPlatformSession(clearGeneration);
} catch (clearError) {
failure = clearError;
}
return { status: 'failed', error: failure, authoritative: true };
}
restoreCurrentRendererAccessToken();
return { status: 'failed', error: failure, authoritative: false };
}
})().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 write = await reserveNativePlatformSessionWrite({
identityChange: true,
});
try {
await clearNativePlatformSession(write);
} 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;
platformNativeRevision = 0;
platformNativeIdentityGeneration = 0;
platformNativeSessionFloorPromise = null;
committedPlatformSession = null;
desiredPlatformSession = null;
platformSessionRefreshPromise = null;
platformSessionOperation = null;
platformSessionNativeMutationGate = Promise.resolve();
platformSessionRefreshListeners.clear();
platformSessionGenerationListeners.clear();
}