Files
geo/apps/desktop-client/src/renderer/composables/useDesktopSession.ts
T

740 lines
20 KiB
TypeScript
Raw Normal View History

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,
}
}