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

553 lines
14 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,
DesktopRuntimeSessionSyncRequest,
LoginRequest,
LoginResponse,
RefreshResponse,
UserInfo,
} from '@geo/shared-types'
type SessionMode = 'authenticated' | 'preview'
interface DesktopSession {
mode: SessionMode
accessToken: string | 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'
export const DEFAULT_API_BASE_URL =
import.meta.env.VITE_DESKTOP_API_BASE_URL ?? 'https://api.shengxintui.com'
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
function readStoredSession(): DesktopSession | null {
if (typeof window === 'undefined') {
return null
}
try {
const raw = window.localStorage.getItem(sessionStorageKey)
if (!raw) {
return null
}
return JSON.parse(raw) as DesktopSession
} catch {
return 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 DEFAULT_API_BASE_URL
}
return window.localStorage.getItem(apiBaseURLStorageKey) ?? DEFAULT_API_BASE_URL
}
function persistApiBaseURL(value: string): void {
const trimmed = value.trim() || DEFAULT_API_BASE_URL
apiBaseURL.value = trimmed
if (typeof window !== 'undefined') {
window.localStorage.setItem(apiBaseURLStorageKey, trimmed)
}
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,
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,
}
},
},
})
}
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,
}
},
},
})
}
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,
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,
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,
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 = event.newValue?.trim() || DEFAULT_API_BASE_URL
void syncRuntimeSessionToMain()
}
})
void syncStoredDesktopClientDeviceInfo()
}
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,
login,
logout,
enterPreview,
}
}