diff --git a/apps/desktop/main/api/routes/import.ts b/apps/desktop/main/api/routes/import.ts index d82566ca..7b4057a8 100644 --- a/apps/desktop/main/api/routes/import.ts +++ b/apps/desktop/main/api/routes/import.ts @@ -373,9 +373,7 @@ async function handleLegacyImport(request: FastifyRequest, reply: FastifyReply, if (sessionId) { const session = await worker.getSession(sessionId) if (!session) { - // Legacy route requires session to exist - const { sessionNotFound } = await import('../errors') - const err = sessionNotFound(sessionId) + const err = new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `Session not found: ${sessionId}`) reply.code(err.statusCode).send(errorResponse(err)) return } diff --git a/apps/desktop/main/index.ts b/apps/desktop/main/index.ts index 7dfd4932..e6977b0c 100644 --- a/apps/desktop/main/index.ts +++ b/apps/desktop/main/index.ts @@ -3,6 +3,8 @@ import { join } from 'path' import { optimizer, is, platform } from '@electron-toolkit/utils' import { checkUpdate } from './update' import mainIpcMain, { cleanup } from './ipcMain' +import { startInternalServer, stopInternalServer, registerInternalApiIpc } from './internal-api' +import { getPathProvider } from './path-context' import { initAnalytics, trackDailyActive } from './analytics' import { initProxy } from './network/proxy' import { @@ -270,6 +272,16 @@ class MainProcess { // 记录日活(用于统计操作系统版本、客户端版本,便于更好的适配客户端) trackDailyActive() + // 启动 Internal API Server(在 createWindow 之前,确保 Renderer 加载时 endpoint 可用) + try { + await startInternalServer(getPathProvider()) + registerInternalApiIpc() + console.log('[Main] Internal API Server ready') + } catch (err) { + console.error('[Main] Internal API Server failed to start (falling back to IPC):', err) + registerInternalApiIpc() + } + // 创建主窗口 console.log('[Main] Creating window...') await this.createWindow() @@ -329,6 +341,7 @@ class MainProcess { // 退出前清理资源 app.on('will-quit', () => { + stopInternalServer().catch(() => {}) cleanup() }) }) diff --git a/apps/desktop/main/internal-api.ts b/apps/desktop/main/internal-api.ts new file mode 100644 index 00000000..cae282a4 --- /dev/null +++ b/apps/desktop/main/internal-api.ts @@ -0,0 +1,187 @@ +/** + * Electron Internal API Server + * + * Provides HTTP-based business communication for the Renderer process, + * reusing @openchatlab/http-routes shared routes with ephemeral auth. + * + * Completely isolated from the user-facing External API Server + * (apps/desktop/main/api/). Different port, different token, different lifecycle. + */ + +import { randomBytes, createHmac, timingSafeEqual } from 'crypto' +import { ipcMain } from 'electron' +import Fastify, { type FastifyInstance, type FastifyError, type FastifyRequest, type FastifyReply } from 'fastify' +import type { PathProvider } from '@openchatlab/core' +import { DatabaseManager, createDatabaseManagerAdapter } from '@openchatlab/node-runtime' +import { registerSharedRoutes, ApiError, ApiErrorCode, errorResponse, serverError } from '@openchatlab/http-routes' +import type { HttpRouteContext } from '@openchatlab/http-routes' + +export interface InternalEndpoint { + baseUrl: string + token: string +} + +let server: FastifyInstance | null = null +let endpoint: InternalEndpoint | null = null +let dbManager: DatabaseManager | null = null + +const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50 MB + +/** + * Start the Internal API Server. + * Must be called before createWindow() so the Renderer can retrieve the endpoint. + */ +export async function startInternalServer(pathProvider: PathProvider): Promise { + if (server) return endpoint! + + let newServer: FastifyInstance | null = null + let newDbManager: DatabaseManager | null = null + + try { + const token = `int_${randomBytes(32).toString('hex')}` + + newDbManager = new DatabaseManager(pathProvider) + const sessionAdapter = createDatabaseManagerAdapter(newDbManager) + + const { app } = await import('electron') + + const ctx: HttpRouteContext = { + dbManager: newDbManager, + sessionAdapter, + pathProvider, + getVersion: () => app.getVersion(), + } + + newServer = Fastify({ logger: false, bodyLimit: JSON_BODY_LIMIT }) + + // CORS: dev allows the Vite dev server origin; prod allows Electron app origins only + const isDev = !app.isPackaged + const devOrigin = process.env.ELECTRON_RENDERER_URL || 'http://localhost:13100' + newServer.addHook('onRequest', (request, reply, done) => { + const origin = request.headers.origin + if (!origin) { + done() + return + } + + if (isDev) { + if (origin === devOrigin || origin.startsWith('http://localhost:')) { + reply.header('Access-Control-Allow-Origin', origin) + } + } else if (origin === 'file://' || origin === 'app://' || origin === 'null') { + reply.header('Access-Control-Allow-Origin', origin) + } + + reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') + reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization') + + if (request.method === 'OPTIONS') { + reply.code(204).send() + return + } + done() + }) + + newServer.addHook('onRequest', createInternalAuthHook(token)) + + newServer.setErrorHandler((error: FastifyError, _request, reply) => { + if (error instanceof ApiError) { + reply.code(error.statusCode).send(errorResponse(error)) + return + } + if (error.statusCode === 413) { + const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit') + reply.code(413).send(errorResponse(bodyErr)) + return + } + const statusCode = (error as any).statusCode + if (statusCode && statusCode >= 400 && statusCode < 600) { + reply.code(statusCode).send({ success: false, error: { code: 'CLIENT_ERROR', message: error.message } }) + return + } + const err = serverError(error.message) + reply.code(err.statusCode).send(errorResponse(err)) + }) + + registerSharedRoutes(newServer, ctx) + + await newServer.listen({ port: 0, host: '127.0.0.1' }) + + const address = newServer.server.address() + const port = typeof address === 'object' && address ? address.port : 0 + + server = newServer + dbManager = newDbManager + endpoint = { baseUrl: `http://127.0.0.1:${port}`, token } + console.log(`[InternalAPI] Server started on port ${port}`) + + return endpoint + } catch (err) { + // Cleanup partial initialization to avoid leaked resources + try { await newServer?.close() } catch { /* best-effort */ } + try { newDbManager?.closeAll() } catch { /* best-effort */ } + server = null + dbManager = null + endpoint = null + throw err + } +} + +export function getInternalEndpoint(): InternalEndpoint | null { + return endpoint +} + +export async function stopInternalServer(): Promise { + if (!server) return + try { + await server.close() + } catch (err) { + console.error('[InternalAPI] Error closing server:', err) + } finally { + try { dbManager?.closeAll() } catch { /* best-effort */ } + server = null + endpoint = null + dbManager = null + console.log('[InternalAPI] Server stopped') + } +} + +/** + * Register IPC handler so the Renderer can retrieve the endpoint via preload. + * Must be called before createWindow(). + */ +export function registerInternalApiIpc(): void { + ipcMain.handle('internal-api:getEndpoint', () => getInternalEndpoint()) +} + +// ==================== Auth Hook (Internal Only) ==================== + +const hmacKey = randomBytes(32) + +function safeTokenCompare(a: string, b: string): boolean { + const hashA = createHmac('sha256', hmacKey).update(a).digest() + const hashB = createHmac('sha256', hmacKey).update(b).digest() + return timingSafeEqual(hashA, hashB) +} + +/** + * Auth hook for the Internal Server. ALL routes require Bearer token, no exceptions. + * Independent from @openchatlab/http-routes auth (which uses global state for External Server). + */ +function createInternalAuthHook(token: string) { + return async function internalAuthHook(request: FastifyRequest, reply: FastifyReply): Promise { + if (request.method === 'OPTIONS') return + + const authHeader = request.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + reply.code(401).send({ success: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } }) + return + } + + const provided = authHeader.slice(7) + if (!safeTokenCompare(provided, token)) { + reply.code(401).send({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid token' } }) + return + } + } +} diff --git a/apps/desktop/preload/apis/internal-api.ts b/apps/desktop/preload/apis/internal-api.ts new file mode 100644 index 00000000..b1719f63 --- /dev/null +++ b/apps/desktop/preload/apis/internal-api.ts @@ -0,0 +1,17 @@ +/** + * Internal API Server endpoint — Preload API + * + * Exposes the Internal Server's baseUrl and ephemeral token to the Renderer. + * Token is held in memory only (never persisted to localStorage/sessionStorage). + */ + +import { ipcRenderer } from 'electron' + +export interface InternalEndpoint { + baseUrl: string + token: string +} + +export const internalApi = { + getEndpoint: (): Promise => ipcRenderer.invoke('internal-api:getEndpoint'), +} diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index ae3476aa..83d9dc84 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -1206,9 +1206,19 @@ declare global { nlpApi: NlpApi apiServerApi: ApiServerApi preferencesApi: PreferencesApi + internalApi: InternalApi } } +interface InternalEndpoint { + baseUrl: string + token: string +} + +interface InternalApi { + getEndpoint: () => Promise +} + export { ChatApi, Api, @@ -1269,4 +1279,6 @@ export { DataSource, ImportSession, PreferencesApi, + InternalApi, + InternalEndpoint, } diff --git a/apps/desktop/preload/index.ts b/apps/desktop/preload/index.ts index 409bbfa8..154aaf51 100644 --- a/apps/desktop/preload/index.ts +++ b/apps/desktop/preload/index.ts @@ -12,6 +12,7 @@ import { aiApi, llmApi, agentApi, assistantApi, skillApi } from './apis/ai' import { nlpApi, networkApi, cacheApi, sessionApi } from './apis/utils' import { apiServerApi } from './apis/api-server' import { preferencesApi } from './apis/preferences' +import { internalApi } from './apis/internal-api' // 为渲染进程提供统一的类型入口,避免 type-only import 指向无导出的运行时代码。 export type { PreprocessConfig } from './apis/ai' @@ -48,6 +49,7 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('nlpApi', nlpApi) contextBridge.exposeInMainWorld('apiServerApi', apiServerApi) contextBridge.exposeInMainWorld('preferencesApi', preferencesApi) + contextBridge.exposeInMainWorld('internalApi', internalApi) } catch (error) { console.error(error) } @@ -82,4 +84,6 @@ if (process.contextIsolated) { window.apiServerApi = apiServerApi // @ts-ignore (define in dts) window.preferencesApi = preferencesApi + // @ts-ignore (define in dts) + window.internalApi = internalApi } diff --git a/apps/desktop/tsconfig.node.json b/apps/desktop/tsconfig.node.json index 5756edbd..666628ce 100644 --- a/apps/desktop/tsconfig.node.json +++ b/apps/desktop/tsconfig.node.json @@ -14,7 +14,8 @@ "../../packages/mcp-server/**/*", "../../apps/cli/**/*", "../../packages/tools/**/*", - "../../packages/sync/**/*" + "../../packages/sync/**/*", + "../../packages/http-routes/**/*" ], "compilerOptions": { "composite": true, diff --git a/src/App.vue b/src/App.vue index 232ed78e..34d1d5af 100644 --- a/src/App.vue +++ b/src/App.vue @@ -88,7 +88,14 @@ onMounted(async () => { document.documentElement.classList.add('platform-linux') } - if (!IS_ELECTRON) { + if (IS_ELECTRON) { + // Electron: get Internal API Server endpoint from preload + const ep = await window.internalApi?.getEndpoint() + if (ep) { + configureHttpClient({ baseUrl: `${ep.baseUrl}/_web`, token: ep.token }) + } + } else { + // CLI Web: use relative paths + dynamic token from auth store let redirectingTo401 = false const on401 = () => { if (redirectingTo401 || router.currentRoute.value.name === 'login') return diff --git a/src/services/registry.ts b/src/services/registry.ts index 914d4523..78ff2a8d 100644 --- a/src/services/registry.ts +++ b/src/services/registry.ts @@ -60,7 +60,66 @@ export async function initServices(): Promise { _initialized = true } +/** + * Check if the Internal HTTP Server is available. + * When available, business adapters use Fetch instead of IPC. + */ +function isInternalHttpAvailable(): boolean { + try { + return typeof window !== 'undefined' && typeof (window as any).internalApi?.getEndpoint === 'function' + } catch { + return false + } +} + async function initElectronAdapters(): Promise { + // Check if Internal API Server endpoint was configured in App.vue + const { getBaseUrl } = await import('./utils/http') + const useHttp = isInternalHttpAvailable() && getBaseUrl().startsWith('http') + + if (useHttp) { + await initElectronHttpAdapters() + } else { + await initElectronIpcAdapters() + } +} + +/** + * HTTP mode: business adapters use Fetch (via Internal API Server). + * Only platform + import + ai remain on IPC (routes not yet available). + */ +async function initElectronHttpAdapters(): Promise { + const { FetchDataAdapter } = await import('./data/fetch') + registerAdapter('data', new FetchDataAdapter()) + + // Import stays on IPC: routes depend on CLI-specific modules + const { ElectronImportAdapter } = await import('./import/electron') + registerAdapter('import', new ElectronImportAdapter()) + + // Session-index stays on IPC: summaries routes (generateSummary, checkCanGenerateSummary) + // depend on LLM config and are not registered in Internal Server's registerSharedRoutes() + const { ElectronSessionIndexAdapter } = await import('./session-index/electron') + registerAdapter('session-index', new ElectronSessionIndexAdapter()) + + const { FetchMessageAdapter } = await import('./message/fetch') + registerAdapter('message', new FetchMessageAdapter()) + + // Platform always uses IPC (native capabilities) + const { ElectronPlatformAdapter } = await import('./platform/electron') + registerAdapter('platform', new ElectronPlatformAdapter()) + + // AI stays on IPC: routes depend on CLI-specific modules + const { ElectronAIAdapter } = await import('./ai/electron') + registerAdapter('ai', new ElectronAIAdapter()) + + const { FetchPreferencesAdapter } = await import('./preferences/fetch') + registerAdapter('preferences', new FetchPreferencesAdapter()) +} + +/** + * IPC fallback mode: all adapters use Electron IPC (original behavior). + */ +async function initElectronIpcAdapters(): Promise { const { ElectronDataAdapter } = await import('./data/electron') registerAdapter('data', new ElectronDataAdapter()) diff --git a/src/services/utils/http.ts b/src/services/utils/http.ts index 04eb1c3f..18048eb3 100644 --- a/src/services/utils/http.ts +++ b/src/services/utils/http.ts @@ -36,9 +36,27 @@ export function getBaseUrl(): string { return _baseUrl } +/** + * Resolve a URL path against the configured base. + * When baseUrl is absolute (http://...), relative paths like "/_web/..." + * are rewritten to the Internal Server origin. Otherwise returns as-is. + */ +function resolveFullUrl(url: string): string { + if (!_baseUrl.startsWith('http://') && !_baseUrl.startsWith('https://')) return url + if (url.startsWith('http://') || url.startsWith('https://')) return url + try { + const origin = new URL(_baseUrl).origin + return `${origin}${url}` + } catch { + return url + } +} + /** * Authenticated fetch wrapper — same API as native fetch, * but auto-injects Authorization header and handles 401. + * Paths starting with / are resolved via resolveFullUrl so that + * Electron Internal Server mode works with absolute base URLs. */ export async function fetchWithAuth(url: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers) @@ -46,7 +64,8 @@ export async function fetchWithAuth(url: string, init?: RequestInit): Promise