import { createHash } from 'node:crypto' import { appendFileSync, mkdirSync } from 'node:fs' import { dirname, join } from 'node:path' import { ApiClientError, createApiClient, type ApiClient } from '@geo/http-client' import type { ApiEnvelope, ApiErrorEnvelope, CancelDesktopTaskRequest, CompleteDesktopTaskRequest, CreatePublishJobResponse, DesktopAccountInfo, DesktopArticleContent, DesktopClientHeartbeatRequest, DesktopClientHeartbeatResponse, DesktopClientReleaseCheckResponse, DesktopClientRotateResponse, DesktopPublishTaskListResponse, DesktopRuntimeSessionSyncRequest, DesktopTaskInfo, ExtendDesktopTaskRequest, LeaseDesktopTaskRequest, LeaseDesktopTaskResponse, ListDesktopPublishTasksParams, MonitoringLeaseTasksPayload, MonitoringLeaseTasksResponse, MonitoringResumeTasksPayload, MonitoringResumeTasksResponse, MonitoringSkipTaskPayload, MonitoringSkipTaskResponse, MonitoringTaskResultPayload, MonitoringTaskResultResponse, ReportDesktopAccountHealthRequest, ReportDesktopAccountHealthResponse, UpsertDesktopAccountRequest, } from '@geo/shared-types' import { app } from 'electron/main' import { normalizeDesktopApiBaseURL } from '../../shared/api-base-url' import { failObservedRequest, resolveObservedRequest, startObservedRequest, } from '../network-observer' import { canUseRendererDevtoolsProxy, rendererDevtoolsFetch } from '../renderer-devtools-proxy' type TransportAuthState = 'pending' | 'registered' | 'expired' type TransportDispatchState = 'idle' | 'connecting' | 'streaming' type ObservedAxiosConfig = { method?: string url?: string baseURL?: string headers?: Record __observedRequestId?: string } interface DesktopTransportSession { baseURL: string clientToken: string | null } interface MonitoringCancelTaskPayload { lease_token: string reason?: string | null } interface MonitoringCancelTaskResponse { task_id: number task_status: string } let desktopApiClient: ApiClient | null = null let transportSession: DesktopTransportSession = { baseURL: normalizeDesktopApiBaseURL(process.env.DESKTOP_API_BASE_URL), clientToken: null, } const ACCOUNT_UPSERT_CACHE_TTL_MS = 5 * 60_000 const ACCOUNT_UPSERT_DIAGNOSTIC_FILE = 'desktop-account-upsert-errors.jsonl' interface AccountUpsertCacheEntry { signature: string expiresAt: number response: DesktopAccountInfo inFlight: Promise | null } const accountUpsertCache = new Map() const transportState = { baseURL: transportSession.baseURL, initializedAt: 0, mode: 'rabbitmq-first' as const, requestDebugMode: 'main-process' as 'main-process' | 'renderer-devtools', auth: 'pending' as TransportAuthState, dispatchState: 'idle' as TransportDispatchState, lastHeartbeatAt: 0, lastHeartbeatStatus: 'idle' as 'idle' | 'success' | 'failed', lastPullAt: 0, lastPullStatus: 'idle' as 'idle' | 'success' | 'failed', } function createDesktopClient(baseURL: string): ApiClient { const client = createApiClient({ baseURL, timeoutMs: 15000, }) client.raw.interceptors.request.use((config) => { if (transportSession.clientToken) { config.headers = config.headers ?? {} ;(config.headers as Record).Authorization = `Bearer ${transportSession.clientToken}` } const observedConfig = config as ObservedAxiosConfig observedConfig.__observedRequestId = startObservedRequest({ source: 'transport', label: 'desktop.api', method: config.method, url: resolveObservedRequestURL(config.url, config.baseURL ?? baseURL), }) return config }) client.raw.interceptors.response.use( (response) => { const observedConfig = response.config as ObservedAxiosConfig if (observedConfig.__observedRequestId) { resolveObservedRequest(observedConfig.__observedRequestId, { phase: 'response', status: response.status, }) } return response }, (error: unknown) => { const responseError = error as { config?: ObservedAxiosConfig response?: { status?: number } } const requestId = responseError.config?.__observedRequestId if (requestId) { if (typeof responseError.response?.status === 'number') { resolveObservedRequest(requestId, { phase: 'response', status: responseError.response.status, }) } else { failObservedRequest(requestId, error) } } return Promise.reject(error) }, ) return client } function stableJson(value: unknown): string { if (Array.isArray(value)) { return `[${value.map((item) => stableJson(item)).join(',')}]` } if (value && typeof value === 'object') { return `{${Object.entries(value as Record) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`) .join(',')}}` } return JSON.stringify(value) } function accountUpsertCacheKey(payload: UpsertDesktopAccountRequest): string { return [payload.platform.trim(), payload.platform_uid.trim()].join('::') } function accountUpsertSignature(payload: UpsertDesktopAccountRequest): string { return stableJson({ platform: payload.platform.trim(), platform_uid: payload.platform_uid.trim(), account_fingerprint: payload.account_fingerprint ?? null, display_name: payload.display_name.trim(), avatar_url: payload.avatar_url ?? null, health: payload.health, verified_at: payload.verified_at ?? null, tags: payload.tags ?? [], if_sync_version: payload.if_sync_version ?? null, }) } function clearAccountUpsertCache(): void { accountUpsertCache.clear() } function diagnosticHash(value: string | null | undefined): string | null { const normalized = value?.trim() if (!normalized) { return null } return createHash('sha256').update(normalized).digest('hex').slice(0, 16) } function normalizeErrorForDiagnostics(error: unknown): Record { if (error instanceof ApiClientError) { return { name: error.name, message: error.message, code: error.code, status: error.status ?? null, detail: error.detail ?? null, requestId: error.requestId ?? null, handled: error.handled === true, } } if (error instanceof Error) { return { name: error.name, message: error.message, } } return { name: typeof error, message: String(error), } } function appendAccountUpsertDiagnostic(payload: UpsertDesktopAccountRequest, error: unknown): void { const target = join(app.getPath('userData'), ACCOUNT_UPSERT_DIAGNOSTIC_FILE) const event = { at: new Date().toISOString(), action: 'desktop_account_upsert_failed', transport: { baseURL: transportState.baseURL, auth: transportState.auth, requestDebugMode: transportState.requestDebugMode, hasClient: desktopApiClient !== null, hasClientToken: Boolean(transportSession.clientToken), }, request: { platform: payload.platform, platformUidHash: diagnosticHash(payload.platform_uid), accountFingerprintHash: diagnosticHash(payload.account_fingerprint ?? null), displayName: payload.display_name, health: payload.health, verifiedAt: payload.verified_at ?? null, tagCount: payload.tags?.length ?? 0, hasIfSyncVersion: typeof payload.if_sync_version === 'number', }, error: normalizeErrorForDiagnostics(error), } try { mkdirSync(dirname(target), { recursive: true }) appendFileSync(target, `${JSON.stringify(event)}\n`, 'utf8') } catch { // Best-effort diagnostics. Never block account binding on local log writes. } } function currentRequestDebugMode(): 'main-process' | 'renderer-devtools' { return canUseRendererDevtoolsProxy() ? 'renderer-devtools' : 'main-process' } function resolveDesktopURL(path: string): string { return new URL(path, transportSession.baseURL).toString() } export function resolveDesktopApiURL(path: string): string { return resolveDesktopURL(path) } export async function fetchDesktopApiURL( input: string | URL, init: RequestInit = {}, ): Promise { const headers = new Headers(init.headers) if (transportSession.clientToken && !headers.has('Authorization')) { headers.set('Authorization', `Bearer ${transportSession.clientToken}`) } const url = input instanceof URL ? input.toString() : /^https?:\/\//i.test(input) ? input : resolveDesktopURL(input) return fetch(url, { ...init, headers, }) } function resolveObservedRequestURL(path: string | undefined, baseURL: string): string { if (!path) { return baseURL } try { return new URL(path, baseURL).toString() } catch { return path } } function shouldFallbackToMainProcess(error: unknown): boolean { if (error instanceof ApiClientError) { if (typeof error.status === 'number' && error.status > 0) { return false } return /Failed to fetch|renderer_devtools_proxy_/i.test(error.message) } if (error instanceof Error) { return /Failed to fetch|renderer_devtools_proxy_/i.test(error.message) } return false } async function proxyDesktopRequest( method: 'GET' | 'POST' | 'DELETE', path: string, body?: B, ): Promise { const headers: Record = { Accept: 'application/json', } let serializedBody: string | undefined if (body !== undefined) { headers['Content-Type'] = 'application/json' serializedBody = JSON.stringify(body) } if (transportSession.clientToken) { headers.Authorization = `Bearer ${transportSession.clientToken}` } const response = await rendererDevtoolsFetch({ url: resolveDesktopURL(path), method, headers, body: serializedBody, }) if (response.error) { throw new ApiClientError({ message: response.error }) } let payload: ApiEnvelope | Partial | null = null if (response.bodyText) { try { payload = JSON.parse(response.bodyText) as ApiEnvelope | Partial } catch { payload = null } } if (!response.ok) { const errorPayload = payload as Partial | null throw new ApiClientError({ message: errorPayload?.message ?? `desktop_http_${response.status}`, code: errorPayload?.code ?? 50000, status: response.status, detail: errorPayload?.detail, requestId: errorPayload?.request_id, }) } if (!payload || typeof payload !== 'object' || !('data' in payload)) { throw new ApiClientError({ message: 'desktop_invalid_api_envelope', status: response.status, }) } return payload.data as T } async function desktopGet(path: string): Promise { transportState.requestDebugMode = currentRequestDebugMode() if (transportState.requestDebugMode === 'renderer-devtools') { try { return await proxyDesktopRequest('GET', path) } catch (error) { if (!shouldFallbackToMainProcess(error)) { throw error } transportState.requestDebugMode = 'main-process' } } return getDesktopApiClient().get(path) } async function desktopPost(path: string, body?: B): Promise { transportState.requestDebugMode = currentRequestDebugMode() if (transportState.requestDebugMode === 'renderer-devtools') { try { return await proxyDesktopRequest('POST', path, body) } catch (error) { if (!shouldFallbackToMainProcess(error)) { throw error } transportState.requestDebugMode = 'main-process' } } return getDesktopApiClient().post(path, body) } async function desktopDelete(path: string): Promise { transportState.requestDebugMode = currentRequestDebugMode() if (transportState.requestDebugMode === 'renderer-devtools') { try { return await proxyDesktopRequest('DELETE', path) } catch (error) { if (!shouldFallbackToMainProcess(error)) { throw error } transportState.requestDebugMode = 'main-process' } } return getDesktopApiClient().remove(path) } export function initTransport(): ApiClient { if (desktopApiClient) { return desktopApiClient } transportState.initializedAt = transportState.initializedAt || Date.now() transportState.baseURL = transportSession.baseURL transportState.requestDebugMode = currentRequestDebugMode() desktopApiClient = createDesktopClient(transportSession.baseURL) return desktopApiClient } export function configureTransport( session: DesktopRuntimeSessionSyncRequest | null, ): ApiClient | null { if (!session) { transportSession = { baseURL: normalizeDesktopApiBaseURL(process.env.DESKTOP_API_BASE_URL), clientToken: null, } transportState.baseURL = transportSession.baseURL transportState.requestDebugMode = currentRequestDebugMode() transportState.auth = 'pending' transportState.dispatchState = 'idle' desktopApiClient = null clearAccountUpsertCache() return null } transportSession = { baseURL: normalizeDesktopApiBaseURL(session.api_base_url), clientToken: session.client_token, } transportState.baseURL = transportSession.baseURL transportState.requestDebugMode = currentRequestDebugMode() transportState.auth = session.client_token ? 'registered' : 'pending' transportState.dispatchState = session.client_token ? 'connecting' : 'idle' transportState.initializedAt = Date.now() desktopApiClient = createDesktopClient(transportSession.baseURL) clearAccountUpsertCache() return desktopApiClient } export function setTransportAuthState(next: TransportAuthState): void { transportState.auth = next } export function setTransportDispatchState(next: TransportDispatchState): void { transportState.dispatchState = next } export function noteTransportHeartbeat(success: boolean): void { transportState.lastHeartbeatAt = Date.now() transportState.lastHeartbeatStatus = success ? 'success' : 'failed' } export function noteTransportPull(success: boolean): void { transportState.lastPullAt = Date.now() transportState.lastPullStatus = success ? 'success' : 'failed' } export function getDesktopApiClient(): ApiClient { return desktopApiClient ?? initTransport() } export function getTransportSnapshot() { return { ...transportState, hasClient: desktopApiClient !== null, hasClientToken: Boolean(transportSession.clientToken), } } export async function heartbeatDesktopClient( payload: DesktopClientHeartbeatRequest, ): Promise { return desktopPost( '/api/desktop/clients/heartbeat', payload, ) } export async function rotateDesktopClient(): Promise { return desktopPost>( '/api/desktop/clients/rotate', {}, ) } export async function checkDesktopClientRelease(params: { platform: string arch: string version: string channel?: string }): Promise { const search = new URLSearchParams({ platform: params.platform, arch: params.arch, version: params.version, }) if (params.channel?.trim()) { search.set('channel', params.channel.trim()) } return desktopGet( `/api/desktop/clients/releases/latest?${search.toString()}`, ) } export async function resolveDesktopClientReleaseDownloadURL(params: { platform: string arch: string version: string channel?: string }): Promise<{ download_url: string; expires_in?: number | null }> { const search = new URLSearchParams({ platform: params.platform, arch: params.arch, version: params.version, }) if (params.channel?.trim()) { search.set('channel', params.channel.trim()) } return desktopGet<{ download_url: string; expires_in?: number | null }>( `/api/desktop/clients/releases/download-url?${search.toString()}`, ) } export async function offlineDesktopClient(): Promise { await desktopPost<{ server_time: string }, Record>( '/api/desktop/clients/offline', {}, ) } export async function revokeDesktopClient(): Promise { await desktopPost<{ id: string }, Record>('/api/desktop/clients/revoke', {}) } export async function listDesktopAccounts(): Promise { return desktopGet('/api/desktop/accounts') } export async function getDesktopArticleContent(articleId: number): Promise { return desktopGet(`/api/desktop/content/articles/${articleId}`) } export async function listDesktopPublishTasks( params: ListDesktopPublishTasksParams = {}, ): Promise { const search = new URLSearchParams() if (params.page) { search.set('page', String(params.page)) } if (params.page_size) { search.set('page_size', String(params.page_size)) } if (params.title?.trim()) { search.set('title', params.title.trim()) } const query = search.toString() const suffix = query ? `?${query}` : '' return desktopGet(`/api/desktop/publish-tasks${suffix}`) } export async function retryDesktopPublishTask(taskId: string): Promise { return desktopPost>( `/api/desktop/publish-tasks/${encodeURIComponent(taskId)}/retry`, {}, ) } export async function upsertDesktopAccount( payload: UpsertDesktopAccountRequest, ): Promise { const now = Date.now() const cacheKey = accountUpsertCacheKey(payload) const signature = accountUpsertSignature(payload) const cached = accountUpsertCache.get(cacheKey) if (cached && cached.signature === signature && cached.expiresAt > now) { if (cached.inFlight) { return cached.inFlight } return cached.response } const request = desktopPost( '/api/desktop/accounts', payload, ) accountUpsertCache.set(cacheKey, { signature, expiresAt: now + ACCOUNT_UPSERT_CACHE_TTL_MS, response: cached?.response ?? ({} as DesktopAccountInfo), inFlight: request, }) try { const response = await request accountUpsertCache.set(cacheKey, { signature, expiresAt: Date.now() + ACCOUNT_UPSERT_CACHE_TTL_MS, response, inFlight: null, }) return response } catch (error) { if (accountUpsertCache.get(cacheKey)?.inFlight === request) { accountUpsertCache.delete(cacheKey) } appendAccountUpsertDiagnostic(payload, error) throw error } } export async function reportDesktopAccountHealth( payload: ReportDesktopAccountHealthRequest, ): Promise { return desktopPost( '/api/desktop/accounts/health-reports', payload, ) } export async function deleteDesktopAccount( desktopId: string, ifSyncVersion: number, ): Promise { const params = new URLSearchParams({ if_sync_version: String(ifSyncVersion) }) const response = await desktopDelete( `/api/desktop/accounts/${encodeURIComponent(desktopId)}?${params.toString()}`, ) clearAccountUpsertCache() return response } export async function leaseDesktopTask( request: LeaseDesktopTaskRequest, ): Promise { const taskId = request.task_id?.trim() const path = taskId ? `/api/desktop/tasks/${taskId}/lease` : '/api/desktop/tasks/lease' return desktopPost( path, taskId ? { kind: request.kind } : request, ) } export async function extendDesktopTask( taskId: string, payload: ExtendDesktopTaskRequest, ): Promise { return desktopPost( `/api/desktop/tasks/${taskId}/extend`, payload, ) } export async function completeDesktopTask( taskId: string, payload: CompleteDesktopTaskRequest, ): Promise { return desktopPost( `/api/desktop/tasks/${taskId}/result`, payload, ) } export async function markDesktopTaskPublishSubmitStarted( taskId: string, leaseToken: string, ): Promise { await desktopPost<{ ok: boolean }, { lease_token: string }>( `/api/desktop/tasks/${taskId}/publish-submit-marker`, { lease_token: leaseToken }, ) } export async function cancelDesktopTask( taskId: string, payload: CancelDesktopTaskRequest, ): Promise { return desktopPost( `/api/desktop/tasks/${taskId}/cancel`, payload, ) } export async function leaseMonitoringTasks( payload: MonitoringLeaseTasksPayload = {}, ): Promise { return desktopPost( '/api/desktop/monitoring/tasks/lease', payload, ) } export async function resumeMonitoringTasks( payload: MonitoringResumeTasksPayload = {}, ): Promise { return desktopPost( '/api/desktop/monitoring/tasks/resume', payload, ) } export async function submitMonitoringTaskResult( taskId: number, payload: MonitoringTaskResultPayload, ): Promise { return desktopPost( `/api/desktop/monitoring/tasks/${taskId}/result`, payload, ) } export async function skipMonitoringTask( taskId: number, payload: MonitoringSkipTaskPayload, ): Promise { return desktopPost( `/api/desktop/monitoring/tasks/${taskId}/skip`, payload, ) } export async function cancelMonitoringTask( taskId: number, payload: MonitoringCancelTaskPayload, ): Promise { return desktopPost( `/api/desktop/monitoring/tasks/${taskId}/cancel`, payload, ) }