feat: implement desktop authorization recovery handling and session management improvements
This commit is contained in:
@@ -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<ElectronBrowserWindow> | null = null
|
||||
let settingsWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
|
||||
let windowModeSwitchQueue: Promise<void> = Promise.resolve()
|
||||
let clientTokenRotatePromise: Promise<DesktopClientRotateResponse> | null = null
|
||||
let desktopAuthRecoveryPromise: Promise<boolean> | null = null
|
||||
let canHandleDesktopDeepLinks = false
|
||||
const pendingDesktopDeepLinks: string[] = []
|
||||
const forceCloseWindows = new WeakSet<ElectronBrowserWindow>()
|
||||
@@ -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<WindowMode, WindowSizePreset> = {
|
||||
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> {
|
||||
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()
|
||||
|
||||
@@ -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 shouldReleaseRemote = canRunLive(currentSession)
|
||||
const shouldReleaseRemote = !options.skipRemote && canRunLive(currentSession)
|
||||
|
||||
if (shouldReleaseRemote) {
|
||||
try {
|
||||
|
||||
@@ -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<boolean>
|
||||
|
||||
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<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>(
|
||||
method: 'GET' | 'POST' | 'DELETE',
|
||||
method: DesktopRequestMethod,
|
||||
path: string,
|
||||
body?: B,
|
||||
): Promise<T> {
|
||||
@@ -384,13 +431,23 @@ async function desktopGet<T>(path: string): Promise<T> {
|
||||
try {
|
||||
return await proxyDesktopRequest<T>('GET', path)
|
||||
} catch (error) {
|
||||
if (await recoverDesktopAuthorization('GET', path, error)) {
|
||||
return proxyDesktopRequest<T>('GET', path)
|
||||
}
|
||||
if (!shouldFallbackToMainProcess(error)) {
|
||||
throw error
|
||||
}
|
||||
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> {
|
||||
@@ -399,13 +456,23 @@ async function desktopPost<T, B = unknown>(path: string, body?: B): Promise<T> {
|
||||
try {
|
||||
return await proxyDesktopRequest<T, B>('POST', path, body)
|
||||
} catch (error) {
|
||||
if (await recoverDesktopAuthorization('POST', path, error)) {
|
||||
return proxyDesktopRequest<T, B>('POST', path, body)
|
||||
}
|
||||
if (!shouldFallbackToMainProcess(error)) {
|
||||
throw error
|
||||
}
|
||||
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> {
|
||||
@@ -414,13 +481,23 @@ async function desktopDelete<T>(path: string): Promise<T> {
|
||||
try {
|
||||
return await proxyDesktopRequest<T>('DELETE', path)
|
||||
} catch (error) {
|
||||
if (await recoverDesktopAuthorization('DELETE', path, error)) {
|
||||
return proxyDesktopRequest<T>('DELETE', path)
|
||||
}
|
||||
if (!shouldFallbackToMainProcess(error)) {
|
||||
throw error
|
||||
}
|
||||
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 {
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user