Files
geo/apps/desktop-client/src/renderer/composables/useDesktopSession.ts
T
Xu Liang f38930b0ac
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
feat(desktop): implement client token rotation and session management enhancements
2026-05-08 11:43:25 +08:00

740 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { computed, readonly, shallowRef } from 'vue'
import { ApiClientError, createApiClient } from '@geo/http-client'
import type {
AuthTokens,
DesktopClientInfo,
DesktopClientRegisterRequest,
DesktopClientRegisterResponse,
DesktopClientRotateResponse,
DesktopRuntimeSessionSyncRequest,
LoginRequest,
LoginResponse,
RefreshResponse,
UserInfo,
} from '@geo/shared-types'
import { normalizeDesktopApiBaseURL } from '../../shared/api-base-url'
type SessionMode = 'authenticated' | 'preview'
interface DesktopSession {
mode: SessionMode
accessToken: string | null
accessTokenExpiresAt: number | null
refreshToken: string | null
user: UserInfo | null
clientToken: string | null
clientTokenExpiresAt: number | null
desktopClient: DesktopClientInfo | null
}
interface ResolvedDeviceInfo {
device_name: string
os: string
cpu_arch: string
}
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)
const session = shallowRef<DesktopSession | null>(readStoredSession())
const apiBaseURL = shallowRef(readStoredApiBaseURL())
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') {
return null
}
try {
if (shouldDiscardStoredSessionForWindow()) {
window.localStorage.removeItem(sessionStorageKey)
return null
}
const raw = window.localStorage.getItem(sessionStorageKey)
if (!raw) {
return null
}
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
if (typeof window === 'undefined') {
return
}
if (!next) {
window.localStorage.removeItem(sessionStorageKey)
} else {
window.localStorage.setItem(sessionStorageKey, JSON.stringify(next))
}
void syncRuntimeSessionToMain(next)
}
function readStoredApiBaseURL(): string {
if (typeof window === 'undefined') {
return normalizedDefaultApiBaseURL
}
const stored = window.localStorage.getItem(apiBaseURLStorageKey)
const normalized = normalizeDesktopApiBaseURL(stored, normalizedDefaultApiBaseURL)
if (stored && stored !== normalized) {
window.localStorage.setItem(apiBaseURLStorageKey, normalized)
}
return normalized
}
function persistApiBaseURL(value: string): void {
const normalized = normalizeDesktopApiBaseURL(value, normalizedDefaultApiBaseURL)
apiBaseURL.value = normalized
if (typeof window !== 'undefined') {
window.localStorage.setItem(apiBaseURLStorageKey, normalized)
}
void syncRuntimeSessionToMain()
}
function createPublicClient() {
return createApiClient({
baseURL: apiBaseURL.value,
timeoutMs: 15000,
})
}
function createAuthenticatedClient() {
return createApiClient({
baseURL: apiBaseURL.value,
timeoutMs: 15000,
auth: {
getAccessToken: () => session.value?.accessToken ?? null,
getRefreshToken: () => session.value?.refreshToken ?? null,
setTokens: (tokens: AuthTokens) => {
if (!session.value) {
return
}
persistSession({
...session.value,
accessToken: tokens.accessToken,
accessTokenExpiresAt: tokens.expiresAt ?? session.value.accessTokenExpiresAt,
refreshToken: tokens.refreshToken,
})
},
clearTokens: () => persistSession(null),
async refresh(refreshToken: string): Promise<AuthTokens> {
const next = await createPublicClient().post<RefreshResponse, { refresh_token: string }>(
'/api/auth/refresh',
{ refresh_token: refreshToken },
)
return {
accessToken: next.access_token,
refreshToken: next.refresh_token,
expiresAt: next.expires_at,
}
},
},
})
}
function createSessionBoundAuthenticatedClient(source: DesktopSession) {
let accessToken = source.accessToken
let refreshToken = source.refreshToken
return createApiClient({
baseURL: apiBaseURL.value,
timeoutMs: 15000,
auth: {
getAccessToken: () => accessToken,
getRefreshToken: () => refreshToken,
setTokens: (tokens: AuthTokens) => {
accessToken = tokens.accessToken
refreshToken = tokens.refreshToken
},
clearTokens: () => {
accessToken = null
refreshToken = null
},
async refresh(refreshTokenValue: string): Promise<AuthTokens> {
const next = await createPublicClient().post<RefreshResponse, { refresh_token: string }>(
'/api/auth/refresh',
{ refresh_token: refreshTokenValue },
)
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 }
}
return currentNavigator.userAgentData?.platform || currentNavigator.platform || 'unknown'
}
function resolveFallbackOS(platform: string): string {
const normalized = platform.toLowerCase()
if (normalized.includes('mac')) {
return 'darwin'
}
if (normalized.includes('win')) {
return 'windows'
}
if (normalized.includes('linux')) {
return 'linux'
}
return normalized || 'unknown'
}
function resolveFallbackDeviceName(platform: string): string {
const normalized = platform.toLowerCase()
if (normalized.includes('mac')) {
return 'Current Mac'
}
if (normalized.includes('win')) {
return 'Current Windows PC'
}
if (normalized.includes('linux')) {
return 'Current Linux Desktop'
}
return 'Current Desktop'
}
async function resolveDeviceInfo(): Promise<ResolvedDeviceInfo> {
try {
const info = await window.desktopBridge?.app?.deviceInfo?.()
if (info?.device_name && info.os && info.cpu_arch) {
return info
}
} catch (err) {
console.warn('[desktop-session] deviceInfo bridge failed', err)
}
const platform = resolveFallbackPlatform()
return {
device_name: resolveFallbackDeviceName(platform),
os: resolveFallbackOS(platform),
cpu_arch: 'unknown',
}
}
async function resolveDesktopClientID(user: UserInfo): Promise<string> {
const clientID = await window.desktopBridge?.app?.clientId?.({
tenant_id: user.tenant_id,
workspace_id: user.primary_workspace_id,
user_id: user.id,
})
if (!clientID) {
throw new Error('无法生成本机账号 client_id,请重启桌面客户端后重试')
}
return clientID
}
async function syncStoredDesktopClientDeviceInfo(): Promise<void> {
const current = session.value
if (
typeof window === 'undefined' ||
current?.mode !== 'authenticated' ||
!current.desktopClient ||
!current.user
) {
return
}
const [device, clientID] = await Promise.all([
resolveDeviceInfo(),
resolveDesktopClientID(current.user),
])
if (session.value !== current) {
return
}
const desktopClient = current.desktopClient
if (desktopClient.id !== clientID) {
try {
const registered = await registerDesktopClient(current.user, device)
const latest = session.value
if (latest?.mode !== 'authenticated' || latest.user?.id !== current.user?.id) {
return
}
persistSession({
...latest,
clientToken: registered.client_token,
clientTokenExpiresAt: registered.expires_at,
desktopClient: registered.client,
})
} catch (err) {
console.warn('[desktop-session] stable client migration failed', err)
}
return
}
if (
desktopClient.device_name === device.device_name &&
desktopClient.os === device.os &&
desktopClient.cpu_arch === device.cpu_arch
) {
return
}
persistSession({
...current,
desktopClient: {
...desktopClient,
device_name: device.device_name,
os: device.os,
cpu_arch: device.cpu_arch,
},
})
}
async function registerPayload(
user: UserInfo,
device?: ResolvedDeviceInfo,
): Promise<DesktopClientRegisterRequest> {
const resolvedDevice = device ?? (await resolveDeviceInfo())
return {
client_id: await resolveDesktopClientID(user),
device_name: resolvedDevice.device_name,
os: resolvedDevice.os,
cpu_arch: resolvedDevice.cpu_arch,
client_version: '0.1.0-dev',
channel: 'dev',
}
}
async function registerDesktopClient(
user: UserInfo,
device?: ResolvedDeviceInfo,
): Promise<DesktopClientRegisterResponse> {
const resolvedDevice = device ?? (await resolveDeviceInfo())
return createAuthenticatedClient().post<
DesktopClientRegisterResponse,
DesktopClientRegisterRequest
>('/api/desktop/clients/register', await registerPayload(user, resolvedDevice))
}
function createPreviewUser(): UserInfo {
return {
id: 0,
email: 'preview@geo-rankly.local',
phone: '',
name: '开发预览账号',
avatar_url: null,
tenant_id: 0,
primary_workspace_id: 0,
tenant_role: 'owner',
permissions: ['desktop.preview'],
membership: null,
kol_profile: null,
}
}
async function createPreviewClient(): Promise<DesktopClientInfo> {
const now = new Date().toISOString()
const device = await resolveDeviceInfo()
return {
id: 'preview-client',
tenant_id: 0,
workspace_id: 0,
user_id: 0,
device_name: `Preview ${device.device_name}`,
os: device.os,
cpu_arch: device.cpu_arch,
client_version: '0.1.0-preview',
channel: 'dev',
created_at: now,
last_seen_at: now,
last_rotated_at: now,
revoked_at: null,
}
}
function buildRuntimeSessionPayload(
next: DesktopSession | null,
): DesktopRuntimeSessionSyncRequest | null {
if (!next) {
return null
}
return {
api_base_url: apiBaseURL.value,
mode: next.mode,
client_token: next.clientToken,
desktop_client: next.desktopClient,
}
}
async function syncRuntimeSessionToMain(
next: DesktopSession | null = session.value,
): Promise<void> {
if (typeof window === 'undefined') {
return
}
if (!window.desktopBridge?.app?.syncRuntimeSession) {
return
}
await window.desktopBridge.app.syncRuntimeSession(buildRuntimeSessionPayload(next))
}
function formatLoginError(err: unknown): string {
if (err instanceof ApiClientError) {
const messages: Record<string, string> = {
invalid_credentials: '账号或密码错误',
login_rate_limited: '登录请求过于频繁',
login_locked: '登录已被临时保护',
login_in_progress: '登录请求正在处理中',
login_guard_unavailable: '登录保护暂时不可用',
}
if (err.message in messages) {
const message = messages[err.message]!
return err.message === 'login_locked' && err.detail ? `${message}${err.detail}` : message
}
if (err.status === 404) return '登录接口不存在,请确认服务器地址'
if (err.status && err.status >= 500) return '服务器内部错误,请稍后重试'
if (err.status && err.status >= 400) return '请求被服务器拒绝,请检查账号信息'
if (!err.status) return '网络连接失败,请检查服务器地址或网络'
return err.message.replaceAll('_', ' ')
}
if (err instanceof Error) {
const msg = err.message
if (msg.includes('Invalid URL') || msg.includes('Failed to construct')) {
return '服务器地址无效,请检查网关 API 地址设置'
}
}
return '登录失败,请稍后重试'
}
async function performLogin(payload: LoginRequest): Promise<void> {
pending.value = true
error.value = null
try {
const authData = await createPublicClient().post<LoginResponse, LoginRequest>(
'/api/auth/login',
payload,
)
persistSession({
mode: 'authenticated',
accessToken: authData.access_token,
accessTokenExpiresAt: authData.expires_at,
refreshToken: authData.refresh_token,
user: authData.user,
clientToken: null,
clientTokenExpiresAt: null,
desktopClient: null,
})
const registered = await registerDesktopClient(authData.user)
persistSession({
mode: 'authenticated',
accessToken: authData.access_token,
accessTokenExpiresAt: authData.expires_at,
refreshToken: authData.refresh_token,
user: authData.user,
clientToken: registered.client_token,
clientTokenExpiresAt: registered.expires_at,
desktopClient: registered.client,
})
} catch (err) {
error.value = formatLoginError(err)
persistSession(null)
throw err
} finally {
pending.value = false
}
}
async function login(payload: LoginRequest): Promise<void> {
if (loginPromise) {
return loginPromise
}
loginPromise = performLogin({ ...payload }).finally(() => {
loginPromise = null
})
return loginPromise
}
async function enterPreview(): Promise<void> {
error.value = null
persistSession({
mode: 'preview',
accessToken: null,
accessTokenExpiresAt: null,
refreshToken: null,
user: createPreviewUser(),
clientToken: 'preview-client-token',
clientTokenExpiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
desktopClient: await createPreviewClient(),
})
}
async function releaseSessionForLogout(current: DesktopSession | null): Promise<void> {
if (current?.mode === 'authenticated') {
try {
await window.desktopBridge?.app?.releaseRuntimeSession?.(true)
} catch (err) {
console.warn('[desktop-session] releaseRuntimeSession failed', err)
}
try {
await createSessionBoundAuthenticatedClient(current).post<null, Record<string, never>>(
'/api/auth/logout',
{},
)
} catch (err) {
console.warn('[desktop-session] auth logout failed', err)
}
}
}
async function transitionWindowForLogout(): Promise<void> {
try {
await window.desktopBridge?.app?.setWindowMode?.('login', { source: 'logout', animate: true })
} catch (err) {
console.warn('[desktop-session] logout window transition failed', err)
}
}
async function performLogout(): Promise<void> {
error.value = null
const current = session.value
const releasePromise = releaseSessionForLogout(current)
persistSession(null)
await transitionWindowForLogout()
void releasePromise.catch((err) => {
console.warn('[desktop-session] background logout release failed', err)
})
}
async function logout(): Promise<void> {
if (logoutPromise) {
return logoutPromise
}
logoutPromise = performLogout().finally(() => {
logoutPromise = null
})
return logoutPromise
}
if (typeof window !== 'undefined') {
window.addEventListener('storage', (event) => {
if (event.key === apiBaseURLStorageKey) {
apiBaseURL.value = normalizeDesktopApiBaseURL(event.newValue, normalizedDefaultApiBaseURL)
void syncRuntimeSessionToMain()
}
})
void syncStoredDesktopClientDeviceInfo()
ensureTokenRenewTimer()
void renewAuthenticatedSession()
}
export function useDesktopSession() {
return {
session: readonly(session),
apiBaseURL: readonly(apiBaseURL),
pending: readonly(pending),
error: readonly(error),
isAuthenticated: computed(() => Boolean(session.value?.clientToken)),
isPreviewMode: computed(() => session.value?.mode === 'preview'),
setApiBaseURL: persistApiBaseURL,
syncRuntimeSession: syncRuntimeSessionToMain,
renewSession: renewAuthenticatedSession,
login,
logout,
enterPreview,
}
}