/** * IPC Handlers * Registers the renderer channels used by the opencode-first desktop app. */ import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'; import type { OpencodeManager } from '../opencode/manager'; import { registerHostApiProxyHandlers } from './ipc/host-api-proxy'; import { registerTranscriptExportHandler } from './ipc/transcript-export'; import { applyProxySettings } from './proxy'; import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup'; import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../utils/store'; import { getProviderService } from '../services/providers/provider-service'; import type { ProviderConfig } from '../utils/secure-storage'; import type { ProviderAccount } from '../shared/providers/types'; import { validateApiKeyWithProvider } from '../services/providers/provider-validation'; type UnifiedRequest = { id?: string; module?: string; action?: string; payload?: unknown; }; type UnifiedResponse = { id?: string; ok: boolean; data?: unknown; error?: { code?: string; message?: string; details?: unknown; }; }; function ok(id: string | undefined, data: unknown): UnifiedResponse { return { id, ok: true, data }; } function fail(id: string | undefined, code: string, message: string, details?: unknown): UnifiedResponse { return { id, ok: false, error: { code, message, details }, }; } function unsupported(id: string | undefined, module: string, action: string): UnifiedResponse { return fail(id, 'UNSUPPORTED', `APP_REQUEST_UNSUPPORTED:${module}:${action}`); } function payloadArray(payload: unknown): unknown[] { return Array.isArray(payload) ? payload : payload === undefined ? [] : [payload]; } async function applySettingsPatch(patch: Partial): Promise { for (const [key, value] of Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>) { await setSetting(key, value); } if ( Object.prototype.hasOwnProperty.call(patch, 'proxyEnabled') || Object.prototype.hasOwnProperty.call(patch, 'proxyServer') || Object.prototype.hasOwnProperty.call(patch, 'proxyHttpServer') || Object.prototype.hasOwnProperty.call(patch, 'proxyHttpsServer') || Object.prototype.hasOwnProperty.call(patch, 'proxyAllServer') || Object.prototype.hasOwnProperty.call(patch, 'proxyBypassRules') ) { await applyProxySettings(await getAllSettings()); } if (Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup')) { await syncLaunchAtStartupSettingFromStore(); } } function normalizeProviderValidationPayload(payload: unknown): { providerId: string; apiKey: string; options?: { baseUrl?: string; apiProtocol?: string }; } { const args = payloadArray(payload); if (args.length > 1) { return { providerId: String(args[0] ?? ''), apiKey: String(args[1] ?? ''), options: args[2] as { baseUrl?: string; apiProtocol?: string } | undefined, }; } const record = (args[0] && typeof args[0] === 'object' ? args[0] : {}) as Record; return { providerId: String(record.providerId ?? record.accountId ?? ''), apiKey: String(record.apiKey ?? ''), options: record.options as { baseUrl?: string; apiProtocol?: string } | undefined, }; } async function handleUnifiedProvider(action: string, payload: unknown): Promise { const providerService = getProviderService(); const args = payloadArray(payload); if (action === 'list') return providerService._listProvidersWithKeyInfoInternal(); if (action === 'listVendors') return providerService.listVendors(); if (action === 'listAccounts') return providerService.listAccounts(); if (action === 'getDefault') return providerService._getDefaultProviderInternal(); if (action === 'get') return providerService._getProviderInternal(String(args[0] ?? '')); if (action === 'hasApiKey') return providerService._hasProviderApiKeyInternal(String(args[0] ?? '')); if (action === 'getApiKey') return providerService._getProviderApiKeyInternal(String(args[0] ?? '')); if (action === 'save') { const body = (args[0] && typeof args[0] === 'object' ? args[0] : {}) as { config?: ProviderConfig; apiKey?: string }; if (!body.config) throw new Error('Missing provider config'); await providerService._saveProviderInternal(body.config); if (body.apiKey !== undefined && body.apiKey.trim()) { await providerService._setProviderApiKeyInternal(body.config.id, body.apiKey.trim()); } return { success: true }; } if (action === 'delete') { await providerService._deleteProviderInternal(String(args[0] ?? '')); return { success: true }; } if (action === 'setApiKey') { await providerService._setProviderApiKeyInternal(String(args[0] ?? ''), String(args[1] ?? '')); return { success: true }; } if (action === 'updateWithKey') { const providerId = String(args[0] ?? ''); const updates = (args[1] ?? {}) as Partial; const apiKey = typeof args[2] === 'string' ? args[2] : undefined; const existing = await providerService._getProviderInternal(providerId); if (!existing) throw new Error('Provider not found'); const nextConfig = { ...existing, ...updates, updatedAt: new Date().toISOString() }; await providerService._saveProviderInternal(nextConfig); if (apiKey !== undefined) { if (apiKey.trim()) { await providerService._setProviderApiKeyInternal(providerId, apiKey.trim()); } else { await providerService._deleteProviderApiKeyInternal(providerId); } } return { success: true }; } if (action === 'deleteApiKey') { await providerService._deleteProviderApiKeyInternal(String(args[0] ?? '')); return { success: true }; } if (action === 'setDefault') { await providerService._setDefaultProviderInternal(String(args[0] ?? '')); return { success: true }; } if (action === 'validateKey') { const validation = normalizeProviderValidationPayload(payload); const provider = await providerService._getProviderInternal(validation.providerId); const providerType = provider?.type || validation.providerId; return validateApiKeyWithProvider(providerType, validation.apiKey, { baseUrl: validation.options?.baseUrl || provider?.baseUrl, apiProtocol: validation.options?.apiProtocol || provider?.apiProtocol, }); } if (action === 'createAccount') { const body = (args[0] && typeof args[0] === 'object' ? args[0] : {}) as { account?: ProviderAccount; apiKey?: string }; if (!body.account) throw new Error('Missing provider account'); return providerService.createAccount(body.account, body.apiKey); } throw new Error(`Unsupported provider action: ${action}`); } async function handleUnifiedRequest( request: UnifiedRequest, opencodeManager: OpencodeManager, ): Promise { const id = request.id; const module = request.module || ''; const action = request.action || ''; try { if (module === 'app') { if (action === 'version') return ok(id, app.getVersion()); if (action === 'name') return ok(id, app.getName()); if (action === 'platform') return ok(id, process.platform); return unsupported(id, module, action); } if (module === 'opencode') { if (action === 'status') return ok(id, opencodeManager.getStatus()); return unsupported(id, module, action); } if (module === 'settings') { if (action === 'getAll') return ok(id, await getAllSettings()); if (action === 'get') { const key = ((request.payload && typeof request.payload === 'object') ? (request.payload as { key?: keyof AppSettings }).key : undefined) as keyof AppSettings | undefined; return ok(id, key ? await getSetting(key) : undefined); } if (action === 'set') { const body = (request.payload && typeof request.payload === 'object' ? request.payload : {}) as { key?: keyof AppSettings; value?: AppSettings[keyof AppSettings] }; if (!body.key) throw new Error('Missing settings key'); await applySettingsPatch({ [body.key]: body.value } as Partial); return ok(id, { success: true }); } if (action === 'setMany') { await applySettingsPatch((request.payload ?? {}) as Partial); return ok(id, { success: true }); } if (action === 'reset') { await resetSettings(); return ok(id, { success: true, settings: await getAllSettings() }); } return unsupported(id, module, action); } if (module === 'provider') { return ok(id, await handleUnifiedProvider(action, request.payload)); } return unsupported(id, module, action); } catch (error) { return fail(id, 'INTERNAL_ERROR', error instanceof Error ? error.message : String(error), error); } } export function registerIpcHandlers( _legacyRuntimeManager: unknown, opencodeManager: OpencodeManager, _legacyMarketplaceService: unknown, mainWindow: BrowserWindow, ): void { registerHostApiProxyHandlers(); registerTranscriptExportHandler(mainWindow); ipcMain.handle('app:request', async (_event, request: UnifiedRequest) => ( handleUnifiedRequest(request, opencodeManager) )); ipcMain.handle('app:version', () => app.getVersion()); ipcMain.handle('app:name', () => app.getName()); ipcMain.handle('app:platform', () => process.platform); ipcMain.handle('app:getPath', (_event, name: Parameters[0]) => app.getPath(name)); ipcMain.handle('app:quit', () => app.quit()); ipcMain.handle('app:relaunch', () => { app.relaunch(); app.exit(0); }); ipcMain.handle('opencode:status', () => opencodeManager.getStatus()); ipcMain.handle('opencode:start', () => opencodeManager.start()); ipcMain.handle('opencode:stop', async () => { await opencodeManager.stop(); return opencodeManager.getStatus(); }); ipcMain.handle('opencode:restart', () => opencodeManager.restart()); ipcMain.handle('opencode:health', () => opencodeManager.checkHealth()); ipcMain.handle('settings:getAll', () => getAllSettings()); ipcMain.handle('settings:get', (_event, payload: { key?: keyof AppSettings } | keyof AppSettings) => { const key = typeof payload === 'string' ? payload : payload?.key; return key ? getSetting(key) : undefined; }); ipcMain.handle('settings:set', async (_event, payload: { key: keyof AppSettings; value: AppSettings[keyof AppSettings] }) => { await applySettingsPatch({ [payload.key]: payload.value } as Partial); return { success: true }; }); ipcMain.handle('settings:setMany', async (_event, patch: Partial) => { await applySettingsPatch(patch); return { success: true }; }); ipcMain.handle('settings:reset', async () => { await resetSettings(); return { success: true, settings: await getAllSettings() }; }); ipcMain.handle('shell:openExternal', (_event, url: string) => shell.openExternal(url)); ipcMain.handle('shell:showItemInFolder', (_event, path: string) => shell.showItemInFolder(path)); ipcMain.handle('shell:openPath', (_event, path: string) => shell.openPath(path)); ipcMain.handle('dialog:open', (_event, options: Electron.OpenDialogOptions) => dialog.showOpenDialog(mainWindow, options)); ipcMain.handle('dialog:save', (_event, options: Electron.SaveDialogOptions) => dialog.showSaveDialog(mainWindow, options)); ipcMain.handle('dialog:message', (_event, options: Electron.MessageBoxOptions) => dialog.showMessageBox(mainWindow, options)); ipcMain.handle('window:minimize', () => mainWindow.minimize()); ipcMain.handle('window:maximize', () => { if (mainWindow.isMaximized()) { mainWindow.unmaximize(); return false; } mainWindow.maximize(); return true; }); ipcMain.handle('window:close', () => mainWindow.close()); ipcMain.handle('window:isMaximized', () => mainWindow.isMaximized()); }