From f38930b0ac290dd0b64f460c8ec88ae5291eeda1 Mon Sep 17 00:00:00 2001 From: Xu Liang Date: Fri, 8 May 2026 11:43:25 +0800 Subject: [PATCH] feat(desktop): implement client token rotation and session management enhancements --- apps/desktop-client/src/main/bootstrap.ts | 88 ++++++++- .../src/main/runtime-controller.ts | 7 +- .../desktop-client/src/main/runtime-events.ts | 32 ++++ .../src/main/transport/api-client.ts | 8 + apps/desktop-client/src/preload/bridge.ts | 5 + .../renderer/composables/useDesktopSession.ts | 181 +++++++++++++++++- apps/desktop-client/src/renderer/env.d.ts | 2 + 7 files changed, 317 insertions(+), 6 deletions(-) diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 704e7c8..5c4d6c5 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -1,7 +1,11 @@ import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from '@geo/shared-types' +import type { + DesktopAccountInfo, + DesktopClientRotateResponse, + DesktopRuntimeSessionSyncRequest, +} from '@geo/shared-types' import { isAIPlatformId } from '@geo/shared-types' import { shell } from 'electron' import type { @@ -38,12 +42,13 @@ import { registerRendererDevtoolsProxyTarget } from './renderer-devtools-proxy' import { noteRuntimeAccountBound, refreshRuntimeAccounts, + getRuntimeControllerSnapshot, releaseRuntimeSession, requestRuntimeAccountProbe, syncRuntimeSession, unbindRuntimeAccount, } from './runtime-controller' -import { onRuntimeInvalidated } from './runtime-events' +import { onRuntimeAuthExpired, onRuntimeInvalidated } from './runtime-events' import { createRuntimeAccountSnapshot, createRuntimeSnapshot } from './runtime-snapshot' import { initScheduler } from './scheduler' import { initSessionRegistry } from './session-registry' @@ -52,6 +57,7 @@ import { initTransport, listDesktopPublishTasks, retryDesktopPublishTask, + rotateDesktopClient, } from './transport/api-client' import { initTray, showTrayBalloon } from './tray' import { STANDARD_USER_AGENT } from './user-agent' @@ -120,6 +126,7 @@ let mainWindowCreatePromise: Promise | null = null let loginWindowCreatePromise: Promise | null = null let settingsWindowCreatePromise: Promise | null = null let windowModeSwitchQueue: Promise = Promise.resolve() +let clientTokenRotatePromise: Promise | null = null let canHandleDesktopDeepLinks = false const pendingDesktopDeepLinks: string[] = [] const forceCloseWindows = new WeakSet() @@ -358,6 +365,32 @@ function captureRuntimeAccountSnapshot(accountId: string) { return createRuntimeAccountSnapshot(accountId) } +async function clearRendererDesktopSession(window: ElectronBrowserWindow | null): Promise { + if (!window || window.isDestroyed()) { + return + } + + await window.webContents + .executeJavaScript("window.localStorage.removeItem('geo.desktop.session.v1')", true) + .catch((error) => { + console.warn('[desktop-main] clear renderer desktop session failed', { + message: error instanceof Error ? error.message : String(error), + }) + }) +} + +async function clearRendererDesktopSessions(): Promise { + await Promise.all( + BrowserWindow.getAllWindows().map((window) => + clearRendererDesktopSession(window).catch((error) => { + console.warn('[desktop-main] clear window desktop session failed', { + message: error instanceof Error ? error.message : String(error), + }) + }), + ), + ) +} + async function ensureMainWindow(): Promise { const existing = currentMainWindow() if (existing) { @@ -491,6 +524,13 @@ function queueWindowModeSwitch( return nextSwitch } +async function forceLoginWindowForAuthExpired(message: string): Promise { + console.warn('[desktop-main] runtime auth expired; switching to login window', { message }) + syncRuntimeSession(null) + await clearRendererDesktopSessions() + await queueWindowModeSwitch('login', { source: 'logout', animate: true }, currentMainWindow()) +} + async function openSettingsWindow(): Promise { const window = await ensureSettingsWindow() const parent = settingsWindowParent() @@ -890,7 +930,35 @@ function registerBridgeHandlers(): void { return null }, ) + safeHandle('desktop:runtime-session-rotate-client-token', async () => { + if (!clientTokenRotatePromise) { + clientTokenRotatePromise = rotateDesktopClient() + .then((rotated) => { + const currentSession = getRuntimeControllerSnapshot().session + if ( + currentSession?.mode === 'authenticated' && + currentSession.desktop_client?.id === rotated.client.id + ) { + syncRuntimeSession({ + ...currentSession, + client_token: rotated.client_token, + desktop_client: rotated.client, + }) + } + return rotated + }) + .finally(() => { + clientTokenRotatePromise = null + }) + } + return clientTokenRotatePromise + }) safeHandle('desktop:runtime-session-release', async (_event, revoke?: boolean) => { + if (revoke) { + currentWindowMode = 'login' + persistWindowMode('login') + await clearRendererDesktopSessions() + } await releaseRuntimeSession({ revoke: Boolean(revoke) }) return null }) @@ -920,7 +988,8 @@ if (!hasSingleInstanceLock) { .then(async () => { suppressNativeWindowMenu() registerDesktopProtocolClient() - currentWindowMode = readPersistedWindowMode() + currentWindowMode = 'login' + persistWindowMode('login') initDesktopAppSettings() registerBridgeHandlers() await initSessionRegistry() @@ -937,6 +1006,11 @@ if (!hasSingleInstanceLock) { } mainRendererContents.send('desktop:runtime-invalidated', event) }) + onRuntimeAuthExpired((event) => { + void forceLoginWindowForAuthExpired(event.message).catch((error) => { + console.error('[desktop-main] auth-expired window transition failed', error) + }) + }) await queueWindowModeSwitch(currentWindowMode) initTray(() => { revealActiveWindowSafely('tray') @@ -976,8 +1050,14 @@ if (!hasSingleInstanceLock) { quitReleaseInFlight = true event.preventDefault() + currentWindowMode = 'login' + persistWindowMode('login') + void Promise.race([ - releaseRuntimeSession(), + (async () => { + await clearRendererDesktopSessions() + await releaseRuntimeSession() + })(), new Promise((resolve) => setTimeout(resolve, 1500)), ]).finally(() => { app.quit() diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index 1362221..1468e6b 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -84,7 +84,11 @@ import { notePublishTaskLeaseMiss, selectNextPublishTask, } from './publish-scheduler' -import { emitRuntimeInvalidated, onRuntimeInvalidated } from './runtime-events' +import { + emitRuntimeAuthExpired, + emitRuntimeInvalidated, + onRuntimeInvalidated, +} from './runtime-events' import { initScheduler, noteSchedulerAccountsSync, @@ -3277,6 +3281,7 @@ function handleAuthExpired(error: unknown): void { setTransportAuthState('expired') recordActivity('danger', '客户端令牌已过期', message) stopRuntime() + emitRuntimeAuthExpired(message) } function selectPublishAdapter(platform: string): PublishAdapter | null { diff --git a/apps/desktop-client/src/main/runtime-events.ts b/apps/desktop-client/src/main/runtime-events.ts index 75c81b9..f4827d1 100644 --- a/apps/desktop-client/src/main/runtime-events.ts +++ b/apps/desktop-client/src/main/runtime-events.ts @@ -1,5 +1,11 @@ type RuntimeInvalidationReason = 'account-health' | 'publish-task-lease' +type RuntimeAuthExpiredListener = (event: { + reason: 'client-auth-expired' + at: number + message: string +}) => void + type RuntimeInvalidationListener = (event: { reason: RuntimeInvalidationReason at: number @@ -8,6 +14,7 @@ type RuntimeInvalidationListener = (event: { }) => void const listeners = new Set() +const authExpiredListeners = new Set() export function emitRuntimeInvalidated( reason: RuntimeInvalidationReason, @@ -37,3 +44,28 @@ export function onRuntimeInvalidated(listener: RuntimeInvalidationListener): () listeners.delete(listener) } } + +export function emitRuntimeAuthExpired(message: string): void { + const event = { + reason: 'client-auth-expired', + at: Date.now(), + message, + } as const + + for (const listener of authExpiredListeners) { + try { + listener(event) + } catch (error) { + console.warn('[desktop-runtime] auth-expired listener failed', { + message: error instanceof Error ? error.message : String(error), + }) + } + } +} + +export function onRuntimeAuthExpired(listener: RuntimeAuthExpiredListener): () => void { + authExpiredListeners.add(listener) + return () => { + authExpiredListeners.delete(listener) + } +} diff --git a/apps/desktop-client/src/main/transport/api-client.ts b/apps/desktop-client/src/main/transport/api-client.ts index 40bc438..f4e39ca 100644 --- a/apps/desktop-client/src/main/transport/api-client.ts +++ b/apps/desktop-client/src/main/transport/api-client.ts @@ -9,6 +9,7 @@ import type { DesktopArticleContent, DesktopClientHeartbeatRequest, DesktopClientHeartbeatResponse, + DesktopClientRotateResponse, DesktopPublishTaskListResponse, DesktopRuntimeSessionSyncRequest, DesktopTaskInfo, @@ -430,6 +431,13 @@ export async function heartbeatDesktopClient( ) } +export async function rotateDesktopClient(): Promise { + return desktopPost>( + '/api/desktop/clients/rotate', + {}, + ) +} + export async function offlineDesktopClient(): Promise { await desktopPost<{ server_time: string }, Record>( '/api/desktop/clients/offline', diff --git a/apps/desktop-client/src/preload/bridge.ts b/apps/desktop-client/src/preload/bridge.ts index 2065c09..f784538 100644 --- a/apps/desktop-client/src/preload/bridge.ts +++ b/apps/desktop-client/src/preload/bridge.ts @@ -1,6 +1,7 @@ import type { CreatePublishJobResponse, DesktopAccountInfo, + DesktopClientRotateResponse, DesktopPublishTaskListResponse, DesktopRuntimeSessionSyncRequest, ListDesktopPublishTasksParams, @@ -140,6 +141,10 @@ const desktopBridge = { ipcRenderer.invoke('desktop:set-app-setting', key, value) as Promise, syncRuntimeSession: (session: DesktopRuntimeSessionSyncRequest | null) => ipcRenderer.invoke('desktop:runtime-session-sync', session) as Promise, + rotateRuntimeClientToken: () => + ipcRenderer.invoke( + 'desktop:runtime-session-rotate-client-token', + ) as Promise, releaseRuntimeSession: (revoke?: boolean) => ipcRenderer.invoke('desktop:runtime-session-release', revoke) as Promise, setWindowMode: (mode: 'login' | 'main', request?: WindowModeRequest) => diff --git a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts index 079ede0..c7311a9 100644 --- a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts +++ b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts @@ -6,6 +6,7 @@ import type { DesktopClientInfo, DesktopClientRegisterRequest, DesktopClientRegisterResponse, + DesktopClientRotateResponse, DesktopRuntimeSessionSyncRequest, LoginRequest, LoginResponse, @@ -20,6 +21,7 @@ type SessionMode = 'authenticated' | 'preview' interface DesktopSession { mode: SessionMode accessToken: string | null + accessTokenExpiresAt: number | null refreshToken: string | null user: UserInfo | null clientToken: string | null @@ -35,6 +37,9 @@ interface ResolvedDeviceInfo { const sessionStorageKey = 'geo.desktop.session.v1' const apiBaseURLStorageKey = 'geo.desktop.api-base-url' +const tokenRenewCheckMs = 60_000 +const accessTokenRenewWindowMs = 5 * 60_000 +const clientTokenRenewWindowMs = 3 * 24 * 60 * 60_000 export const DEFAULT_API_BASE_URL = import.meta.env.VITE_DESKTOP_API_BASE_URL ?? 'https://api.shengxintui.com' const normalizedDefaultApiBaseURL = normalizeDesktopApiBaseURL(DEFAULT_API_BASE_URL) @@ -45,6 +50,8 @@ const pending = shallowRef(false) const error = shallowRef(null) let loginPromise: Promise | null = null let logoutPromise: Promise | null = null +let renewPromise: Promise | null = null +let tokenRenewTimer: ReturnType | null = null function readStoredSession(): DesktopSession | null { if (typeof window === 'undefined') { @@ -52,16 +59,49 @@ function readStoredSession(): DesktopSession | null { } try { + if (shouldDiscardStoredSessionForWindow()) { + window.localStorage.removeItem(sessionStorageKey) + return null + } + const raw = window.localStorage.getItem(sessionStorageKey) if (!raw) { return null } - return JSON.parse(raw) as DesktopSession + return normalizeStoredSession(JSON.parse(raw)) } catch { return null } } +function shouldDiscardStoredSessionForWindow(): boolean { + return new URLSearchParams(window.location.search).get('window') === 'login' +} + +function normalizeStoredSession(value: unknown): DesktopSession | null { + if (!value || typeof value !== 'object') { + return null + } + + const candidate = value as Partial + if (candidate.mode !== 'authenticated' && candidate.mode !== 'preview') { + return null + } + + return { + mode: candidate.mode, + accessToken: typeof candidate.accessToken === 'string' ? candidate.accessToken : null, + accessTokenExpiresAt: + typeof candidate.accessTokenExpiresAt === 'number' ? candidate.accessTokenExpiresAt : null, + refreshToken: typeof candidate.refreshToken === 'string' ? candidate.refreshToken : null, + user: candidate.user ?? null, + clientToken: typeof candidate.clientToken === 'string' ? candidate.clientToken : null, + clientTokenExpiresAt: + typeof candidate.clientTokenExpiresAt === 'number' ? candidate.clientTokenExpiresAt : null, + desktopClient: candidate.desktopClient ?? null, + } +} + function persistSession(next: DesktopSession | null): void { session.value = next @@ -124,6 +164,7 @@ function createAuthenticatedClient() { persistSession({ ...session.value, accessToken: tokens.accessToken, + accessTokenExpiresAt: tokens.expiresAt ?? session.value.accessTokenExpiresAt, refreshToken: tokens.refreshToken, }) }, @@ -137,6 +178,7 @@ function createAuthenticatedClient() { return { accessToken: next.access_token, refreshToken: next.refresh_token, + expiresAt: next.expires_at, } }, }, @@ -170,12 +212,143 @@ function createSessionBoundAuthenticatedClient(source: DesktopSession) { return { accessToken: next.access_token, refreshToken: next.refresh_token, + expiresAt: next.expires_at, } }, }, }) } +function expiresAtMs(value: number | null | undefined): number | null { + if (!value || !Number.isFinite(value)) { + return null + } + return value < 10_000_000_000 ? value * 1000 : value +} + +function isRenewDue(value: number | null | undefined, renewWindowMs: number): boolean { + const expiresAt = expiresAtMs(value) + if (!expiresAt) { + return true + } + return expiresAt - Date.now() <= renewWindowMs +} + +async function refreshLoginTokens(current: DesktopSession): Promise { + if (!current.refreshToken) { + throw new Error('missing_refresh_token') + } + + const next = await createPublicClient().post( + '/api/auth/refresh', + { refresh_token: current.refreshToken }, + ) + + return { + ...current, + accessToken: next.access_token, + accessTokenExpiresAt: next.expires_at, + refreshToken: next.refresh_token, + } +} + +async function rotateRuntimeClientToken(current: DesktopSession): Promise { + if (!current.clientToken || !current.desktopClient) { + throw new Error('missing_desktop_client_token') + } + + const rotated = await window.desktopBridge?.app?.rotateRuntimeClientToken?.() + if (!rotated) { + throw new Error('desktop_client_rotate_unavailable') + } + + return applyRotatedDesktopClient(current, rotated) +} + +function applyRotatedDesktopClient( + current: DesktopSession, + rotated: DesktopClientRotateResponse, +): DesktopSession { + return { + ...current, + clientToken: rotated.client_token, + clientTokenExpiresAt: rotated.expires_at, + desktopClient: rotated.client, + } +} + +async function forceLogoutAfterRenewFailure(err: unknown): Promise { + console.warn('[desktop-session] token renewal failed', err) + error.value = '登录状态已过期,请重新登录' + persistSession(null) + try { + await window.desktopBridge?.app?.releaseRuntimeSession?.(true) + } catch (releaseError) { + console.warn('[desktop-session] release after renewal failure failed', releaseError) + } + await transitionWindowForLogout() +} + +async function renewAuthenticatedSession(options: { force?: boolean } = {}): Promise { + const current = session.value + if (current?.mode !== 'authenticated') { + return + } + + if (renewPromise) { + return renewPromise + } + + renewPromise = (async () => { + let next = current + let expectedSession = current + const needsAccessRefresh = + options.force || isRenewDue(next.accessTokenExpiresAt, accessTokenRenewWindowMs) + const needsClientRotate = + options.force || isRenewDue(next.clientTokenExpiresAt, clientTokenRenewWindowMs) + + if (!needsAccessRefresh && !needsClientRotate) { + return + } + + if (needsAccessRefresh) { + next = await refreshLoginTokens(next) + if (session.value !== expectedSession) { + return + } + persistSession(next) + expectedSession = next + } + + if (needsClientRotate) { + next = await rotateRuntimeClientToken(next) + if (session.value !== expectedSession) { + return + } + persistSession(next) + expectedSession = next + } + })() + .catch(async (err) => { + await forceLogoutAfterRenewFailure(err) + }) + .finally(() => { + renewPromise = null + }) + + return renewPromise +} + +function ensureTokenRenewTimer(): void { + if (typeof window === 'undefined' || tokenRenewTimer) { + return + } + + tokenRenewTimer = setInterval(() => { + void renewAuthenticatedSession() + }, tokenRenewCheckMs) +} + function resolveFallbackPlatform(): string { const currentNavigator = globalThis.navigator as Navigator & { userAgentData?: { platform?: string } @@ -431,6 +604,7 @@ async function performLogin(payload: LoginRequest): Promise { persistSession({ mode: 'authenticated', accessToken: authData.access_token, + accessTokenExpiresAt: authData.expires_at, refreshToken: authData.refresh_token, user: authData.user, clientToken: null, @@ -443,6 +617,7 @@ async function performLogin(payload: LoginRequest): Promise { persistSession({ mode: 'authenticated', accessToken: authData.access_token, + accessTokenExpiresAt: authData.expires_at, refreshToken: authData.refresh_token, user: authData.user, clientToken: registered.client_token, @@ -474,6 +649,7 @@ async function enterPreview(): Promise { persistSession({ mode: 'preview', accessToken: null, + accessTokenExpiresAt: null, refreshToken: null, user: createPreviewUser(), clientToken: 'preview-client-token', @@ -541,6 +717,8 @@ if (typeof window !== 'undefined') { } }) void syncStoredDesktopClientDeviceInfo() + ensureTokenRenewTimer() + void renewAuthenticatedSession() } export function useDesktopSession() { @@ -553,6 +731,7 @@ export function useDesktopSession() { isPreviewMode: computed(() => session.value?.mode === 'preview'), setApiBaseURL: persistApiBaseURL, syncRuntimeSession: syncRuntimeSessionToMain, + renewSession: renewAuthenticatedSession, login, logout, enterPreview, diff --git a/apps/desktop-client/src/renderer/env.d.ts b/apps/desktop-client/src/renderer/env.d.ts index 3048ad8..e35b50a 100644 --- a/apps/desktop-client/src/renderer/env.d.ts +++ b/apps/desktop-client/src/renderer/env.d.ts @@ -1,6 +1,7 @@ import type { CreatePublishJobResponse, DesktopAccountInfo, + DesktopClientRotateResponse, DesktopPublishTaskListResponse, DesktopRuntimeSessionSyncRequest, ListDesktopPublishTasksParams, @@ -55,6 +56,7 @@ declare global { getAppSettings(): Promise setAppSetting(key: keyof DesktopAppSettings, value: boolean): Promise syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise + rotateRuntimeClientToken(): Promise releaseRuntimeSession(revoke?: boolean): Promise setWindowMode( mode: 'login' | 'main',