feat: implement desktop authorization recovery handling and session management improvements
Desktop Client Build / Resolve Build Metadata (push) Successful in 24s
Desktop Client Build / Build Desktop Client (push) Successful in 21m30s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 26s

This commit is contained in:
2026-06-24 11:00:19 +08:00
parent 0afd082d96
commit 6b60438f25
6 changed files with 317 additions and 19 deletions
+116 -9
View File
@@ -83,6 +83,7 @@ import {
resolveDesktopClientReleaseDownloadURL, resolveDesktopClientReleaseDownloadURL,
retryDesktopPublishTask, retryDesktopPublishTask,
rotateDesktopClient, rotateDesktopClient,
setDesktopUnauthorizedRecoveryHandler,
} from './transport/api-client' } from './transport/api-client'
import { initTray, showTrayBalloon } from './tray' import { initTray, showTrayBalloon } from './tray'
import { STANDARD_USER_AGENT } from './user-agent' import { STANDARD_USER_AGENT } from './user-agent'
@@ -174,6 +175,7 @@ let loginWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
let settingsWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null let settingsWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
let windowModeSwitchQueue: Promise<void> = Promise.resolve() let windowModeSwitchQueue: Promise<void> = Promise.resolve()
let clientTokenRotatePromise: Promise<DesktopClientRotateResponse> | null = null let clientTokenRotatePromise: Promise<DesktopClientRotateResponse> | null = null
let desktopAuthRecoveryPromise: Promise<boolean> | null = null
let canHandleDesktopDeepLinks = false let canHandleDesktopDeepLinks = false
const pendingDesktopDeepLinks: string[] = [] const pendingDesktopDeepLinks: string[] = []
const forceCloseWindows = new WeakSet<ElectronBrowserWindow>() const forceCloseWindows = new WeakSet<ElectronBrowserWindow>()
@@ -200,7 +202,19 @@ interface WindowModeRequest {
animate?: boolean 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 WINDOW_READY_TIMEOUT_MS = 1200
const DESKTOP_AUTH_RECOVERY_TIMEOUT_MS = 12_000
const WINDOW_SIZES: Record<WindowMode, WindowSizePreset> = { const WINDOW_SIZES: Record<WindowMode, WindowSizePreset> = {
login: { width: 340, height: 540, minWidth: 340, minHeight: 540, resizable: false }, login: { width: 340, height: 540, minWidth: 340, minHeight: 540, resizable: false },
@@ -442,6 +456,92 @@ async function clearRendererDesktopSessions(): Promise<void> {
) )
} }
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<void> {
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<boolean> {
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<ElectronBrowserWindow> { async function ensureMainWindow(): Promise<ElectronBrowserWindow> {
const existing = currentMainWindow() const existing = currentMainWindow()
if (existing) { if (existing) {
@@ -1214,15 +1314,21 @@ function registerBridgeHandlers(): void {
} }
return clientTokenRotatePromise return clientTokenRotatePromise
}) })
safeHandle('desktop:runtime-session-release', async (_event, revoke?: boolean) => { safeHandle(
if (revoke) { 'desktop:runtime-session-release',
currentWindowMode = 'login' async (_event, revoke?: boolean, options?: { skipRemote?: unknown }) => {
persistWindowMode('login') if (revoke) {
await clearRendererDesktopSessions() currentWindowMode = 'login'
} persistWindowMode('login')
await releaseRuntimeSession({ revoke: Boolean(revoke) }) await clearRendererDesktopSessions()
return null }
}) await releaseRuntimeSession({
revoke: Boolean(revoke),
skipRemote: options?.skipRemote === true,
})
return null
},
)
safeHandle( safeHandle(
'desktop:set-window-mode', 'desktop:set-window-mode',
async (event: IpcMainInvokeEvent, mode: WindowMode, request?: unknown) => { async (event: IpcMainInvokeEvent, mode: WindowMode, request?: unknown) => {
@@ -1264,6 +1370,7 @@ if (!hasSingleInstanceLock) {
await initSessionRegistry() await initSessionRegistry()
initAccountHealth() initAccountHealth()
initTransport() initTransport()
setDesktopUnauthorizedRecoveryHandler(requestDesktopAuthRecovery)
initScheduler() initScheduler()
startStorageCleanupScheduler() startStorageCleanupScheduler()
initProcessMetricsSampler() initProcessMetricsSampler()
@@ -716,9 +716,11 @@ export function syncRuntimeSession(next: DesktopRuntimeSessionSyncRequest | null
} }
} }
export async function releaseRuntimeSession(options: { revoke?: boolean } = {}): Promise<void> { export async function releaseRuntimeSession(
options: { revoke?: boolean; skipRemote?: boolean } = {},
): Promise<void> {
const currentSession = state.session const currentSession = state.session
const shouldReleaseRemote = canRunLive(currentSession) const shouldReleaseRemote = !options.skipRemote && canRunLive(currentSession)
if (shouldReleaseRemote) { if (shouldReleaseRemote) {
try { try {
@@ -60,6 +60,20 @@ interface DesktopTransportSession {
clientToken: string | null 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<boolean>
interface MonitoringCancelTaskPayload { interface MonitoringCancelTaskPayload {
lease_token: string lease_token: string
reason?: string | null reason?: string | null
@@ -75,6 +89,7 @@ let transportSession: DesktopTransportSession = {
baseURL: normalizeDesktopApiBaseURL(process.env.DESKTOP_API_BASE_URL), baseURL: normalizeDesktopApiBaseURL(process.env.DESKTOP_API_BASE_URL),
clientToken: null, clientToken: null,
} }
let unauthorizedRecoveryHandler: DesktopUnauthorizedRecoveryHandler | null = null
const ACCOUNT_UPSERT_CACHE_TTL_MS = 5 * 60_000 const ACCOUNT_UPSERT_CACHE_TTL_MS = 5 * 60_000
const ACCOUNT_UPSERT_DIAGNOSTIC_FILE = 'desktop-account-upsert-errors.jsonl' const ACCOUNT_UPSERT_DIAGNOSTIC_FILE = 'desktop-account-upsert-errors.jsonl'
@@ -319,8 +334,40 @@ function shouldFallbackToMainProcess(error: unknown): boolean {
return false 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<boolean> {
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<T, B = unknown>( async function proxyDesktopRequest<T, B = unknown>(
method: 'GET' | 'POST' | 'DELETE', method: DesktopRequestMethod,
path: string, path: string,
body?: B, body?: B,
): Promise<T> { ): Promise<T> {
@@ -384,13 +431,23 @@ async function desktopGet<T>(path: string): Promise<T> {
try { try {
return await proxyDesktopRequest<T>('GET', path) return await proxyDesktopRequest<T>('GET', path)
} catch (error) { } catch (error) {
if (await recoverDesktopAuthorization('GET', path, error)) {
return proxyDesktopRequest<T>('GET', path)
}
if (!shouldFallbackToMainProcess(error)) { if (!shouldFallbackToMainProcess(error)) {
throw error throw error
} }
transportState.requestDebugMode = 'main-process' transportState.requestDebugMode = 'main-process'
} }
} }
return getDesktopApiClient().get<T>(path) try {
return await getDesktopApiClient().get<T>(path)
} catch (error) {
if (await recoverDesktopAuthorization('GET', path, error)) {
return getDesktopApiClient().get<T>(path)
}
throw error
}
} }
async function desktopPost<T, B = unknown>(path: string, body?: B): Promise<T> { async function desktopPost<T, B = unknown>(path: string, body?: B): Promise<T> {
@@ -399,13 +456,23 @@ async function desktopPost<T, B = unknown>(path: string, body?: B): Promise<T> {
try { try {
return await proxyDesktopRequest<T, B>('POST', path, body) return await proxyDesktopRequest<T, B>('POST', path, body)
} catch (error) { } catch (error) {
if (await recoverDesktopAuthorization('POST', path, error)) {
return proxyDesktopRequest<T, B>('POST', path, body)
}
if (!shouldFallbackToMainProcess(error)) { if (!shouldFallbackToMainProcess(error)) {
throw error throw error
} }
transportState.requestDebugMode = 'main-process' transportState.requestDebugMode = 'main-process'
} }
} }
return getDesktopApiClient().post<T, B>(path, body) try {
return await getDesktopApiClient().post<T, B>(path, body)
} catch (error) {
if (await recoverDesktopAuthorization('POST', path, error)) {
return getDesktopApiClient().post<T, B>(path, body)
}
throw error
}
} }
async function desktopDelete<T>(path: string): Promise<T> { async function desktopDelete<T>(path: string): Promise<T> {
@@ -414,13 +481,23 @@ async function desktopDelete<T>(path: string): Promise<T> {
try { try {
return await proxyDesktopRequest<T>('DELETE', path) return await proxyDesktopRequest<T>('DELETE', path)
} catch (error) { } catch (error) {
if (await recoverDesktopAuthorization('DELETE', path, error)) {
return proxyDesktopRequest<T>('DELETE', path)
}
if (!shouldFallbackToMainProcess(error)) { if (!shouldFallbackToMainProcess(error)) {
throw error throw error
} }
transportState.requestDebugMode = 'main-process' transportState.requestDebugMode = 'main-process'
} }
} }
return getDesktopApiClient().remove<T>(path) try {
return await getDesktopApiClient().remove<T>(path)
} catch (error) {
if (await recoverDesktopAuthorization('DELETE', path, error)) {
return getDesktopApiClient().remove<T>(path)
}
throw error
}
} }
export function initTransport(): ApiClient { export function initTransport(): ApiClient {
@@ -474,6 +551,12 @@ export function setTransportDispatchState(next: TransportDispatchState): void {
transportState.dispatchState = next transportState.dispatchState = next
} }
export function setDesktopUnauthorizedRecoveryHandler(
handler: DesktopUnauthorizedRecoveryHandler | null,
): void {
unauthorizedRecoveryHandler = handler
}
export function noteTransportHeartbeat(success: boolean): void { export function noteTransportHeartbeat(success: boolean): void {
transportState.lastHeartbeatAt = Date.now() transportState.lastHeartbeatAt = Date.now()
transportState.lastHeartbeatStatus = success ? 'success' : 'failed' transportState.lastHeartbeatStatus = success ? 'success' : 'failed'
+22 -2
View File
@@ -39,6 +39,17 @@ interface WindowModeRequest {
animate?: boolean 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 { interface DesktopAppSettings {
openAtLogin: boolean openAtLogin: boolean
keepRunningInBackground: boolean keepRunningInBackground: boolean
@@ -243,10 +254,19 @@ const desktopBridge = {
ipcRenderer.invoke( ipcRenderer.invoke(
'desktop:runtime-session-rotate-client-token', 'desktop:runtime-session-rotate-client-token',
) as Promise<DesktopClientRotateResponse>, ) as Promise<DesktopClientRotateResponse>,
releaseRuntimeSession: (revoke?: boolean) => releaseRuntimeSession: (revoke?: boolean, options?: { skipRemote?: boolean }) =>
ipcRenderer.invoke('desktop:runtime-session-release', revoke) as Promise<null>, ipcRenderer.invoke('desktop:runtime-session-release', revoke, options) as Promise<null>,
setWindowMode: (mode: 'login' | 'main', request?: WindowModeRequest) => setWindowMode: (mode: 'login' | 'main', request?: WindowModeRequest) =>
ipcRenderer.invoke('desktop:set-window-mode', mode, request) as Promise<null>, ipcRenderer.invoke('desktop:set-window-mode', mode, request) as Promise<null>,
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: ( onRuntimeInvalidated: (
listener: (event: { listener: (event: {
reason: reason:
@@ -53,6 +53,7 @@ const error = shallowRef<string | null>(null)
let loginPromise: Promise<void> | null = null let loginPromise: Promise<void> | null = null
let logoutPromise: Promise<void> | null = null let logoutPromise: Promise<void> | null = null
let renewPromise: Promise<void> | null = null let renewPromise: Promise<void> | null = null
let recoverDesktopClientPromise: Promise<void> | null = null
let tokenRenewTimer: ReturnType<typeof setInterval> | null = null let tokenRenewTimer: ReturnType<typeof setInterval> | null = null
function logRuntimeSessionSyncFailure(reason: string, err: unknown): void { function logRuntimeSessionSyncFailure(reason: string, err: unknown): void {
@@ -300,6 +301,20 @@ async function rotateRuntimeClientToken(current: DesktopSession): Promise<Deskto
return applyRotatedDesktopClient(current, rotated) return applyRotatedDesktopClient(current, rotated)
} }
async function refreshDesktopClientRegistration(current: DesktopSession): Promise<DesktopSession> {
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( function applyRotatedDesktopClient(
current: DesktopSession, current: DesktopSession,
rotated: DesktopClientRotateResponse, rotated: DesktopClientRotateResponse,
@@ -317,7 +332,7 @@ async function forceLogoutAfterRenewFailure(err: unknown): Promise<void> {
error.value = '登录状态已过期,请重新登录' error.value = '登录状态已过期,请重新登录'
persistSession(null) persistSession(null)
try { try {
await window.desktopBridge?.app?.releaseRuntimeSession?.(true) await window.desktopBridge?.app?.releaseRuntimeSession?.(false, { skipRemote: true })
} catch (releaseError) { } catch (releaseError) {
console.warn('[desktop-session] release after renewal failure failed', releaseError) console.warn('[desktop-session] release after renewal failure failed', releaseError)
} }
@@ -400,6 +415,54 @@ async function renewAuthenticatedSession(options: { force?: boolean } = {}): Pro
return renewPromise return renewPromise
} }
async function recoverDesktopClientSession(): Promise<void> {
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 { function ensureTokenRenewTimer(): void {
if (typeof window === 'undefined' || tokenRenewTimer) { if (typeof window === 'undefined' || tokenRenewTimer) {
return return
@@ -820,6 +883,15 @@ if (typeof window !== 'undefined') {
void syncStoredDesktopClientDeviceInfo() void syncStoredDesktopClientDeviceInfo()
ensureTokenRenewTimer() ensureTokenRenewTimer()
void renewAuthenticatedSession() 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() { export function useDesktopSession() {
@@ -833,6 +905,7 @@ export function useDesktopSession() {
setApiBaseURL: persistApiBaseURL, setApiBaseURL: persistApiBaseURL,
syncRuntimeSession: syncRuntimeSessionToMain, syncRuntimeSession: syncRuntimeSessionToMain,
renewSession: renewAuthenticatedSession, renewSession: renewAuthenticatedSession,
recoverDesktopClientSession,
login, login,
logout, logout,
enterPreview, enterPreview,
+14 -1
View File
@@ -9,6 +9,7 @@ import type {
DesktopClientUpdateResult, DesktopClientUpdateResult,
DesktopPublishTaskListResponse, DesktopPublishTaskListResponse,
DesktopRuntimeSessionSyncRequest, DesktopRuntimeSessionSyncRequest,
DesktopTaskInfo,
ListDesktopPublishTasksParams, ListDesktopPublishTasksParams,
} from '@geo/shared-types' } from '@geo/shared-types'
import type { DesktopObservedRequest } from '../shared/network-debug' import type { DesktopObservedRequest } from '../shared/network-debug'
@@ -75,6 +76,17 @@ declare global {
secure: boolean 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 { interface Window {
desktopBridge: { desktopBridge: {
app: { app: {
@@ -132,7 +144,7 @@ declare global {
cleanStorage(): Promise<DesktopStorageCleanupResult> cleanStorage(): Promise<DesktopStorageCleanupResult>
syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise<null> syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise<null>
rotateRuntimeClientToken(): Promise<DesktopClientRotateResponse> rotateRuntimeClientToken(): Promise<DesktopClientRotateResponse>
releaseRuntimeSession(revoke?: boolean): Promise<null> releaseRuntimeSession(revoke?: boolean, options?: { skipRemote?: boolean }): Promise<null>
setWindowMode( setWindowMode(
mode: 'login' | 'main', mode: 'login' | 'main',
request?: { request?: {
@@ -140,6 +152,7 @@ declare global {
animate?: boolean animate?: boolean
}, },
): Promise<null> ): Promise<null>
onAuthRecoveryRequested(listener: (event: DesktopAuthRecoveryRequest) => void): () => void
onRuntimeInvalidated( onRuntimeInvalidated(
listener: (event: { listener: (event: {
reason: reason: