diff --git a/main/src/app-events/block-quit.ts b/main/src/app-events/block-quit.ts index a046701cb..1f6beaff1 100644 --- a/main/src/app-events/block-quit.ts +++ b/main/src/app-events/block-quit.ts @@ -8,8 +8,9 @@ import { recreateMainWindowForShutdown, sendToMainWindowRenderer, } from '../main-window' -import { getToolhivePort, stopToolhive, binPath } from '../toolhive-manager' +import { stopToolhive, binPath } from '../toolhive-manager' import { stopAllServers } from '../graceful-exit' +import { createMainProcessFetch } from '../unix-socket-fetch' import { safeTrayDestroy } from '../system-tray' import { delay } from '../../../utils/delay' import log from '../logger' @@ -39,10 +40,7 @@ export async function blockQuit(source: string, event?: Electron.Event) { } try { - const port = getToolhivePort() - if (port) { - await stopAllServers(binPath, port) - } + await stopAllServers(binPath, { createFetch: createMainProcessFetch }) } catch (err) { log.error('Teardown failed: ', err) } finally { diff --git a/main/src/app-events/process-signals.ts b/main/src/app-events/process-signals.ts index 859208d58..b83e78713 100644 --- a/main/src/app-events/process-signals.ts +++ b/main/src/app-events/process-signals.ts @@ -3,8 +3,9 @@ import { setTearingDownState, setQuittingState, } from '../app-state' -import { getToolhivePort, stopToolhive, binPath } from '../toolhive-manager' +import { stopToolhive, binPath } from '../toolhive-manager' import { stopAllServers } from '../graceful-exit' +import { createMainProcessFetch } from '../unix-socket-fetch' import { safeTrayDestroy } from '../system-tray' import log from '../logger' @@ -17,10 +18,7 @@ export function register() { setQuittingState(true) log.info(`[${sig}] delaying exit for teardown...`) try { - const port = getToolhivePort() - if (port) { - await stopAllServers(binPath, port) - } + await stopAllServers(binPath, { createFetch: createMainProcessFetch }) } finally { stopToolhive() safeTrayDestroy() diff --git a/main/src/app-events/when-ready.ts b/main/src/app-events/when-ready.ts index 40090fbcd..2f0997aab 100644 --- a/main/src/app-events/when-ready.ts +++ b/main/src/app-events/when-ready.ts @@ -13,6 +13,7 @@ import { isToolhiveRunning, stopToolhive, } from '../toolhive-manager' +import { registerApiFetchHandlers } from '../unix-socket-fetch' import { getMainWindow, createMainWindow, hideMainWindow } from '../main-window' import { extractDeepLinkFromArgs, handleDeepLink } from '../deep-links' import { getCspString } from '../csp' @@ -69,6 +70,9 @@ export function register() { // Start ToolHive with tray reference await startToolhive() + // Register IPC handlers for renderer -> main -> thv API bridge + registerApiFetchHandlers() + // Create main window try { const mainWindow = await createMainWindow() @@ -131,10 +135,9 @@ export function register() { if (process.env.NODE_ENV === 'development') { return callback({ responseHeaders: details.responseHeaders }) } + // When using UNIX sockets, API requests go through IPC so no port is + // needed in connect-src. Pass the port only when available (TCP fallback). const port = getToolhivePort() - if (port == null) { - throw new Error('[content-security-policy] ToolHive port is not set') - } return callback({ responseHeaders: { ...details.responseHeaders, diff --git a/main/src/auto-update.ts b/main/src/auto-update.ts index 851347a0c..fd5699dca 100644 --- a/main/src/auto-update.ts +++ b/main/src/auto-update.ts @@ -2,12 +2,8 @@ import { app, autoUpdater, dialog, ipcMain, type BrowserWindow } from 'electron' import { updateElectronApp, UpdateSourceType } from 'update-electron-app' import * as Sentry from '@sentry/electron/main' import { stopAllServers } from './graceful-exit' -import { - stopToolhive, - getToolhivePort, - binPath, - isToolhiveRunning, -} from './toolhive-manager' +import { stopToolhive, binPath, isToolhiveRunning } from './toolhive-manager' +import { createMainProcessFetch } from './unix-socket-fetch' import { safeTrayDestroy } from './system-tray' import { getAppVersion, pollWindowReady } from './util' import { delay } from '../../utils/delay' @@ -35,14 +31,7 @@ let updateState: UpdateState = 'none' async function safeServerShutdown(): Promise { try { - const port = getToolhivePort() - if (!port) { - log.info('[update] No ToolHive port available, skipping server shutdown') - return true - } - - await stopAllServers(binPath, port) - + await stopAllServers(binPath, { createFetch: createMainProcessFetch }) log.info('[update] All servers stopped successfully') return true } catch (error) { diff --git a/main/src/csp.ts b/main/src/csp.ts index cb7c41835..4f0f70572 100644 --- a/main/src/csp.ts +++ b/main/src/csp.ts @@ -1,15 +1,21 @@ -const getCspMap = (port: number, sentryDsn?: string) => { - // In production with Sentry enabled, allow blob workers for replay +const getCspMap = (port: number | undefined, sentryDsn?: string) => { const hasSentry = Boolean(sentryDsn) const workerSrc = hasSentry ? "'self' blob:" : "'self'" + // When using UNIX sockets the renderer never makes direct HTTP requests + // to the thv server, so no localhost entry is needed in connect-src. + const connectParts = ["'self'"] + if (port != null) connectParts.push(`http://localhost:${port}`) + connectParts.push('https://api.hsforms.com') + if (hasSentry) connectParts.push('https://*.sentry.io') + return { 'default-src': "'self'", 'script-src': "'self'", 'style-src': "'self' 'unsafe-inline'", 'img-src': "'self' data: blob:", 'font-src': "'self' data:", - 'connect-src': `'self' http://localhost:${port} https://api.hsforms.com${hasSentry ? ' https://*.sentry.io' : ''}`, + 'connect-src': connectParts.join(' '), 'frame-src': "'none'", 'object-src': "'none'", 'base-uri': "'self'", @@ -17,13 +23,12 @@ const getCspMap = (port: number, sentryDsn?: string) => { 'frame-ancestors': "'none'", 'manifest-src': "'self'", 'media-src': "'self' blob: data:", - // Allow blob: workers only when Sentry is configured 'worker-src': workerSrc, 'child-src': "'none'", } } -export const getCspString = (port: number, sentryDsn?: string) => +export const getCspString = (port: number | undefined, sentryDsn?: string) => Object.entries(getCspMap(port, sentryDsn)) .map(([key, value]) => `${key} ${value}`) .join('; ') diff --git a/main/src/graceful-exit.ts b/main/src/graceful-exit.ts index 731adddb5..70f2cc795 100644 --- a/main/src/graceful-exit.ts +++ b/main/src/graceful-exit.ts @@ -22,11 +22,15 @@ export const shutdownStore = new Store({ }, }) -/** Create API client for the given port */ -function createApiClient(port: number) { +/** + * Create API client. When a custom fetch is provided (UNIX socket transport), + * the baseUrl is a dummy since the custom fetch handles routing. + */ +function createApiClient(opts: { port?: number; customFetch?: typeof fetch }) { return createClient({ - baseUrl: `http://localhost:${port}`, + baseUrl: opts.port ? `http://localhost:${opts.port}` : 'http://localhost', headers: getHeaders(), + ...(opts.customFetch ? { fetch: opts.customFetch } : {}), }) } @@ -114,10 +118,11 @@ async function pollUntilAllStopped( /** Stop every running server in parallel and wait until *all* are down. */ export async function stopAllServers( - _binPath: string, // Kept for backward compatibility - port: number + _binPath: string, + opts: { port?: number; createFetch?: () => typeof fetch } ): Promise { - const client = createApiClient(port) + const customFetch = opts.createFetch?.() + const client = createApiClient({ port: opts.port, customFetch }) const servers = await getRunningServers(client) log.info( `Found ${servers.length} running servers: `, diff --git a/main/src/ipc-handlers/toolhive.ts b/main/src/ipc-handlers/toolhive.ts index f6e23627d..aa5a27740 100644 --- a/main/src/ipc-handlers/toolhive.ts +++ b/main/src/ipc-handlers/toolhive.ts @@ -2,17 +2,20 @@ import { ipcMain } from 'electron' import { restartToolhive, getToolhivePort, + getToolhiveSocketPath, isToolhiveRunning, getToolhiveMcpPort, isUsingCustomPort, } from '../toolhive-manager' import { checkContainerEngine } from '../container-engine' import { getLastShutdownServers, clearShutdownHistory } from '../graceful-exit' +import { registerApiFetchHandlers } from '../unix-socket-fetch' import log from '../logger' export function register() { ipcMain.handle('get-toolhive-port', () => getToolhivePort()) ipcMain.handle('get-toolhive-mcp-port', () => getToolhiveMcpPort()) + ipcMain.handle('get-toolhive-socket-path', () => getToolhiveSocketPath()) ipcMain.handle('is-toolhive-running', () => isToolhiveRunning()) ipcMain.handle('is-using-custom-port', () => isUsingCustomPort()) @@ -41,4 +44,6 @@ export function register() { clearShutdownHistory() return { success: true } }) + + registerApiFetchHandlers() } diff --git a/main/src/tests/auto-update.test.ts b/main/src/tests/auto-update.test.ts index df7d8077c..418aa5752 100644 --- a/main/src/tests/auto-update.test.ts +++ b/main/src/tests/auto-update.test.ts @@ -124,6 +124,10 @@ vi.mock('../toolhive-manager', () => ({ binPath: '/mock/bin/path', })) +vi.mock('../unix-socket-fetch', () => ({ + createMainProcessFetch: vi.fn(() => vi.fn()), +})) + vi.mock('../system-tray', () => ({ safeTrayDestroy: vi.fn(), })) @@ -156,7 +160,7 @@ vi.mock('../app-state', () => ({ })) import { stopAllServers } from '../graceful-exit' -import { stopToolhive, getToolhivePort } from '../toolhive-manager' +import { stopToolhive } from '../toolhive-manager' import { safeTrayDestroy } from '../system-tray' import { pollWindowReady } from '../util' import { delay } from '../../../utils/delay' @@ -199,7 +203,6 @@ describe('auto-update', () => { // Setup default mocks vi.mocked(stopAllServers).mockResolvedValue(undefined) vi.mocked(stopToolhive).mockReturnValue(undefined) - vi.mocked(getToolhivePort).mockReturnValue(3000) vi.mocked(pollWindowReady).mockResolvedValue(undefined) vi.mocked(delay).mockResolvedValue(undefined) vi.mocked(dialog.showMessageBox).mockResolvedValue({ @@ -803,8 +806,7 @@ describe('auto-update', () => { expect(vi.mocked(autoUpdater).quitAndInstall).toHaveBeenCalled() }) - it('integrates with toolhive manager port detection', async () => { - vi.mocked(getToolhivePort).mockReturnValue(undefined) + it('always attempts server shutdown via IPC fetch bridge', async () => { vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, checkboxChecked: false, @@ -823,13 +825,14 @@ describe('auto-update', () => { await updatePromise - // Should skip server shutdown when no port is available - expect(vi.mocked(getToolhivePort)).toHaveBeenCalled() - expect(vi.mocked(stopAllServers)).not.toHaveBeenCalled() + // Always attempts server shutdown (connection errors handled internally) + expect(vi.mocked(stopAllServers)).toHaveBeenCalled() }) - it('handles missing toolhive port gracefully', async () => { - vi.mocked(getToolhivePort).mockReturnValue(undefined) + it('handles server shutdown failure gracefully', async () => { + vi.mocked(stopAllServers).mockRejectedValueOnce( + new Error('No ToolHive connection available') + ) vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, checkboxChecked: false, @@ -848,8 +851,9 @@ describe('auto-update', () => { await updatePromise - expect(vi.mocked(log).info).toHaveBeenCalledWith( - '[update] No ToolHive port available, skipping server shutdown' + expect(vi.mocked(log).error).toHaveBeenCalledWith( + expect.stringContaining('[update] Server shutdown failed'), + expect.anything() ) }) diff --git a/main/src/tests/graceful-exit.test.ts b/main/src/tests/graceful-exit.test.ts index 165aec7ca..e91119da7 100644 --- a/main/src/tests/graceful-exit.test.ts +++ b/main/src/tests/graceful-exit.test.ts @@ -117,7 +117,7 @@ describe('graceful-exit', () => { createMockWorkloadsResponse([]) ) - await stopAllServers('', 3000) + await stopAllServers('', { port: 3000 }) expect(mockLog.info).toHaveBeenCalledWith( 'No running servers – teardown complete' @@ -140,7 +140,7 @@ describe('graceful-exit', () => { mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse()) - await stopAllServers('', 3000) + await stopAllServers('', { port: 3000 }) expect(mockPostApiV1BetaWorkloadsStop).toHaveBeenCalledTimes(1) expect(mockPostApiV1BetaWorkloadsStop).toHaveBeenCalledWith({ @@ -165,7 +165,7 @@ describe('graceful-exit', () => { mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse()) - await stopAllServers('', 3000) + await stopAllServers('', { port: 3000 }) expect(mockLog.info).toHaveBeenCalledWith( 'All servers have reached final state' @@ -182,7 +182,9 @@ describe('graceful-exit', () => { new Error('Stop failed') ) - await expect(stopAllServers('', 3000)).rejects.toThrow('Stop failed') + await expect(stopAllServers('', { port: 3000 })).rejects.toThrow( + 'Stop failed' + ) }) it('handles timeout when servers do not stop', async () => { @@ -201,7 +203,7 @@ describe('graceful-exit', () => { mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse()) - await expect(stopAllServers('', 3000)).rejects.toThrow( + await expect(stopAllServers('', { port: 3000 })).rejects.toThrow( 'Some servers failed to stop within timeout' ) }) @@ -213,7 +215,7 @@ describe('graceful-exit', () => { mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse()) - await stopAllServers('', 3000) + await stopAllServers('', { port: 3000 }) expect(mockWriteShutdownServers).toHaveBeenCalledWith(mockRunningServers) }) @@ -234,7 +236,7 @@ describe('graceful-exit', () => { mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse()) - await stopAllServers('', 3000) + await stopAllServers('', { port: 3000 }) // Should only include the server with a name in the batch call expect(mockPostApiV1BetaWorkloadsStop).toHaveBeenCalledTimes(1) @@ -300,7 +302,7 @@ describe('graceful-exit', () => { mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse()) - await stopAllServers('', 3000) + await stopAllServers('', { port: 3000 }) expect(mockLog.info).toHaveBeenCalledWith( 'Still waiting for 1 servers to reach final state: server1(stopping)' @@ -326,7 +328,7 @@ describe('graceful-exit', () => { mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse()) - await stopAllServers('', 3000) + await stopAllServers('', { port: 3000 }) // Should call delay between polling attempts (not on first attempt) expect(mockDelay).toHaveBeenCalledWith(2000) diff --git a/main/src/toolhive-manager.ts b/main/src/toolhive-manager.ts index 440f03f91..727457acf 100644 --- a/main/src/toolhive-manager.ts +++ b/main/src/toolhive-manager.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import { existsSync } from 'node:fs' +import { existsSync, unlinkSync } from 'node:fs' import path from 'node:path' import net from 'node:net' import { app } from 'electron' @@ -28,6 +28,7 @@ const binPath = app.isPackaged let toolhiveProcess: ReturnType | undefined let toolhivePort: number | undefined let toolhiveMcpPort: number | undefined +let toolhiveSocketPath: string | undefined let isRestarting = false let killTimer: NodeJS.Timeout | undefined @@ -39,6 +40,10 @@ export function getToolhiveMcpPort(): number | undefined { return toolhiveMcpPort } +export function getToolhiveSocketPath(): string | undefined { + return toolhiveSocketPath +} + export function isToolhiveRunning(): boolean { const isRunning = !!toolhiveProcess && !toolhiveProcess.killed return isRunning @@ -108,6 +113,21 @@ async function findFreePort( return await getRandomPort() } +function generateSocketPath(): string { + const socketName = `toolhive-${process.pid}.sock` + return path.join(app.getPath('temp'), socketName) +} + +function cleanupSocketFile(socketPath: string): void { + try { + if (existsSync(socketPath)) { + unlinkSync(socketPath) + } + } catch { + // Ignore cleanup errors + } +} + export async function startToolhive(): Promise { Sentry.withScope>(async (scope) => { if (isUsingCustomPort()) { @@ -119,6 +139,7 @@ export async function startToolhive(): Promise { return } toolhivePort = customPort + toolhiveSocketPath = undefined toolhiveMcpPort = process.env.THV_MCP_PORT ? parseInt(process.env.THV_MCP_PORT!, 10) : undefined @@ -132,9 +153,11 @@ export async function startToolhive(): Promise { } toolhiveMcpPort = await findFreePort() - toolhivePort = await findFreePort(50000, 50100) + toolhiveSocketPath = generateSocketPath() + cleanupSocketFile(toolhiveSocketPath) + log.info( - `Starting ToolHive from: ${binPath} on port ${toolhivePort}, MCP on port ${toolhiveMcpPort}` + `Starting ToolHive from: ${binPath} on socket ${toolhiveSocketPath}, MCP on port ${toolhiveMcpPort}` ) toolhiveProcess = spawn( @@ -145,14 +168,11 @@ export async function startToolhive(): Promise { '--experimental-mcp', '--experimental-mcp-host=127.0.0.1', `--experimental-mcp-port=${toolhiveMcpPort}`, - '--host=127.0.0.1', - `--port=${toolhivePort}`, + `--socket=${toolhiveSocketPath}`, ], { stdio: ['ignore', 'ignore', 'pipe'], detached: false, - // Ensure child process is killed when parent exits - // On Windows, this creates a job object to enforce cleanup windowsHide: true, env: { ...process.env, @@ -164,7 +184,7 @@ export async function startToolhive(): Promise { scope.addBreadcrumb({ category: 'debug', - message: `Starting ToolHive from: ${binPath} on port ${toolhivePort}, MCP on port ${toolhiveMcpPort}, PID: ${toolhiveProcess.pid}`, + message: `Starting ToolHive from: ${binPath} on socket ${toolhiveSocketPath}, MCP on port ${toolhiveMcpPort}, PID: ${toolhiveProcess.pid}`, }) updateTrayStatus(!!toolhiveProcess) @@ -316,6 +336,10 @@ export function stopToolhive(options?: { force?: boolean }): void { scheduleForceKill(processToKill, pidToKill) } + if (toolhiveSocketPath) { + cleanupSocketFile(toolhiveSocketPath) + } + log.info(`[stopToolhive] Process cleanup completed`) } diff --git a/main/src/unix-socket-fetch.ts b/main/src/unix-socket-fetch.ts new file mode 100644 index 000000000..3f51904d1 --- /dev/null +++ b/main/src/unix-socket-fetch.ts @@ -0,0 +1,166 @@ +import http from 'node:http' +import { ipcMain } from 'electron' +import log from './logger' +import { getToolhiveSocketPath, getToolhivePort } from './toolhive-manager' +import { getHeaders } from './headers' + +export interface ApiFetchRequest { + requestId: string + method: string + path: string + headers: Record + body?: string +} + +export interface ApiFetchResponse { + status: number + headers: Record + body: string +} + +const inflightRequests = new Map() + +function serializeResponseHeaders( + raw: http.IncomingHttpHeaders +): Record { + const headers: Record = {} + for (const [key, value] of Object.entries(raw)) { + if (value !== undefined) { + headers[key] = Array.isArray(value) ? value.join(', ') : value + } + } + return headers +} + +function performRequest( + connectionOpts: { socketPath: string } | { hostname: string; port: number }, + opts: { + method: string + path: string + headers: Record + body?: string + }, + requestId?: string +): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { + ...connectionOpts, + method: opts.method, + path: opts.path, + headers: opts.headers, + }, + (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + if (requestId) inflightRequests.delete(requestId) + resolve({ + status: res.statusCode ?? 500, + headers: serializeResponseHeaders(res.headers), + body: Buffer.concat(chunks).toString('utf-8'), + }) + }) + } + ) + + if (requestId) inflightRequests.set(requestId, req) + + req.on('error', (err) => { + if (requestId) inflightRequests.delete(requestId) + reject(err) + }) + + if (opts.body) req.write(opts.body) + req.end() + }) +} + +function getConnectionOpts(): + | { socketPath: string } + | { hostname: string; port: number } { + const socketPath = getToolhiveSocketPath() + if (socketPath) return { socketPath } + + const port = getToolhivePort() + if (port) return { hostname: '127.0.0.1', port } + + throw new Error('No ToolHive connection available (no socket path or port)') +} + +/** + * Creates a `fetch`-compatible function that routes requests through a UNIX + * socket (or TCP fallback). Intended for use in the main process (e.g. the + * graceful-exit client) where Node.js APIs are available. + */ +export function createMainProcessFetch(): typeof fetch { + return async ( + input: RequestInfo | URL, + init?: RequestInit + ): Promise => { + const request = new Request(input, init) + const url = new URL(request.url) + const body = request.body + ? await new Response(request.body).text() + : undefined + + const result = await performRequest(getConnectionOpts(), { + method: request.method, + path: url.pathname + url.search, + headers: Object.fromEntries(request.headers.entries()), + body, + }) + + return new Response(result.body, { + status: result.status, + headers: result.headers, + }) + } +} + +/** + * Registers IPC handlers that let the renderer make API requests through the + * main process. The main process then forwards them over the UNIX socket (or + * TCP port as fallback) to the thv server. + */ +export function registerApiFetchHandlers(): void { + ipcMain.handle( + 'api-fetch', + async (_event, opts: ApiFetchRequest): Promise => { + const rawHeaders = getHeaders() + const telemetryHeaders: Record = {} + for (const [k, v] of Object.entries(rawHeaders)) { + telemetryHeaders[k] = String(v) + } + const mergedHeaders = { ...telemetryHeaders, ...opts.headers } + + try { + return await performRequest( + getConnectionOpts(), + { + method: opts.method, + path: opts.path, + headers: mergedHeaders, + body: opts.body, + }, + opts.requestId + ) + } catch (err) { + log.error( + `[api-fetch] Request failed: ${opts.method} ${opts.path}`, + err + ) + throw err + } + } + ) + + ipcMain.handle('api-fetch-abort', (_event, requestId: string) => { + const req = inflightRequests.get(requestId) + if (req) { + req.destroy() + inflightRequests.delete(requestId) + log.info(`[api-fetch] Aborted request ${requestId}`) + } + }) +} diff --git a/preload/src/api/toolhive.ts b/preload/src/api/toolhive.ts index 5d1073e95..4398f6fe4 100644 --- a/preload/src/api/toolhive.ts +++ b/preload/src/api/toolhive.ts @@ -5,12 +5,23 @@ import { TOOLHIVE_VERSION } from '../../../utils/constants' export const toolhiveApi = { getToolhivePort: () => ipcRenderer.invoke('get-toolhive-port'), getToolhiveMcpPort: () => ipcRenderer.invoke('get-toolhive-mcp-port'), + getToolhiveSocketPath: () => ipcRenderer.invoke('get-toolhive-socket-path'), getToolhiveVersion: () => TOOLHIVE_VERSION, isToolhiveRunning: () => ipcRenderer.invoke('is-toolhive-running'), isUsingCustomPort: () => ipcRenderer.invoke('is-using-custom-port'), checkContainerEngine: () => ipcRenderer.invoke('check-container-engine'), restartToolhive: () => ipcRenderer.invoke('restart-toolhive'), + apiFetch: (req: { + requestId: string + method: string + path: string + headers: Record + body?: string + }) => ipcRenderer.invoke('api-fetch', req), + apiFetchAbort: (requestId: string) => + ipcRenderer.invoke('api-fetch-abort', requestId), + shutdownStore: { getLastShutdownServers: () => ipcRenderer.invoke('shutdown-store:get-last-servers'), @@ -22,6 +33,7 @@ export const toolhiveApi = { export interface ToolhiveAPI { getToolhivePort: () => Promise getToolhiveMcpPort: () => Promise + getToolhiveSocketPath: () => Promise getToolhiveVersion: () => string isToolhiveRunning: () => Promise isUsingCustomPort: () => Promise @@ -35,6 +47,18 @@ export interface ToolhiveAPI { success: boolean error?: string }> + apiFetch: (req: { + requestId: string + method: string + path: string + headers: Record + body?: string + }) => Promise<{ + status: number + headers: Record + body: string + }> + apiFetchAbort: (requestId: string) => Promise shutdownStore: { getLastShutdownServers: () => Promise clearShutdownHistory: () => Promise<{ success: boolean }> diff --git a/renderer/src/common/lib/ipc-fetch.ts b/renderer/src/common/lib/ipc-fetch.ts new file mode 100644 index 000000000..8c495d546 --- /dev/null +++ b/renderer/src/common/lib/ipc-fetch.ts @@ -0,0 +1,67 @@ +let requestCounter = 0 + +function nextRequestId(): string { + return `req-${Date.now()}-${++requestCounter}` +} + +// Status codes where the browser forbids a response body (fetch spec). +const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]) + +/** + * A `fetch`-compatible function that routes HTTP requests through the Electron + * IPC bridge. The main process forwards them to the thv server over a UNIX + * socket (or TCP as fallback). + * + * Plug this into the hey-api client via `client.setConfig({ fetch: ipcFetch })` + * so all generated SDK calls transparently use the IPC transport. + */ +export const ipcFetch: typeof fetch = async ( + input: RequestInfo | URL, + init?: RequestInit +): Promise => { + const request = new Request(input, init) + const url = new URL(request.url) + const requestId = nextRequestId() + + const body = request.body + ? await new Response(request.body).text() + : undefined + + if (request.signal?.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError') + } + + const abortHandler = () => { + window.electronAPI.apiFetchAbort(requestId) + } + + request.signal?.addEventListener('abort', abortHandler, { once: true }) + + try { + const result = await window.electronAPI.apiFetch({ + requestId, + method: request.method, + path: url.pathname + url.search, + headers: Object.fromEntries(request.headers.entries()), + body, + }) + + // The browser's Response constructor throws if you provide a body for + // null-body status codes (204, 304, etc.). + const responseBody = NULL_BODY_STATUSES.has(result.status) + ? null + : result.body + + return new Response(responseBody, { + status: result.status, + headers: result.headers, + }) + } catch (err) { + if (request.signal?.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError') + } + throw err + } finally { + request.signal?.removeEventListener('abort', abortHandler) + } +} diff --git a/renderer/src/common/mocks/electronAPI.ts b/renderer/src/common/mocks/electronAPI.ts index 74d474ac3..f31ca2b27 100644 --- a/renderer/src/common/mocks/electronAPI.ts +++ b/renderer/src/common/mocks/electronAPI.ts @@ -40,6 +40,10 @@ function createElectronStub(): Partial { disable: vi.fn().mockResolvedValue(undefined), getAll: vi.fn().mockResolvedValue({}), } as ElectronAPI['featureFlags'], + apiFetch: vi + .fn() + .mockResolvedValue({ status: 200, headers: {}, body: '{}' }), + apiFetchAbort: vi.fn().mockResolvedValue(undefined), chat: { stream: vi.fn(), } as unknown as ElectronAPI['chat'], diff --git a/renderer/src/lib/client-config.ts b/renderer/src/lib/client-config.ts index 25a4d7dd0..b4c93f50b 100644 --- a/renderer/src/lib/client-config.ts +++ b/renderer/src/lib/client-config.ts @@ -1,18 +1,23 @@ import { client } from '@common/api/generated/client.gen' +import { ipcFetch } from '../common/lib/ipc-fetch' import log from 'electron-log/renderer' export async function configureClient() { try { - const port = await window.electronAPI.getToolhivePort() + // All API requests are routed through the main process via IPC. The main + // process forwards them to the thv server over a UNIX socket (or TCP + // fallback). The baseUrl is a dummy used only for URL construction inside + // the hey-api client; the ipcFetch adapter strips it and sends only the + // path + query to the main process. const telemetryHeaders = await window.electronAPI.getTelemetryHeaders() - const baseUrl = `http://localhost:${port}` client.setConfig({ - baseUrl, + baseUrl: 'http://localhost', + fetch: ipcFetch, headers: telemetryHeaders, }) } catch (e) { - log.error('Failed to get ToolHive port from main process: ', e) + log.error('Failed to configure ToolHive API client: ', e) throw e } } diff --git a/renderer/src/renderer.tsx b/renderer/src/renderer.tsx index 9889d9780..baa0efa23 100644 --- a/renderer/src/renderer.tsx +++ b/renderer/src/renderer.tsx @@ -20,8 +20,8 @@ import './common/lib/os-design' initSentry() -if (!window.electronAPI || !window.electronAPI.getToolhivePort) { - log.error('ToolHive port API not available in renderer') +if (!window.electronAPI || !window.electronAPI.apiFetch) { + log.error('ToolHive API bridge not available in renderer') } ;(async () => {