From 6b60438f25707d701e20220d604c6c92f4b9bb8c Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 24 Jun 2026 11:00:19 +0800 Subject: [PATCH] feat: implement desktop authorization recovery handling and session management improvements --- apps/desktop-client/src/main/bootstrap.ts | 125 ++++++++++++++++-- .../src/main/runtime-controller.ts | 6 +- .../src/main/transport/api-client.ts | 91 ++++++++++++- apps/desktop-client/src/preload/bridge.ts | 24 +++- .../renderer/composables/useDesktopSession.ts | 75 ++++++++++- apps/desktop-client/src/renderer/env.d.ts | 15 ++- 6 files changed, 317 insertions(+), 19 deletions(-) diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 470406f..b8d8a64 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -83,6 +83,7 @@ import { resolveDesktopClientReleaseDownloadURL, retryDesktopPublishTask, rotateDesktopClient, + setDesktopUnauthorizedRecoveryHandler, } from './transport/api-client' import { initTray, showTrayBalloon } from './tray' import { STANDARD_USER_AGENT } from './user-agent' @@ -174,6 +175,7 @@ let loginWindowCreatePromise: Promise | null = null let settingsWindowCreatePromise: Promise | null = null let windowModeSwitchQueue: Promise = Promise.resolve() let clientTokenRotatePromise: Promise | null = null +let desktopAuthRecoveryPromise: Promise | null = null let canHandleDesktopDeepLinks = false const pendingDesktopDeepLinks: string[] = [] const forceCloseWindows = new WeakSet() @@ -200,7 +202,19 @@ interface WindowModeRequest { animate?: boolean } +interface DesktopAuthRecoveryRequest { + reason: 'desktop-client-token-unauthorized' + method: string + path: string + status: number + code?: number + message: string + requestId: string + at: number +} + const WINDOW_READY_TIMEOUT_MS = 1200 +const DESKTOP_AUTH_RECOVERY_TIMEOUT_MS = 12_000 const WINDOW_SIZES: Record = { login: { width: 340, height: 540, minWidth: 340, minHeight: 540, resizable: false }, @@ -442,6 +456,92 @@ async function clearRendererDesktopSessions(): Promise { ) } +function desktopAuthRecoveryRequestId(): string { + return `desktop-auth-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +function sendDesktopAuthRecoveryRequest(payload: DesktopAuthRecoveryRequest): boolean { + const targetContents = + mainRendererContents && !mainRendererContents.isDestroyed() + ? mainRendererContents + : (currentMainWindow() ?? currentActiveWindow())?.webContents + + if (targetContents && !targetContents.isDestroyed()) { + targetContents.send('desktop:auth-recovery-requested', payload) + return true + } + return false +} + +async function expireDesktopSessionToLogin(reason: string): Promise { + console.warn('[desktop-main] desktop auth expired; returning to login', { reason }) + currentWindowMode = 'login' + persistWindowMode('login') + await Promise.race([clearRendererDesktopSessions(), timeout(1000)]) + await Promise.race([releaseRuntimeSession({ skipRemote: true }), timeout(1500)]) + await queueWindowModeSwitch('login', { source: 'auth-state', animate: true }) +} + +async function requestDesktopAuthRecovery(context: { + method: string + path: string + status: number + code?: number + message: string +}): Promise { + if (desktopAuthRecoveryPromise) { + return desktopAuthRecoveryPromise + } + + desktopAuthRecoveryPromise = (async () => { + const requestId = desktopAuthRecoveryRequestId() + const beforeToken = getRuntimeControllerSnapshot().session?.client_token ?? null + + const requested = sendDesktopAuthRecoveryRequest({ + reason: 'desktop-client-token-unauthorized', + method: context.method, + path: context.path, + status: context.status, + code: context.code, + message: context.message, + requestId, + at: Date.now(), + }) + if (!requested) { + throw new Error('desktop_auth_recovery_target_unavailable') + } + + const deadline = Date.now() + DESKTOP_AUTH_RECOVERY_TIMEOUT_MS + while (Date.now() < deadline) { + await timeout(250) + const snapshot = getRuntimeControllerSnapshot() + const nextToken = snapshot.session?.client_token ?? null + if (nextToken && nextToken !== beforeToken) { + return true + } + if (!snapshot.session) { + break + } + } + + await expireDesktopSessionToLogin( + context.message || `desktop_auth_recovery_failed_${requestId}`, + ) + return false + })() + .catch(async (error) => { + await expireDesktopSessionToLogin( + error instanceof Error ? error.message : 'desktop_auth_recovery_failed', + ) + return false + }) + .finally(() => { + desktopAuthRecoveryPromise = null + }) + + return desktopAuthRecoveryPromise +} + async function ensureMainWindow(): Promise { const existing = currentMainWindow() if (existing) { @@ -1214,15 +1314,21 @@ function registerBridgeHandlers(): void { } 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 - }) + safeHandle( + 'desktop:runtime-session-release', + async (_event, revoke?: boolean, options?: { skipRemote?: unknown }) => { + if (revoke) { + currentWindowMode = 'login' + persistWindowMode('login') + await clearRendererDesktopSessions() + } + await releaseRuntimeSession({ + revoke: Boolean(revoke), + skipRemote: options?.skipRemote === true, + }) + return null + }, + ) safeHandle( 'desktop:set-window-mode', async (event: IpcMainInvokeEvent, mode: WindowMode, request?: unknown) => { @@ -1264,6 +1370,7 @@ if (!hasSingleInstanceLock) { await initSessionRegistry() initAccountHealth() initTransport() + setDesktopUnauthorizedRecoveryHandler(requestDesktopAuthRecovery) initScheduler() startStorageCleanupScheduler() initProcessMetricsSampler() diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index bd814ff..2f338c5 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -716,9 +716,11 @@ export function syncRuntimeSession(next: DesktopRuntimeSessionSyncRequest | null } } -export async function releaseRuntimeSession(options: { revoke?: boolean } = {}): Promise { +export async function releaseRuntimeSession( + options: { revoke?: boolean; skipRemote?: boolean } = {}, +): Promise { const currentSession = state.session - const shouldReleaseRemote = canRunLive(currentSession) + const shouldReleaseRemote = !options.skipRemote && canRunLive(currentSession) if (shouldReleaseRemote) { try { diff --git a/apps/desktop-client/src/main/transport/api-client.ts b/apps/desktop-client/src/main/transport/api-client.ts index 6807d39..244ce95 100644 --- a/apps/desktop-client/src/main/transport/api-client.ts +++ b/apps/desktop-client/src/main/transport/api-client.ts @@ -60,6 +60,20 @@ interface DesktopTransportSession { clientToken: string | null } +type DesktopRequestMethod = 'GET' | 'POST' | 'DELETE' + +interface DesktopUnauthorizedRecoveryContext { + method: DesktopRequestMethod + path: string + status: number + code?: number + message: string +} + +type DesktopUnauthorizedRecoveryHandler = ( + context: DesktopUnauthorizedRecoveryContext, +) => Promise + interface MonitoringCancelTaskPayload { lease_token: string reason?: string | null @@ -75,6 +89,7 @@ let transportSession: DesktopTransportSession = { baseURL: normalizeDesktopApiBaseURL(process.env.DESKTOP_API_BASE_URL), clientToken: null, } +let unauthorizedRecoveryHandler: DesktopUnauthorizedRecoveryHandler | null = null const ACCOUNT_UPSERT_CACHE_TTL_MS = 5 * 60_000 const ACCOUNT_UPSERT_DIAGNOSTIC_FILE = 'desktop-account-upsert-errors.jsonl' @@ -319,8 +334,40 @@ function shouldFallbackToMainProcess(error: unknown): boolean { return false } +function isDesktopUnauthorizedError(error: unknown): error is ApiClientError { + return error instanceof ApiClientError && error.status === 401 +} + +async function recoverDesktopAuthorization( + method: DesktopRequestMethod, + path: string, + error: unknown, +): Promise { + if (!unauthorizedRecoveryHandler || !isDesktopUnauthorizedError(error)) { + return false + } + + setTransportAuthState('expired') + try { + return await unauthorizedRecoveryHandler({ + method, + path, + status: error.status ?? 401, + code: error.code, + message: error.message, + }) + } catch (recoveryError) { + console.warn('[desktop-transport] unauthorized recovery failed', { + method, + path, + message: recoveryError instanceof Error ? recoveryError.message : String(recoveryError), + }) + return false + } +} + async function proxyDesktopRequest( - method: 'GET' | 'POST' | 'DELETE', + method: DesktopRequestMethod, path: string, body?: B, ): Promise { @@ -384,13 +431,23 @@ async function desktopGet(path: string): Promise { try { return await proxyDesktopRequest('GET', path) } catch (error) { + if (await recoverDesktopAuthorization('GET', path, error)) { + return proxyDesktopRequest('GET', path) + } if (!shouldFallbackToMainProcess(error)) { throw error } transportState.requestDebugMode = 'main-process' } } - return getDesktopApiClient().get(path) + try { + return await getDesktopApiClient().get(path) + } catch (error) { + if (await recoverDesktopAuthorization('GET', path, error)) { + return getDesktopApiClient().get(path) + } + throw error + } } async function desktopPost(path: string, body?: B): Promise { @@ -399,13 +456,23 @@ async function desktopPost(path: string, body?: B): Promise { try { return await proxyDesktopRequest('POST', path, body) } catch (error) { + if (await recoverDesktopAuthorization('POST', path, error)) { + return proxyDesktopRequest('POST', path, body) + } if (!shouldFallbackToMainProcess(error)) { throw error } transportState.requestDebugMode = 'main-process' } } - return getDesktopApiClient().post(path, body) + try { + return await getDesktopApiClient().post(path, body) + } catch (error) { + if (await recoverDesktopAuthorization('POST', path, error)) { + return getDesktopApiClient().post(path, body) + } + throw error + } } async function desktopDelete(path: string): Promise { @@ -414,13 +481,23 @@ async function desktopDelete(path: string): Promise { try { return await proxyDesktopRequest('DELETE', path) } catch (error) { + if (await recoverDesktopAuthorization('DELETE', path, error)) { + return proxyDesktopRequest('DELETE', path) + } if (!shouldFallbackToMainProcess(error)) { throw error } transportState.requestDebugMode = 'main-process' } } - return getDesktopApiClient().remove(path) + try { + return await getDesktopApiClient().remove(path) + } catch (error) { + if (await recoverDesktopAuthorization('DELETE', path, error)) { + return getDesktopApiClient().remove(path) + } + throw error + } } export function initTransport(): ApiClient { @@ -474,6 +551,12 @@ export function setTransportDispatchState(next: TransportDispatchState): void { transportState.dispatchState = next } +export function setDesktopUnauthorizedRecoveryHandler( + handler: DesktopUnauthorizedRecoveryHandler | null, +): void { + unauthorizedRecoveryHandler = handler +} + export function noteTransportHeartbeat(success: boolean): void { transportState.lastHeartbeatAt = Date.now() transportState.lastHeartbeatStatus = success ? 'success' : 'failed' diff --git a/apps/desktop-client/src/preload/bridge.ts b/apps/desktop-client/src/preload/bridge.ts index fce63f7..545bfdb 100644 --- a/apps/desktop-client/src/preload/bridge.ts +++ b/apps/desktop-client/src/preload/bridge.ts @@ -39,6 +39,17 @@ interface WindowModeRequest { animate?: boolean } +interface DesktopAuthRecoveryRequest { + reason: 'desktop-client-token-unauthorized' + method: string + path: string + status: number + code?: number + message: string + requestId: string + at: number +} + interface DesktopAppSettings { openAtLogin: boolean keepRunningInBackground: boolean @@ -243,10 +254,19 @@ const desktopBridge = { ipcRenderer.invoke( 'desktop:runtime-session-rotate-client-token', ) as Promise, - releaseRuntimeSession: (revoke?: boolean) => - ipcRenderer.invoke('desktop:runtime-session-release', revoke) as Promise, + releaseRuntimeSession: (revoke?: boolean, options?: { skipRemote?: boolean }) => + ipcRenderer.invoke('desktop:runtime-session-release', revoke, options) as Promise, setWindowMode: (mode: 'login' | 'main', request?: WindowModeRequest) => ipcRenderer.invoke('desktop:set-window-mode', mode, request) as Promise, + onAuthRecoveryRequested: (listener: (event: DesktopAuthRecoveryRequest) => void) => { + const wrapped = (_event: IpcRendererEvent, payload: DesktopAuthRecoveryRequest) => { + listener(payload) + } + ipcRenderer.on('desktop:auth-recovery-requested', wrapped) + return () => { + ipcRenderer.off('desktop:auth-recovery-requested', wrapped) + } + }, onRuntimeInvalidated: ( listener: (event: { reason: diff --git a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts index e33515a..4a72db9 100644 --- a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts +++ b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts @@ -53,6 +53,7 @@ const error = shallowRef(null) let loginPromise: Promise | null = null let logoutPromise: Promise | null = null let renewPromise: Promise | null = null +let recoverDesktopClientPromise: Promise | null = null let tokenRenewTimer: ReturnType | null = null function logRuntimeSessionSyncFailure(reason: string, err: unknown): void { @@ -300,6 +301,20 @@ async function rotateRuntimeClientToken(current: DesktopSession): Promise { + if (!current.user) { + throw new Error('missing_desktop_user') + } + + const registered = await registerDesktopClient(current.user) + return { + ...current, + clientToken: registered.client_token, + clientTokenExpiresAt: registered.expires_at, + desktopClient: registered.client, + } +} + function applyRotatedDesktopClient( current: DesktopSession, rotated: DesktopClientRotateResponse, @@ -317,7 +332,7 @@ async function forceLogoutAfterRenewFailure(err: unknown): Promise { error.value = '登录状态已过期,请重新登录' persistSession(null) try { - await window.desktopBridge?.app?.releaseRuntimeSession?.(true) + await window.desktopBridge?.app?.releaseRuntimeSession?.(false, { skipRemote: true }) } catch (releaseError) { console.warn('[desktop-session] release after renewal failure failed', releaseError) } @@ -400,6 +415,54 @@ async function renewAuthenticatedSession(options: { force?: boolean } = {}): Pro return renewPromise } +async function recoverDesktopClientSession(): Promise { + const current = session.value + if (current?.mode !== 'authenticated') { + await forceLogoutAfterRenewFailure(new Error('desktop_auth_recovery_requires_login')) + return + } + + if (recoverDesktopClientPromise) { + return recoverDesktopClientPromise + } + + recoverDesktopClientPromise = (async () => { + let next = current + let expectedSession = current + + try { + next = await refreshLoginTokens(next) + } catch (err) { + if (isApiAuthExpiredError(err)) { + throw err + } + keepSessionAfterRenewFailure(err) + throw err + } + + if (session.value !== expectedSession) { + return + } + persistSession(next) + expectedSession = next + + next = await refreshDesktopClientRegistration(next) + if (session.value !== expectedSession) { + return + } + persistSession(next) + })() + .catch(async (err) => { + await forceLogoutAfterRenewFailure(err) + throw err + }) + .finally(() => { + recoverDesktopClientPromise = null + }) + + return recoverDesktopClientPromise +} + function ensureTokenRenewTimer(): void { if (typeof window === 'undefined' || tokenRenewTimer) { return @@ -820,6 +883,15 @@ if (typeof window !== 'undefined') { void syncStoredDesktopClientDeviceInfo() ensureTokenRenewTimer() void renewAuthenticatedSession() + + window.desktopBridge?.app?.onAuthRecoveryRequested?.((event) => { + void recoverDesktopClientSession().catch((err) => { + console.warn('[desktop-session] desktop auth recovery failed', { + requestId: event.requestId, + message: err instanceof Error ? err.message : String(err), + }) + }) + }) } export function useDesktopSession() { @@ -833,6 +905,7 @@ export function useDesktopSession() { setApiBaseURL: persistApiBaseURL, syncRuntimeSession: syncRuntimeSessionToMain, renewSession: renewAuthenticatedSession, + recoverDesktopClientSession, login, logout, enterPreview, diff --git a/apps/desktop-client/src/renderer/env.d.ts b/apps/desktop-client/src/renderer/env.d.ts index 30ee1e9..cfb6aa0 100644 --- a/apps/desktop-client/src/renderer/env.d.ts +++ b/apps/desktop-client/src/renderer/env.d.ts @@ -9,6 +9,7 @@ import type { DesktopClientUpdateResult, DesktopPublishTaskListResponse, DesktopRuntimeSessionSyncRequest, + DesktopTaskInfo, ListDesktopPublishTasksParams, } from '@geo/shared-types' import type { DesktopObservedRequest } from '../shared/network-debug' @@ -75,6 +76,17 @@ declare global { secure: boolean } + interface DesktopAuthRecoveryRequest { + reason: 'desktop-client-token-unauthorized' + method: string + path: string + status: number + code?: number + message: string + requestId: string + at: number + } + interface Window { desktopBridge: { app: { @@ -132,7 +144,7 @@ declare global { cleanStorage(): Promise syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise rotateRuntimeClientToken(): Promise - releaseRuntimeSession(revoke?: boolean): Promise + releaseRuntimeSession(revoke?: boolean, options?: { skipRemote?: boolean }): Promise setWindowMode( mode: 'login' | 'main', request?: { @@ -140,6 +152,7 @@ declare global { animate?: boolean }, ): Promise + onAuthRecoveryRequested(listener: (event: DesktopAuthRecoveryRequest) => void): () => void onRuntimeInvalidated( listener: (event: { reason: