From 4edf06b68576b971fe38247c0d233e8a9ed0a63f Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sun, 27 Sep 2026 09:03:17 +0800 Subject: [PATCH] refactor: extract model settings IPC handlers --- .../model-connections/technical-design.md | 22 + src/main/ipc.test.ts | 45 ++ src/main/ipc.ts | 373 +---------------- src/main/model-settings-ipc.ts | 388 ++++++++++++++++++ 4 files changed, 476 insertions(+), 352 deletions(-) create mode 100644 src/main/model-settings-ipc.ts diff --git a/docs/features/model-connections/technical-design.md b/docs/features/model-connections/technical-design.md index b9c9f887..c5783c48 100644 --- a/docs/features/model-connections/technical-design.md +++ b/docs/features/model-connections/technical-design.md @@ -125,3 +125,25 @@ Main 的 `toPublicSettings` 复用 `resolveDeepSeekHarnessModelProfile`,只在 `source: 'profile'` 描述来源分支,不代表凭据来源:仅环境密钥或旧环境变量可能仍影响该 GoodBuddy 连接,但不会被误报为完整管理员预置。连接解析不代替真实模型请求或可用性测试。 UI 的未保存兼容候选只作草稿预览,保存后继续以 Main 返回的投影为准。 + +## 10. Settings IPC Ownership + +[model-settings-ipc.ts](../../../src/main/model-settings-ipc.ts) registers the +11 model/runtime settings handlers: public settings, customization reads and +updates, native inventory, settings updates, workspace/file selection, runtime +detection, config actions, and model/runtime connection tests. It owns sender +checks, shared schema parsing, config-file metadata, and activation rollback. + +[ipc.ts](../../../src/main/ipc.ts) calls this registrar at the original position +and retains tracked registration, the shared settings-update queue, runtime +activation, conversation-selection repair, and project execution-space resolution. +These operations are injected as narrow callbacks. Runtime activation still +cancels affected expert-team requests; failed activation restores the previous +settings and reactivates them before the next queued update can run. Cleanup waits +for the tracked handler promises. Usage recording, runtime refresh subscriptions, +and runtime/Agent lifecycle ownership remain in the composition root. + +The integration tests in [ipc.test.ts](../../../src/main/ipc.test.ts) exercise the +production registrar through `registerIpcHandlers`, including registration order, +trusted senders, serialized updates, rollback failures, cleanup waiting for reload, +native inventory resolution, and connection/config actions. diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index e45becc5..4d814156 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -4945,6 +4945,35 @@ describe('registerIpcHandlers Runtime customization', () => { senderFrame: webContents.mainFrame } + const settingsChannels = [ + ipcChannels.runtimeSettingsGet, + ipcChannels.runtimeCustomizationGet, + ipcChannels.runtimeCustomizationUpdate, + ipcChannels.runtimeNativeSnapshot, + ipcChannels.runtimeSettingsUpdate, + ipcChannels.runtimeSettingsSelectWorkspace, + ipcChannels.runtimeSettingsDetect, + ipcChannels.runtimeSettingsSelectFile, + ipcChannels.runtimeSettingsOpenConfig, + ipcChannels.runtimeSettingsTestModel, + ipcChannels.runtimeSettingsTest + ] + const registeredChannels = [...electronMocks.handlers.keys()] + const settingsStart = registeredChannels.indexOf(settingsChannels[0]!) + expect(registeredChannels.slice(settingsStart - 1, settingsStart + 12)).toEqual([ + ipcChannels.agentCompactConversation, + ...settingsChannels, + ipcChannels.sshHostsGet + ]) + for (const channel of settingsChannels) { + await expect(Promise.resolve().then(() => + electronMocks.handlers.get(channel)?.({ ...event, sender: {} }, {}) + )).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') + } + expect(settingsStore.getRuntimeCustomization).not.toHaveBeenCalled() + expect(settingsStore.updateRuntimeCustomization).not.toHaveBeenCalled() + expect(selectedRuntimes.getNativeSnapshot).not.toHaveBeenCalled() + await expect( electronMocks.handlers.get( ipcChannels.runtimeCustomizationGet @@ -5005,6 +5034,22 @@ describe('registerIpcHandlers Runtime customization', () => { }) ) + await expect( + electronMocks.handlers.get(ipcChannels.runtimeNativeSnapshot)?.(event, { + provider: 'opencode' + }) + ).resolves.toEqual(snapshot) + expect(selectedRuntimes.getNativeSnapshot).toHaveBeenLastCalledWith( + { provider: 'opencode' }, + expect.objectContaining({ kind: 'local', rootPath: 'C:\\DefaultWorkspace' }) + ) + await expect( + electronMocks.handlers.get(ipcChannels.runtimeNativeSnapshot)?.(event, { + provider: 'opencode', projectId: 'invalid' + }) + ).rejects.toThrow() + expect(selectedRuntimes.getNativeSnapshot).toHaveBeenCalledTimes(2) + const compactInput = { requestId, conversationId, diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 5ee9fba6..e9ffd916 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,4 +1,5 @@ import { registerKnowledgeIpcHandlers } from './knowledge/knowledge-ipc' +import { registerModelSettingsIpcHandlers } from './model-settings-ipc' import { knowledgeReferenceKey, toKnowledgeReference } from '../shared/knowledge-reference' import { localInferenceService } from './local-inference-service' import { inferenceActionSchema, inferenceCancelSchema } from '../shared/local-inference-contracts' @@ -13,14 +14,12 @@ import { type IpcMainInvokeEvent } from 'electron' import { - mkdir, readFile, realpath, stat } from 'node:fs/promises' import { randomUUID } from 'node:crypto' -import { homedir } from 'node:os' -import { basename, extname, isAbsolute, join } from 'node:path' +import { basename, extname, join } from 'node:path' import { z } from 'zod' import { documentResourceInputSchema } from '../shared/document-result-contracts' import { maximumAttachmentsPerMessage } from '../shared/attachment-limits' @@ -51,21 +50,13 @@ import { conversationQueueUserInputSchema, defaultRuntimeSettings, isAgentRuntimeModelProtocol, - modelProfileIdSchema, pastedImageInputSchema, runtimeConversationCompactInputSchema, runtimeConversationCompactResultSchema, - runtimeConfigActionInputSchema, - runtimeCustomizationSettingsSchema, - runtimeFileSelectionKindSchema, - runtimeNativeSnapshotInputSchema, - runtimeNativeSnapshotSchema, - runtimeSettingsInputSchema, windowCaptureRequestSchema, workspaceDirectoryRequestSchema, workspaceFileRequestSchema, workspaceOpenPathRequestSchema, - type AgentRuntimeDetection, type AgentEvent, type AgentRequest, type AppInfo, @@ -73,8 +64,7 @@ import { type BrowserTabId, type ConversationQueueDispatch, type ConversationQueueUserInput, - type KnowledgeSearchReference, - type RuntimeSettings + type KnowledgeSearchReference } from '../shared/contracts' import { stripKnowledgeHighlightTags } from '../shared/knowledge-text' import type { KnowledgeRetrievalResponse } from '../shared/knowledge-contracts' @@ -172,7 +162,6 @@ import type { RuntimeGeneratedImageEvent, RuntimeModelUsageEvent } from './agent/runtime' -import { detectAgentRuntimes } from './agent/runtime-discovery' import { createModelProfileRuntime } from './agent/create-runtime' @@ -186,11 +175,7 @@ import { RemotePromptCancelledError, RemotePromptRecoveryUnavailableError } from './agent/acp-remote-runtime' -import { - bundledContinueVersion, - bundledDeepSeekHarnessVersion, - type BundledRuntimePaths -} from './agent/bundled-runtimes' +import type { BundledRuntimePaths } from './agent/bundled-runtimes' import type { SelectedRuntimeResolver } from './agent/selected-runtime-manager' import { type MagicNotesCapabilityAccess, @@ -335,23 +320,6 @@ import { const requestIdSchema = z.string().uuid() const BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS = 1_000 const DURABLE_AGENT_EVENT_FLUSH_INTERVAL_MS = 250 -const runtimeConfigFileMetadata = { - opencode: { - filterName: 'OpenCode 配置', - filterExtensions: ['json', 'jsonc'], - allowedExtensions: new Set(['.json', '.jsonc']) - }, - continue: { - filterName: 'Continue 配置', - filterExtensions: ['yaml', 'yml', 'json', 'jsonc'], - allowedExtensions: new Set([ - '.yaml', - '.yml', - '.json', - '.jsonc' - ]) - } -} as const const channelSettingsTestRequestSchema = z.discriminatedUnion('channel', [ z .object({ @@ -367,20 +335,6 @@ const channelSettingsTestRequestSchema = z.discriminatedUnion('channel', [ .strict() ]) -function getRuntimeConfigDirectory( - runtime: 'opencode' | 'continue' -): string { - if (runtime === 'continue') { - return join(homedir(), '.continue') - } - const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() - const configHome = - xdgConfigHome && isAbsolute(xdgConfigHome) - ? xdgConfigHome - : join(homedir(), '.config') - return join(configHome, 'opencode') -} - function isAgentRuntime(runtime: AgentRuntime): boolean { return ( runtime.runtimeId === 'opencode' || @@ -650,31 +604,6 @@ function stripRemoteSemanticProvenance( return publicEvent } -async function activateOrRollback(input: { - previous: T - persistCandidate(): Promise - activate(): Promise - persistPrevious(previous: T): Promise -}): Promise { - const saved = await input.persistCandidate() - try { - await input.activate() - return saved - } catch (activationError) { - try { - await input.persistPrevious(input.previous) - await input.activate() - } catch (rollbackError) { - throw new AggregateError( - [activationError, rollbackError], - 'Runtime 配置激活失败,且回滚未能完成', - { cause: rollbackError } - ) - } - throw activationError - } -} - function createPromiseTracker(): { track(operation: Promise): Promise drain(): Promise @@ -4955,292 +4884,32 @@ export function registerIpcHandlers( } ) - registerHandler( - ipcChannels.runtimeSettingsGet, - (event): Promise => { - assertTrustedSender(event, window) - return settingsStore.getPublicSettings() - } - ) - - registerHandler( - ipcChannels.runtimeCustomizationGet, - (event) => { - assertTrustedSender(event, window) - return settingsStore.getRuntimeCustomization() - } - ) - - registerHandler( - ipcChannels.runtimeCustomizationUpdate, - async (event, input: unknown) => { - assertTrustedSender(event, window) - const settings = - runtimeCustomizationSettingsSchema.parse(input) - return enqueueRuntimeSettingsUpdate(async () => { - const previous = - await settingsStore.getRuntimeCustomization() - return activateOrRollback({ - previous, - persistCandidate: async () => { - const saved = - await settingsStore.updateRuntimeCustomization(settings) - return saved - }, - activate: onRuntimeSettingsChanged, - persistPrevious: (previousSettings) => - settingsStore.updateRuntimeCustomization(previousSettings) - }) - }) - } - ) - - registerHandler( - ipcChannels.runtimeNativeSnapshot, - async (event, input: unknown) => { - assertTrustedSender(event, window) - if (!selectedRuntimes) { - throw new Error('Runtime 管理器不可用') - } - const request = runtimeNativeSnapshotInputSchema.parse(input) - const selection: AgentRuntimeSelection = { - provider: request.provider, - ...(request.profileId - ? { profileId: request.profileId } - : {}) - } - const project = request.projectId - ? assistantDatabase.getProject(request.projectId) + registerModelSettingsIpcHandlers(registerHandler, window, { + settingsStore, + runtime, + selectedRuntimes, + bundledRuntimePaths, + enqueueRuntimeSettingsUpdate, + onRuntimeSettingsChanged, + repairRuntimeSelections: (savedSettings) => { + channelSettingsStore?.reportRuntimeSelectionRepairs( + assistantDatabase.repairConversationRuntimeSelections(savedSettings) + ) + }, + resolveSnapshotExecutionSpace: async (projectId) => { + const project = projectId + ? assistantDatabase.getProject(projectId) : undefined if (project?.executionSpace?.kind === 'ssh') { await requireRemoteProjectsEnabled() } - const executionSpace = project + return project ? spaceResolver.resolveProject(project) : spaceResolver.resolveLocal( (await settingsStore.getResolvedSettings()).workspacePath ) - return runtimeNativeSnapshotSchema.parse( - await selectedRuntimes.getNativeSnapshot( - selection, - executionSpace - ) - ) } - ) - - registerHandler( - ipcChannels.runtimeSettingsUpdate, - async (event, input: unknown): Promise => { - assertTrustedSender(event, window) - const settings = runtimeSettingsInputSchema.parse(input) - return enqueueRuntimeSettingsUpdate(async () => { - let workspacePath: string - try { - workspacePath = await realpath(settings.workspacePath) - if (!(await stat(workspacePath)).isDirectory()) { - throw new Error('Not a directory') - } - } catch { - throw new Error('所选工作区不存在、不可访问或不是文件夹') - } - const rollback = await settingsStore.captureRollback() - const previousSettings = rollback.publicSettings - const savedSettings = await activateOrRollback({ - previous: previousSettings, - persistCandidate: async () => { - const saved = await settingsStore.update({ - ...settings, - workspacePath - }) - return saved - }, - activate: onRuntimeSettingsChanged, - persistPrevious: () => rollback.restore() - }) - channelSettingsStore?.reportRuntimeSelectionRepairs( - assistantDatabase.repairConversationRuntimeSelections( - savedSettings - ) - ) - return savedSettings - }) - } - ) - - registerHandler( - ipcChannels.runtimeSettingsSelectWorkspace, - async (event): Promise => { - assertTrustedSender(event, window) - const result = await dialog.showOpenDialog(window, { - properties: ['openDirectory', 'createDirectory'] - }) - return result.canceled ? undefined : result.filePaths[0] - } - ) - - registerHandler( - ipcChannels.runtimeSettingsDetect, - async (event): Promise => { - assertTrustedSender(event, window) - const settings = await settingsStore.getResolvedSettings() - return detectAgentRuntimes({ - opencodeBinaryPath: settings.opencodeBinaryPath, - continueBinaryPath: settings.continueBinaryPath, - bundledPaths: bundledRuntimePaths, - bundledVersions: { - continue: bundledContinueVersion, - deepseekHarness: bundledDeepSeekHarnessVersion - } - }) - } - ) - - registerHandler( - ipcChannels.runtimeSettingsSelectFile, - async (event, input: unknown): Promise => { - assertTrustedSender(event, window) - const kind = runtimeFileSelectionKindSchema.parse(input) - const binary = - kind === 'opencodeBinary' || - kind === 'continueBinary' - const configRuntime = - kind === 'opencodeConfig' - ? 'opencode' - : kind === 'continueConfig' - ? 'continue' - : undefined - const configMetadata = configRuntime - ? runtimeConfigFileMetadata[configRuntime] - : undefined - const filters = - binary && process.platform === 'win32' - ? [ - { - name: '可执行文件', - extensions: ['exe', 'cmd', 'bat', 'com'] - }, - { name: '所有文件', extensions: ['*'] } - ] - : configMetadata - ? [ - { - name: configMetadata.filterName, - extensions: [...configMetadata.filterExtensions] - } - ] - : undefined - const result = await dialog.showOpenDialog(window, { - properties: ['openFile'], - title: binary ? '选择可执行文件' : '选择配置文件', - ...(filters ? { filters } : {}) - }) - if (result.canceled || !result.filePaths[0]) { - return undefined - } - const selectedPath = await realpath(result.filePaths[0]) - if (!(await stat(selectedPath)).isFile()) { - throw new Error('所选路径不是普通文件') - } - return selectedPath - } - ) - - registerHandler( - ipcChannels.runtimeSettingsOpenConfig, - async (event, input: unknown): Promise => { - assertTrustedSender(event, window) - const request = runtimeConfigActionInputSchema.parse(input) - if (request.action === 'open-directory') { - const directory = getRuntimeConfigDirectory(request.runtime) - await mkdir(directory, { recursive: true, mode: 0o700 }) - const error = await shell.openPath(await realpath(directory)) - if (error) { - throw new Error('无法打开 Runtime 配置目录') - } - return - } - - const settings = await settingsStore.getPublicSettings() - const persisted = settings.configured ?? settings - const configuredPath = - request.runtime === 'opencode' - ? persisted.opencodeConfigPath - : persisted.continueConfigPath - if (!configuredPath) { - throw new Error('尚未选择 Runtime 自有配置文件') - } - const configPath = await realpath(configuredPath) - if (!(await stat(configPath)).isFile()) { - throw new Error('Runtime 配置路径不是普通文件') - } - if (request.action === 'show-file') { - shell.showItemInFolder(configPath) - return - } - if ( - !runtimeConfigFileMetadata[request.runtime].allowedExtensions.has( - extname(configPath).toLowerCase() - ) - ) { - throw new Error('Runtime 配置文件类型不支持直接打开') - } - const error = await shell.openPath(configPath) - if (error) { - throw new Error('无法打开 Runtime 配置文件') - } - } - ) - - registerHandler( - ipcChannels.runtimeSettingsTestModel, - async (event, input: unknown) => { - assertTrustedSender(event, window) - const profileId = modelProfileIdSchema.parse(input) - const settings = await settingsStore.getResolvedSettings() - const profile = settings.modelProfiles.find( - (candidate) => candidate.id === profileId - ) - if (!profile) { - throw new Error('所选模型连接不存在') - } - if (profile.authentication === 'api-key' && !profile.apiKey) { - throw new Error(`模型连接“${profile.name}”未配置 API Key`) - } - const modelRuntime = createModelProfileRuntime( - settings.workspacePath, - settings, - profile - ) - try { - const status = - (await modelRuntime.testConnection?.()) ?? - (await modelRuntime.getStatus()) - if (!status.available) { - throw new Error(status.detail) - } - return status - } finally { - await modelRuntime.dispose() - } - } - ) - - registerHandler( - ipcChannels.runtimeSettingsTest, - async (event, input: unknown) => { - assertTrustedSender(event, window) - const selection = agentRuntimeSelectionSchema.parse(input) - const status = selectedRuntimes - ? await selectedRuntimes.testStatus(selection) - : ((await runtime.testConnection?.()) ?? - (await runtime.getStatus())) - if (!status.available) { - throw new Error(status.detail) - } - return status - } - ) + }) registerHandler(ipcChannels.sshHostsGet, async (event) => { assertTrustedSender(event, window) diff --git a/src/main/model-settings-ipc.ts b/src/main/model-settings-ipc.ts new file mode 100644 index 00000000..f26710ab --- /dev/null +++ b/src/main/model-settings-ipc.ts @@ -0,0 +1,388 @@ +import { dialog, shell, type BrowserWindow, type ipcMain } from 'electron' +import { mkdir, realpath, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { extname, isAbsolute, join } from 'node:path' +import { + modelProfileIdSchema, + runtimeConfigActionInputSchema, + runtimeCustomizationSettingsSchema, + runtimeFileSelectionKindSchema, + runtimeNativeSnapshotInputSchema, + runtimeNativeSnapshotSchema, + runtimeSettingsInputSchema, + type AgentRuntimeDetection, + type RuntimeSettings +} from '../shared/contracts' +import { ipcChannels } from '../shared/ipc-channels' +import { + agentRuntimeSelectionSchema, + type AgentRuntimeSelection +} from '../shared/runtime-selection-contracts' +import { + bundledContinueVersion, + bundledDeepSeekHarnessVersion, + type BundledRuntimePaths +} from './agent/bundled-runtimes' +import { createModelProfileRuntime } from './agent/create-runtime' +import type { AgentRuntime } from './agent/runtime' +import { detectAgentRuntimes } from './agent/runtime-discovery' +import type { SelectedRuntimeResolver } from './agent/selected-runtime-manager' +import type { ExecutionSpaceResolver } from './execution-space' +import type { RuntimeSettingsStore } from './runtime-settings-store' +import { assertTrustedSender } from './trusted-ipc-sender' + +const runtimeConfigFileMetadata = { + opencode: { + filterName: 'OpenCode 配置', + filterExtensions: ['json', 'jsonc'], + allowedExtensions: new Set(['.json', '.jsonc']) + }, + continue: { + filterName: 'Continue 配置', + filterExtensions: ['yaml', 'yml', 'json', 'jsonc'], + allowedExtensions: new Set([ + '.yaml', + '.yml', + '.json', + '.jsonc' + ]) + } +} as const + +function getRuntimeConfigDirectory( + runtime: 'opencode' | 'continue' +): string { + if (runtime === 'continue') { + return join(homedir(), '.continue') + } + const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() + const configHome = + xdgConfigHome && isAbsolute(xdgConfigHome) + ? xdgConfigHome + : join(homedir(), '.config') + return join(configHome, 'opencode') +} + +async function activateOrRollback(input: { + previous: T + persistCandidate(): Promise + activate(): Promise + persistPrevious(previous: T): Promise +}): Promise { + const saved = await input.persistCandidate() + try { + await input.activate() + return saved + } catch (activationError) { + try { + await input.persistPrevious(input.previous) + await input.activate() + } catch (rollbackError) { + throw new AggregateError( + [activationError, rollbackError], + 'Runtime 配置激活失败,且回滚未能完成', + { cause: rollbackError } + ) + } + throw activationError + } +} + +export function registerModelSettingsIpcHandlers( + registerHandler: typeof ipcMain.handle, + window: BrowserWindow, + { + settingsStore, + runtime, + selectedRuntimes, + bundledRuntimePaths, + enqueueRuntimeSettingsUpdate, + onRuntimeSettingsChanged, + repairRuntimeSelections, + resolveSnapshotExecutionSpace + }: { + settingsStore: RuntimeSettingsStore + runtime: Pick + selectedRuntimes?: Pick + bundledRuntimePaths: BundledRuntimePaths + enqueueRuntimeSettingsUpdate: (transaction: () => Promise) => Promise + onRuntimeSettingsChanged: () => Promise + repairRuntimeSelections: (settings: RuntimeSettings) => void + resolveSnapshotExecutionSpace: ( + projectId?: string + ) => Promise> + } +): void { + registerHandler( + ipcChannels.runtimeSettingsGet, + (event): Promise => { + assertTrustedSender(event, window) + return settingsStore.getPublicSettings() + } + ) + + registerHandler( + ipcChannels.runtimeCustomizationGet, + (event) => { + assertTrustedSender(event, window) + return settingsStore.getRuntimeCustomization() + } + ) + + registerHandler( + ipcChannels.runtimeCustomizationUpdate, + async (event, input: unknown) => { + assertTrustedSender(event, window) + const settings = + runtimeCustomizationSettingsSchema.parse(input) + return enqueueRuntimeSettingsUpdate(async () => { + const previous = + await settingsStore.getRuntimeCustomization() + return activateOrRollback({ + previous, + persistCandidate: async () => { + const saved = + await settingsStore.updateRuntimeCustomization(settings) + return saved + }, + activate: onRuntimeSettingsChanged, + persistPrevious: (previousSettings) => + settingsStore.updateRuntimeCustomization(previousSettings) + }) + }) + } + ) + + registerHandler( + ipcChannels.runtimeNativeSnapshot, + async (event, input: unknown) => { + assertTrustedSender(event, window) + if (!selectedRuntimes) { + throw new Error('Runtime 管理器不可用') + } + const request = runtimeNativeSnapshotInputSchema.parse(input) + const selection: AgentRuntimeSelection = { + provider: request.provider, + ...(request.profileId + ? { profileId: request.profileId } + : {}) + } + const executionSpace = await resolveSnapshotExecutionSpace(request.projectId) + return runtimeNativeSnapshotSchema.parse( + await selectedRuntimes.getNativeSnapshot( + selection, + executionSpace + ) + ) + } + ) + + registerHandler( + ipcChannels.runtimeSettingsUpdate, + async (event, input: unknown): Promise => { + assertTrustedSender(event, window) + const settings = runtimeSettingsInputSchema.parse(input) + return enqueueRuntimeSettingsUpdate(async () => { + let workspacePath: string + try { + workspacePath = await realpath(settings.workspacePath) + if (!(await stat(workspacePath)).isDirectory()) { + throw new Error('Not a directory') + } + } catch { + throw new Error('所选工作区不存在、不可访问或不是文件夹') + } + const rollback = await settingsStore.captureRollback() + const previousSettings = rollback.publicSettings + const savedSettings = await activateOrRollback({ + previous: previousSettings, + persistCandidate: async () => { + const saved = await settingsStore.update({ + ...settings, + workspacePath + }) + return saved + }, + activate: onRuntimeSettingsChanged, + persistPrevious: () => rollback.restore() + }) + repairRuntimeSelections(savedSettings) + return savedSettings + }) + } + ) + + registerHandler( + ipcChannels.runtimeSettingsSelectWorkspace, + async (event): Promise => { + assertTrustedSender(event, window) + const result = await dialog.showOpenDialog(window, { + properties: ['openDirectory', 'createDirectory'] + }) + return result.canceled ? undefined : result.filePaths[0] + } + ) + + registerHandler( + ipcChannels.runtimeSettingsDetect, + async (event): Promise => { + assertTrustedSender(event, window) + const settings = await settingsStore.getResolvedSettings() + return detectAgentRuntimes({ + opencodeBinaryPath: settings.opencodeBinaryPath, + continueBinaryPath: settings.continueBinaryPath, + bundledPaths: bundledRuntimePaths, + bundledVersions: { + continue: bundledContinueVersion, + deepseekHarness: bundledDeepSeekHarnessVersion + } + }) + } + ) + + registerHandler( + ipcChannels.runtimeSettingsSelectFile, + async (event, input: unknown): Promise => { + assertTrustedSender(event, window) + const kind = runtimeFileSelectionKindSchema.parse(input) + const binary = + kind === 'opencodeBinary' || + kind === 'continueBinary' + const configRuntime = + kind === 'opencodeConfig' + ? 'opencode' + : kind === 'continueConfig' + ? 'continue' + : undefined + const configMetadata = configRuntime + ? runtimeConfigFileMetadata[configRuntime] + : undefined + const filters = + binary && process.platform === 'win32' + ? [ + { + name: '可执行文件', + extensions: ['exe', 'cmd', 'bat', 'com'] + }, + { name: '所有文件', extensions: ['*'] } + ] + : configMetadata + ? [ + { + name: configMetadata.filterName, + extensions: [...configMetadata.filterExtensions] + } + ] + : undefined + const result = await dialog.showOpenDialog(window, { + properties: ['openFile'], + title: binary ? '选择可执行文件' : '选择配置文件', + ...(filters ? { filters } : {}) + }) + if (result.canceled || !result.filePaths[0]) { + return undefined + } + const selectedPath = await realpath(result.filePaths[0]) + if (!(await stat(selectedPath)).isFile()) { + throw new Error('所选路径不是普通文件') + } + return selectedPath + } + ) + + registerHandler( + ipcChannels.runtimeSettingsOpenConfig, + async (event, input: unknown): Promise => { + assertTrustedSender(event, window) + const request = runtimeConfigActionInputSchema.parse(input) + if (request.action === 'open-directory') { + const directory = getRuntimeConfigDirectory(request.runtime) + await mkdir(directory, { recursive: true, mode: 0o700 }) + const error = await shell.openPath(await realpath(directory)) + if (error) { + throw new Error('无法打开 Runtime 配置目录') + } + return + } + + const settings = await settingsStore.getPublicSettings() + const persisted = settings.configured ?? settings + const configuredPath = + request.runtime === 'opencode' + ? persisted.opencodeConfigPath + : persisted.continueConfigPath + if (!configuredPath) { + throw new Error('尚未选择 Runtime 自有配置文件') + } + const configPath = await realpath(configuredPath) + if (!(await stat(configPath)).isFile()) { + throw new Error('Runtime 配置路径不是普通文件') + } + if (request.action === 'show-file') { + shell.showItemInFolder(configPath) + return + } + if ( + !runtimeConfigFileMetadata[request.runtime].allowedExtensions.has( + extname(configPath).toLowerCase() + ) + ) { + throw new Error('Runtime 配置文件类型不支持直接打开') + } + const error = await shell.openPath(configPath) + if (error) { + throw new Error('无法打开 Runtime 配置文件') + } + } + ) + + registerHandler( + ipcChannels.runtimeSettingsTestModel, + async (event, input: unknown) => { + assertTrustedSender(event, window) + const profileId = modelProfileIdSchema.parse(input) + const settings = await settingsStore.getResolvedSettings() + const profile = settings.modelProfiles.find( + (candidate) => candidate.id === profileId + ) + if (!profile) { + throw new Error('所选模型连接不存在') + } + if (profile.authentication === 'api-key' && !profile.apiKey) { + throw new Error(`模型连接“${profile.name}”未配置 API Key`) + } + const modelRuntime = createModelProfileRuntime( + settings.workspacePath, + settings, + profile + ) + try { + const status = + (await modelRuntime.testConnection?.()) ?? + (await modelRuntime.getStatus()) + if (!status.available) { + throw new Error(status.detail) + } + return status + } finally { + await modelRuntime.dispose() + } + } + ) + + registerHandler( + ipcChannels.runtimeSettingsTest, + async (event, input: unknown) => { + assertTrustedSender(event, window) + const selection = agentRuntimeSelectionSchema.parse(input) + const status = selectedRuntimes + ? await selectedRuntimes.testStatus(selection) + : ((await runtime.testConnection?.()) ?? + (await runtime.getStatus())) + if (!status.available) { + throw new Error(status.detail) + } + return status + } + ) +}