/** * 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'; import { isAdminSessionUnlocked, lockAdminSession, verifyAdminPassword, } from './admin-access'; import { createLearningCourseLibrary, LearningCourseLibraryError } from '../services/learning-course-library'; import { assertLearningCoursePackageRegistered, closeLearningPlayerServer, evictLearningCoursePackagesForAccount, getLearningPlayerServer, } from '../services/learning-player-server'; import { getWorksSquareAccountBinding, isCurrentWorksSquareAccountBinding, subscribeWorksSquareSession, type WorksSquareAccountBinding, } from '../services/works-square-session'; import { createLearningAgentClient, type LearningAgentRequest } from '../services/learning-agent-client'; import { createLearningSpeechClient, type LearningSpeechRequest } from '../services/learning-speech-client'; import { createLearningGenerationClient } from '../services/learning-generation-client'; import { createLearningRuntimeBridge } from '../services/learning-runtime-bridge'; import type { LearningGenerationUploadRequest, LearningRuntimeBridgeRequest } from '../../shared/learning'; import type { BackgroundLifecycleController, DesktopActivity } from './background-lifecycle'; import { collectPerformanceSnapshot } from './performance-diagnostics'; import type { HostApiContext } from '../api/context'; 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; }; }; const LEARNING_COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; const LEARNING_MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/; const LEARNING_SHA256_PATTERN = /^[0-9a-f]{64}$/; 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}`); } async function withLearningBinding( operation: ( binding: WorksSquareAccountBinding, assertCurrentAccount: () => void, ) => T | Promise, ): Promise { const binding = getWorksSquareAccountBinding(); if (!binding) { throw new LearningCourseLibraryError('LEARNING_AUTH_REQUIRED', '请先登录'); } const assertCurrentAccount = () => { if (!isCurrentWorksSquareAccountBinding(binding)) { throw new LearningCourseLibraryError('LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重试'); } }; assertCurrentAccount(); const result = await operation(binding, assertCurrentAccount); assertCurrentAccount(); return result; } function assertLearningCoursePackageActive( binding: WorksSquareAccountBinding, courseId: string, contentHash: string, ): void { try { assertLearningCoursePackageRegistered(binding.accountKey, courseId, contentHash); } catch { throw new LearningCourseLibraryError('LEARNING_COURSE_IDENTITY_INVALID', '课程身份无效'); } } function assertLearningCourseIdentity( requested: { courseId: string; contentHash: string; moduleId?: string | null; moduleContentHash?: string }, authoritative: { courseId: string; courseContentHash: string; moduleId: string | null; moduleContentHash: string }, ): void { if (requested.courseId !== authoritative.courseId || requested.contentHash !== authoritative.courseContentHash || (requested.moduleId !== undefined && requested.moduleId !== authoritative.moduleId) || (requested.moduleContentHash !== undefined && requested.moduleContentHash !== authoritative.moduleContentHash)) { throw new LearningCourseLibraryError('LEARNING_COURSE_IDENTITY_INVALID', '课程身份无效'); } } function learningIdentityError(): LearningCourseLibraryError { return new LearningCourseLibraryError('LEARNING_COURSE_IDENTITY_INVALID', '课程身份无效'); } function requireLearningAgentIdentity(request: unknown): { courseId: string; contentHash: string; moduleId?: string | null; moduleContentHash?: string; } { if (!request || typeof request !== 'object' || Array.isArray(request)) throw learningIdentityError(); const input = request as Record; if (typeof input.courseId !== 'string' || !LEARNING_COURSE_ID_PATTERN.test(input.courseId) || typeof input.contentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(input.contentHash)) { throw learningIdentityError(); } if (input.anchor === undefined) return { courseId: input.courseId, contentHash: input.contentHash }; if (!input.anchor || typeof input.anchor !== 'object' || Array.isArray(input.anchor)) throw learningIdentityError(); const anchor = input.anchor as Record; if (anchor.moduleId !== undefined && anchor.moduleId !== null && (typeof anchor.moduleId !== 'string' || !LEARNING_MODULE_ID_PATTERN.test(anchor.moduleId))) { throw learningIdentityError(); } if (anchor.moduleContentHash !== undefined && (typeof anchor.moduleContentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(anchor.moduleContentHash))) { throw learningIdentityError(); } return { courseId: input.courseId, contentHash: input.contentHash, ...(anchor.moduleId === undefined ? {} : { moduleId: anchor.moduleId as string | null }), ...(anchor.moduleContentHash === undefined ? {} : { moduleContentHash: anchor.moduleContentHash as string }), }; } function requireLearningRuntimeIdentity(request: unknown): { courseId: string; contentHash: string; moduleId: string | null; moduleContentHash: string; } { if (!request || typeof request !== 'object' || Array.isArray(request)) throw learningIdentityError(); const input = request as Record; const context = input.context; if (typeof input.courseId !== 'string' || !LEARNING_COURSE_ID_PATTERN.test(input.courseId) || typeof input.contentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(input.contentHash) || !context || typeof context !== 'object' || Array.isArray(context)) throw learningIdentityError(); const runtimeContext = context as Record; if ((runtimeContext.moduleId !== null && (typeof runtimeContext.moduleId !== 'string' || !LEARNING_MODULE_ID_PATTERN.test(runtimeContext.moduleId))) || typeof runtimeContext.moduleContentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(runtimeContext.moduleContentHash)) throw learningIdentityError(); return { courseId: input.courseId, contentHash: input.contentHash, moduleId: runtimeContext.moduleId as string | null, moduleContentHash: runtimeContext.moduleContentHash, }; } 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, lifecycle?: BackgroundLifecycleController, hostApiContext?: HostApiContext, ): void { registerHostApiProxyHandlers(hostApiContext); registerTranscriptExportHandler(mainWindow); const rendererLeases = new Set(); const releaseRendererLeases = (): void => { if (rendererLeases.size === 0) return; for (const id of rendererLeases) lifecycle?.releaseLease(id); rendererLeases.clear(); }; const rendererWebContents = mainWindow.webContents; if (rendererWebContents && typeof rendererWebContents.on === 'function') { rendererWebContents.on('render-process-gone', releaseRendererLeases); rendererWebContents.on('destroyed', releaseRendererLeases); } const learningCourseLibrary = createLearningCourseLibrary({ rootDirectory: app.getPath('userData') }); let learningAgentClient = createLearningAgentClient(); const learningSpeechClient = createLearningSpeechClient(); const learningGenerationClient = createLearningGenerationClient(); const learningRuntimeBridge = createLearningRuntimeBridge(); let learningPlayerClose = Promise.resolve(); let activeLearningBinding = getWorksSquareAccountBinding(); subscribeWorksSquareSession(() => { const nextBinding = getWorksSquareAccountBinding(); const bindingChanged = activeLearningBinding?.accountKey !== nextBinding?.accountKey || activeLearningBinding?.epoch !== nextBinding?.epoch; if (bindingChanged) { if (activeLearningBinding) { evictLearningCoursePackagesForAccount(activeLearningBinding.accountKey); } learningPlayerClose = closeLearningPlayerServer().catch(() => undefined); learningAgentClient = createLearningAgentClient(); } activeLearningBinding = nextBinding; }); ipcMain.handle('learning:startGeneration', (_event, request: LearningGenerationUploadRequest) => ( withLearningBinding((_binding, assertCurrentAccount) => ( learningGenerationClient.start(request, assertCurrentAccount) )) )); ipcMain.handle('learning:download', (_event, courseId: string) => ( withLearningBinding(() => learningCourseLibrary.download(courseId)) )); ipcMain.handle('learning:listInstalled', () => ( withLearningBinding(() => learningCourseLibrary.listInstalled()) )); ipcMain.handle('learning:readClassroom', (_event, courseId: string, moduleId?: string) => ( withLearningBinding(() => learningCourseLibrary.readClassroom(courseId, moduleId)) )); ipcMain.handle('learning:playerUrl', () => withLearningBinding(async (binding, assertCurrentAccount) => { await learningPlayerClose; assertCurrentAccount(); return (await getLearningPlayerServer(binding.accountKey)).url; })); ipcMain.handle('learning:closePlayer', () => closeLearningPlayerServer()); ipcMain.handle('learning:agentAsk', (_event, request: unknown) => withLearningBinding( async (binding, assertCurrentAccount) => { const identity = requireLearningAgentIdentity(request); const agentRequest = request as LearningAgentRequest; assertLearningCoursePackageActive(binding, identity.courseId, identity.contentHash); const classroom = await learningCourseLibrary.resolveClassroom(identity.courseId, identity.moduleId ?? undefined); assertCurrentAccount(); assertLearningCourseIdentity({ courseId: identity.courseId, contentHash: identity.contentHash, moduleId: identity.moduleId, moduleContentHash: identity.moduleContentHash, }, classroom); return learningAgentClient.ask({ ...agentRequest, contentHash: classroom.courseContentHash, anchor: { ...agentRequest.anchor, moduleId: classroom.moduleId, moduleContentHash: classroom.moduleContentHash, }, }, assertCurrentAccount); }, )); ipcMain.handle('learning:agentReset', (_event, courseId?: string, contentHash?: string) => withLearningBinding( async (binding, assertCurrentAccount) => { if (courseId === undefined && contentHash === undefined) { return learningAgentClient.reset(undefined, undefined, assertCurrentAccount); } if (typeof courseId !== 'string' || !LEARNING_COURSE_ID_PATTERN.test(courseId) || typeof contentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(contentHash)) { throw learningIdentityError(); } assertLearningCoursePackageActive(binding, courseId, contentHash); const classroom = await learningCourseLibrary.resolveClassroom(courseId); assertCurrentAccount(); assertLearningCourseIdentity({ courseId, contentHash, }, classroom); return learningAgentClient.reset(courseId, classroom.courseContentHash, assertCurrentAccount); }, )); ipcMain.handle('learning:transcribe', (_event, request: LearningSpeechRequest) => ( withLearningBinding((_binding, assertCurrentAccount) => ( learningSpeechClient.transcribe(request, assertCurrentAccount) )) )); ipcMain.handle('learning:runtimeRequest', (event, request: unknown) => withLearningBinding( async (binding, assertCurrentAccount) => { const identity = requireLearningRuntimeIdentity(request); const runtimeRequest = request as LearningRuntimeBridgeRequest; assertLearningCoursePackageActive(binding, identity.courseId, identity.contentHash); const classroom = await learningCourseLibrary.resolveClassroom( identity.courseId, identity.moduleId ?? undefined, ); assertCurrentAccount(); assertLearningCourseIdentity({ courseId: identity.courseId, contentHash: identity.contentHash, moduleId: identity.moduleId, moduleContentHash: identity.moduleContentHash, }, classroom); await learningRuntimeBridge.request({ ...runtimeRequest, contentHash: classroom.courseContentHash, context: { ...runtimeRequest.context, moduleId: classroom.moduleId, moduleContentHash: classroom.moduleContentHash, }, }, (payload) => { if (isCurrentWorksSquareAccountBinding(binding) && !event.sender.isDestroyed()) { event.sender.send('learning:runtime-event', payload); } }, assertCurrentAccount); }, )); 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('app:performance', () => collectPerformanceSnapshot(lifecycle)); 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('lifecycle:activity', (_event, activity: DesktopActivity) => { const nextActivity = activity && typeof activity === 'object' ? activity : { visible: true, module: null }; lifecycle?.setActivity(nextActivity); if ((!nextActivity.visible || nextActivity.module !== 'painting') && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) { mainWindow.webContents.send('lifecycle:pause'); } return lifecycle?.getActivity() ?? nextActivity; }); ipcMain.handle('lifecycle:lease', (_event, input: { id: string; kind: string; active: boolean }) => { if (!lifecycle) return { count: 0 }; const id = typeof input?.id === 'string' ? input.id.trim() : ''; if (!id) return { count: lifecycle.getLeaseCount() }; if (input?.active) { lifecycle.acquireLease({ id, kind: typeof input.kind === 'string' && input.kind.trim() ? input.kind.trim() : 'unknown', }); rendererLeases.add(id); } else { lifecycle.releaseLease(id); rendererLeases.delete(id); } return { count: lifecycle.getLeaseCount() }; }); 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('admin:verifyPassword', (_event, password: unknown) => ({ success: verifyAdminPassword(password), })); ipcMain.handle('admin:isUnlocked', () => isAdminSessionUnlocked()); ipcMain.handle('admin:lock', () => { lockAdminSession(); return { success: true }; }); 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()); }