Skip to content

Commit b740d5e

Browse files
committed
feat(api): move from http to socket
1 parent 0b1a935 commit b740d5e

File tree

16 files changed

+371
-72
lines changed

16 files changed

+371
-72
lines changed

main/src/app-events/block-quit.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import {
88
recreateMainWindowForShutdown,
99
sendToMainWindowRenderer,
1010
} from '../main-window'
11-
import { getToolhivePort, stopToolhive, binPath } from '../toolhive-manager'
11+
import { stopToolhive, binPath } from '../toolhive-manager'
1212
import { stopAllServers } from '../graceful-exit'
13+
import { createMainProcessFetch } from '../unix-socket-fetch'
1314
import { safeTrayDestroy } from '../system-tray'
1415
import { delay } from '../../../utils/delay'
1516
import log from '../logger'
@@ -39,10 +40,7 @@ export async function blockQuit(source: string, event?: Electron.Event) {
3940
}
4041

4142
try {
42-
const port = getToolhivePort()
43-
if (port) {
44-
await stopAllServers(binPath, port)
45-
}
43+
await stopAllServers(binPath, { createFetch: createMainProcessFetch })
4644
} catch (err) {
4745
log.error('Teardown failed: ', err)
4846
} finally {

main/src/app-events/process-signals.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import {
33
setTearingDownState,
44
setQuittingState,
55
} from '../app-state'
6-
import { getToolhivePort, stopToolhive, binPath } from '../toolhive-manager'
6+
import { stopToolhive, binPath } from '../toolhive-manager'
77
import { stopAllServers } from '../graceful-exit'
8+
import { createMainProcessFetch } from '../unix-socket-fetch'
89
import { safeTrayDestroy } from '../system-tray'
910
import log from '../logger'
1011

@@ -17,10 +18,7 @@ export function register() {
1718
setQuittingState(true)
1819
log.info(`[${sig}] delaying exit for teardown...`)
1920
try {
20-
const port = getToolhivePort()
21-
if (port) {
22-
await stopAllServers(binPath, port)
23-
}
21+
await stopAllServers(binPath, { createFetch: createMainProcessFetch })
2422
} finally {
2523
stopToolhive()
2624
safeTrayDestroy()

main/src/app-events/when-ready.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isToolhiveRunning,
1414
stopToolhive,
1515
} from '../toolhive-manager'
16+
import { registerApiFetchHandlers } from '../unix-socket-fetch'
1617
import { getMainWindow, createMainWindow, hideMainWindow } from '../main-window'
1718
import { extractDeepLinkFromArgs, handleDeepLink } from '../deep-links'
1819
import { getCspString } from '../csp'
@@ -69,6 +70,9 @@ export function register() {
6970
// Start ToolHive with tray reference
7071
await startToolhive()
7172

73+
// Register IPC handlers for renderer -> main -> thv API bridge
74+
registerApiFetchHandlers()
75+
7276
// Create main window
7377
try {
7478
const mainWindow = await createMainWindow()
@@ -131,10 +135,9 @@ export function register() {
131135
if (process.env.NODE_ENV === 'development') {
132136
return callback({ responseHeaders: details.responseHeaders })
133137
}
138+
// When using UNIX sockets, API requests go through IPC so no port is
139+
// needed in connect-src. Pass the port only when available (TCP fallback).
134140
const port = getToolhivePort()
135-
if (port == null) {
136-
throw new Error('[content-security-policy] ToolHive port is not set')
137-
}
138141
return callback({
139142
responseHeaders: {
140143
...details.responseHeaders,

main/src/auto-update.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,8 @@ import { app, autoUpdater, dialog, ipcMain, type BrowserWindow } from 'electron'
22
import { updateElectronApp, UpdateSourceType } from 'update-electron-app'
33
import * as Sentry from '@sentry/electron/main'
44
import { stopAllServers } from './graceful-exit'
5-
import {
6-
stopToolhive,
7-
getToolhivePort,
8-
binPath,
9-
isToolhiveRunning,
10-
} from './toolhive-manager'
5+
import { stopToolhive, binPath, isToolhiveRunning } from './toolhive-manager'
6+
import { createMainProcessFetch } from './unix-socket-fetch'
117
import { safeTrayDestroy } from './system-tray'
128
import { getAppVersion, pollWindowReady } from './util'
139
import { delay } from '../../utils/delay'
@@ -35,14 +31,7 @@ let updateState: UpdateState = 'none'
3531

3632
async function safeServerShutdown(): Promise<boolean> {
3733
try {
38-
const port = getToolhivePort()
39-
if (!port) {
40-
log.info('[update] No ToolHive port available, skipping server shutdown')
41-
return true
42-
}
43-
44-
await stopAllServers(binPath, port)
45-
34+
await stopAllServers(binPath, { createFetch: createMainProcessFetch })
4635
log.info('[update] All servers stopped successfully')
4736
return true
4837
} catch (error) {

main/src/csp.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,34 @@
1-
const getCspMap = (port: number, sentryDsn?: string) => {
2-
// In production with Sentry enabled, allow blob workers for replay
1+
const getCspMap = (port: number | undefined, sentryDsn?: string) => {
32
const hasSentry = Boolean(sentryDsn)
43
const workerSrc = hasSentry ? "'self' blob:" : "'self'"
54

5+
// When using UNIX sockets the renderer never makes direct HTTP requests
6+
// to the thv server, so no localhost entry is needed in connect-src.
7+
const connectParts = ["'self'"]
8+
if (port != null) connectParts.push(`http://localhost:${port}`)
9+
connectParts.push('https://api.hsforms.com')
10+
if (hasSentry) connectParts.push('https://*.sentry.io')
11+
612
return {
713
'default-src': "'self'",
814
'script-src': "'self'",
915
'style-src': "'self' 'unsafe-inline'",
1016
'img-src': "'self' data: blob:",
1117
'font-src': "'self' data:",
12-
'connect-src': `'self' http://localhost:${port} https://api.hsforms.com${hasSentry ? ' https://*.sentry.io' : ''}`,
18+
'connect-src': connectParts.join(' '),
1319
'frame-src': "'none'",
1420
'object-src': "'none'",
1521
'base-uri': "'self'",
1622
'form-action': "'self'",
1723
'frame-ancestors': "'none'",
1824
'manifest-src': "'self'",
1925
'media-src': "'self' blob: data:",
20-
// Allow blob: workers only when Sentry is configured
2126
'worker-src': workerSrc,
2227
'child-src': "'none'",
2328
}
2429
}
2530

26-
export const getCspString = (port: number, sentryDsn?: string) =>
31+
export const getCspString = (port: number | undefined, sentryDsn?: string) =>
2732
Object.entries(getCspMap(port, sentryDsn))
2833
.map(([key, value]) => `${key} ${value}`)
2934
.join('; ')

main/src/graceful-exit.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,15 @@ export const shutdownStore = new Store({
2222
},
2323
})
2424

25-
/** Create API client for the given port */
26-
function createApiClient(port: number) {
25+
/**
26+
* Create API client. When a custom fetch is provided (UNIX socket transport),
27+
* the baseUrl is a dummy since the custom fetch handles routing.
28+
*/
29+
function createApiClient(opts: { port?: number; customFetch?: typeof fetch }) {
2730
return createClient({
28-
baseUrl: `http://localhost:${port}`,
31+
baseUrl: opts.port ? `http://localhost:${opts.port}` : 'http://localhost',
2932
headers: getHeaders(),
33+
...(opts.customFetch ? { fetch: opts.customFetch } : {}),
3034
})
3135
}
3236

@@ -114,10 +118,11 @@ async function pollUntilAllStopped(
114118

115119
/** Stop every running server in parallel and wait until *all* are down. */
116120
export async function stopAllServers(
117-
_binPath: string, // Kept for backward compatibility
118-
port: number
121+
_binPath: string,
122+
opts: { port?: number; createFetch?: () => typeof fetch }
119123
): Promise<void> {
120-
const client = createApiClient(port)
124+
const customFetch = opts.createFetch?.()
125+
const client = createApiClient({ port: opts.port, customFetch })
121126
const servers = await getRunningServers(client)
122127
log.info(
123128
`Found ${servers.length} running servers: `,

main/src/ipc-handlers/toolhive.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@ import { ipcMain } from 'electron'
22
import {
33
restartToolhive,
44
getToolhivePort,
5+
getToolhiveSocketPath,
56
isToolhiveRunning,
67
getToolhiveMcpPort,
78
isUsingCustomPort,
89
} from '../toolhive-manager'
910
import { checkContainerEngine } from '../container-engine'
1011
import { getLastShutdownServers, clearShutdownHistory } from '../graceful-exit'
12+
import { registerApiFetchHandlers } from '../unix-socket-fetch'
1113
import log from '../logger'
1214

1315
export function register() {
1416
ipcMain.handle('get-toolhive-port', () => getToolhivePort())
1517
ipcMain.handle('get-toolhive-mcp-port', () => getToolhiveMcpPort())
18+
ipcMain.handle('get-toolhive-socket-path', () => getToolhiveSocketPath())
1619
ipcMain.handle('is-toolhive-running', () => isToolhiveRunning())
1720
ipcMain.handle('is-using-custom-port', () => isUsingCustomPort())
1821

@@ -41,4 +44,6 @@ export function register() {
4144
clearShutdownHistory()
4245
return { success: true }
4346
})
47+
48+
registerApiFetchHandlers()
4449
}

main/src/tests/auto-update.test.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ vi.mock('../toolhive-manager', () => ({
124124
binPath: '/mock/bin/path',
125125
}))
126126

127+
vi.mock('../unix-socket-fetch', () => ({
128+
createMainProcessFetch: vi.fn(() => vi.fn()),
129+
}))
130+
127131
vi.mock('../system-tray', () => ({
128132
safeTrayDestroy: vi.fn(),
129133
}))
@@ -156,7 +160,7 @@ vi.mock('../app-state', () => ({
156160
}))
157161

158162
import { stopAllServers } from '../graceful-exit'
159-
import { stopToolhive, getToolhivePort } from '../toolhive-manager'
163+
import { stopToolhive } from '../toolhive-manager'
160164
import { safeTrayDestroy } from '../system-tray'
161165
import { pollWindowReady } from '../util'
162166
import { delay } from '../../../utils/delay'
@@ -199,7 +203,6 @@ describe('auto-update', () => {
199203
// Setup default mocks
200204
vi.mocked(stopAllServers).mockResolvedValue(undefined)
201205
vi.mocked(stopToolhive).mockReturnValue(undefined)
202-
vi.mocked(getToolhivePort).mockReturnValue(3000)
203206
vi.mocked(pollWindowReady).mockResolvedValue(undefined)
204207
vi.mocked(delay).mockResolvedValue(undefined)
205208
vi.mocked(dialog.showMessageBox).mockResolvedValue({
@@ -803,8 +806,7 @@ describe('auto-update', () => {
803806
expect(vi.mocked(autoUpdater).quitAndInstall).toHaveBeenCalled()
804807
})
805808

806-
it('integrates with toolhive manager port detection', async () => {
807-
vi.mocked(getToolhivePort).mockReturnValue(undefined)
809+
it('always attempts server shutdown via IPC fetch bridge', async () => {
808810
vi.mocked(dialog.showMessageBox).mockResolvedValue({
809811
response: 0,
810812
checkboxChecked: false,
@@ -823,13 +825,14 @@ describe('auto-update', () => {
823825

824826
await updatePromise
825827

826-
// Should skip server shutdown when no port is available
827-
expect(vi.mocked(getToolhivePort)).toHaveBeenCalled()
828-
expect(vi.mocked(stopAllServers)).not.toHaveBeenCalled()
828+
// Always attempts server shutdown (connection errors handled internally)
829+
expect(vi.mocked(stopAllServers)).toHaveBeenCalled()
829830
})
830831

831-
it('handles missing toolhive port gracefully', async () => {
832-
vi.mocked(getToolhivePort).mockReturnValue(undefined)
832+
it('handles server shutdown failure gracefully', async () => {
833+
vi.mocked(stopAllServers).mockRejectedValueOnce(
834+
new Error('No ToolHive connection available')
835+
)
833836
vi.mocked(dialog.showMessageBox).mockResolvedValue({
834837
response: 0,
835838
checkboxChecked: false,
@@ -848,8 +851,9 @@ describe('auto-update', () => {
848851

849852
await updatePromise
850853

851-
expect(vi.mocked(log).info).toHaveBeenCalledWith(
852-
'[update] No ToolHive port available, skipping server shutdown'
854+
expect(vi.mocked(log).error).toHaveBeenCalledWith(
855+
expect.stringContaining('[update] Server shutdown failed'),
856+
expect.anything()
853857
)
854858
})
855859

main/src/tests/graceful-exit.test.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ describe('graceful-exit', () => {
117117
createMockWorkloadsResponse([])
118118
)
119119

120-
await stopAllServers('', 3000)
120+
await stopAllServers('', { port: 3000 })
121121

122122
expect(mockLog.info).toHaveBeenCalledWith(
123123
'No running servers – teardown complete'
@@ -140,7 +140,7 @@ describe('graceful-exit', () => {
140140

141141
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
142142

143-
await stopAllServers('', 3000)
143+
await stopAllServers('', { port: 3000 })
144144

145145
expect(mockPostApiV1BetaWorkloadsStop).toHaveBeenCalledTimes(1)
146146
expect(mockPostApiV1BetaWorkloadsStop).toHaveBeenCalledWith({
@@ -165,7 +165,7 @@ describe('graceful-exit', () => {
165165

166166
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
167167

168-
await stopAllServers('', 3000)
168+
await stopAllServers('', { port: 3000 })
169169

170170
expect(mockLog.info).toHaveBeenCalledWith(
171171
'All servers have reached final state'
@@ -182,7 +182,9 @@ describe('graceful-exit', () => {
182182
new Error('Stop failed')
183183
)
184184

185-
await expect(stopAllServers('', 3000)).rejects.toThrow('Stop failed')
185+
await expect(stopAllServers('', { port: 3000 })).rejects.toThrow(
186+
'Stop failed'
187+
)
186188
})
187189

188190
it('handles timeout when servers do not stop', async () => {
@@ -201,7 +203,7 @@ describe('graceful-exit', () => {
201203

202204
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
203205

204-
await expect(stopAllServers('', 3000)).rejects.toThrow(
206+
await expect(stopAllServers('', { port: 3000 })).rejects.toThrow(
205207
'Some servers failed to stop within timeout'
206208
)
207209
})
@@ -213,7 +215,7 @@ describe('graceful-exit', () => {
213215

214216
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
215217

216-
await stopAllServers('', 3000)
218+
await stopAllServers('', { port: 3000 })
217219

218220
expect(mockWriteShutdownServers).toHaveBeenCalledWith(mockRunningServers)
219221
})
@@ -234,7 +236,7 @@ describe('graceful-exit', () => {
234236

235237
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
236238

237-
await stopAllServers('', 3000)
239+
await stopAllServers('', { port: 3000 })
238240

239241
// Should only include the server with a name in the batch call
240242
expect(mockPostApiV1BetaWorkloadsStop).toHaveBeenCalledTimes(1)
@@ -300,7 +302,7 @@ describe('graceful-exit', () => {
300302

301303
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
302304

303-
await stopAllServers('', 3000)
305+
await stopAllServers('', { port: 3000 })
304306

305307
expect(mockLog.info).toHaveBeenCalledWith(
306308
'Still waiting for 1 servers to reach final state: server1(stopping)'
@@ -326,7 +328,7 @@ describe('graceful-exit', () => {
326328

327329
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
328330

329-
await stopAllServers('', 3000)
331+
await stopAllServers('', { port: 3000 })
330332

331333
// Should call delay between polling attempts (not on first attempt)
332334
expect(mockDelay).toHaveBeenCalledWith(2000)

0 commit comments

Comments
 (0)