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(readStoredSession()) const apiBaseURL = shallowRef(readStoredApiBaseURL()) const pending = shallowRef(false) const error = shallowRef(null) let loginPromise: Promise | null = null let logoutPromise: Promise | null = null let renewPromise: Promise | null = null let tokenRenewTimer: ReturnType | 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 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 { const next = await createPublicClient().post( '/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 { const next = await createPublicClient().post( '/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 { if (!current.refreshToken) { throw new Error('missing_refresh_token') } const next = await createPublicClient().post( '/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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 = { 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 { pending.value = true error.value = null try { const authData = await createPublicClient().post( '/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 { if (loginPromise) { return loginPromise } loginPromise = performLogin({ ...payload }).finally(() => { loginPromise = null }) return loginPromise } async function enterPreview(): Promise { 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 { 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>( '/api/auth/logout', {}, ) } catch (err) { console.warn('[desktop-session] auth logout failed', err) } } } async function transitionWindowForLogout(): Promise { 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 { 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 { 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, } }