feat(desktop): implement client token rotation and session management enhancements
Desktop Client Build / Resolve Build Metadata (push) Successful in 27s
Desktop Client Build / Build Desktop Client (push) Successful in 22m14s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 26s

This commit is contained in:
Xu Liang
2026-05-08 11:43:25 +08:00
parent 73f1b2aca8
commit f38930b0ac
7 changed files with 317 additions and 6 deletions
+84 -4
View File
@@ -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<ElectronBrowserWindow> | null = null
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 canHandleDesktopDeepLinks = false
const pendingDesktopDeepLinks: string[] = []
const forceCloseWindows = new WeakSet<ElectronBrowserWindow>()
@@ -358,6 +365,32 @@ function captureRuntimeAccountSnapshot(accountId: string) {
return createRuntimeAccountSnapshot(accountId)
}
async function clearRendererDesktopSession(window: ElectronBrowserWindow | null): Promise<void> {
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<void> {
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<ElectronBrowserWindow> {
const existing = currentMainWindow()
if (existing) {
@@ -491,6 +524,13 @@ function queueWindowModeSwitch(
return nextSwitch
}
async function forceLoginWindowForAuthExpired(message: string): Promise<void> {
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<void> {
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<void>((resolve) => setTimeout(resolve, 1500)),
]).finally(() => {
app.quit()
@@ -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 {
@@ -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<RuntimeInvalidationListener>()
const authExpiredListeners = new Set<RuntimeAuthExpiredListener>()
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)
}
}
@@ -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<DesktopClientRotateResponse> {
return desktopPost<DesktopClientRotateResponse, Record<string, never>>(
'/api/desktop/clients/rotate',
{},
)
}
export async function offlineDesktopClient(): Promise<void> {
await desktopPost<{ server_time: string }, Record<string, never>>(
'/api/desktop/clients/offline',
@@ -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<DesktopAppSettings>,
syncRuntimeSession: (session: DesktopRuntimeSessionSyncRequest | null) =>
ipcRenderer.invoke('desktop:runtime-session-sync', session) as Promise<null>,
rotateRuntimeClientToken: () =>
ipcRenderer.invoke(
'desktop:runtime-session-rotate-client-token',
) as Promise<DesktopClientRotateResponse>,
releaseRuntimeSession: (revoke?: boolean) =>
ipcRenderer.invoke('desktop:runtime-session-release', revoke) as Promise<null>,
setWindowMode: (mode: 'login' | 'main', request?: WindowModeRequest) =>
@@ -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<string | null>(null)
let loginPromise: Promise<void> | null = null
let logoutPromise: Promise<void> | null = null
let renewPromise: Promise<void> | null = null
let tokenRenewTimer: ReturnType<typeof setInterval> | 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<DesktopSession>
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<DesktopSession> {
if (!current.refreshToken) {
throw new Error('missing_refresh_token')
}
const next = await createPublicClient().post<RefreshResponse, { refresh_token: string }>(
'/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<DesktopSession> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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,
+2
View File
@@ -1,6 +1,7 @@
import type {
CreatePublishJobResponse,
DesktopAccountInfo,
DesktopClientRotateResponse,
DesktopPublishTaskListResponse,
DesktopRuntimeSessionSyncRequest,
ListDesktopPublishTasksParams,
@@ -55,6 +56,7 @@ declare global {
getAppSettings(): Promise<DesktopAppSettings>
setAppSetting(key: keyof DesktopAppSettings, value: boolean): Promise<DesktopAppSettings>
syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise<null>
rotateRuntimeClientToken(): Promise<DesktopClientRotateResponse>
releaseRuntimeSession(revoke?: boolean): Promise<null>
setWindowMode(
mode: 'login' | 'main',