refactor: extract model settings IPC handlers

This commit is contained in:
mesalogo
2026-09-27 09:03:17 +08:00
parent f81f342cde
commit 4edf06b685
4 changed files with 476 additions and 352 deletions
@@ -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.
+45
View File
@@ -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,
+21 -352
View File
@@ -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<string>(['.json', '.jsonc'])
},
continue: {
filterName: 'Continue 配置',
filterExtensions: ['yaml', 'yml', 'json', 'jsonc'],
allowedExtensions: new Set<string>([
'.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<T>(input: {
previous: T
persistCandidate(): Promise<T>
activate(): Promise<void>
persistPrevious(previous: T): Promise<unknown>
}): Promise<T> {
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<T>(operation: Promise<T>): Promise<T>
drain(): Promise<void>
@@ -4955,292 +4884,32 @@ export function registerIpcHandlers(
}
)
registerHandler(
ipcChannels.runtimeSettingsGet,
(event): Promise<RuntimeSettings> => {
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<RuntimeSettings> => {
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<string | undefined> => {
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<AgentRuntimeDetection> => {
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<string | undefined> => {
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<void> => {
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)
+388
View File
@@ -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<string>(['.json', '.jsonc'])
},
continue: {
filterName: 'Continue 配置',
filterExtensions: ['yaml', 'yml', 'json', 'jsonc'],
allowedExtensions: new Set<string>([
'.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<T>(input: {
previous: T
persistCandidate(): Promise<T>
activate(): Promise<void>
persistPrevious(previous: T): Promise<unknown>
}): Promise<T> {
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<AgentRuntime, 'testConnection' | 'getStatus'>
selectedRuntimes?: Pick<SelectedRuntimeResolver, 'getNativeSnapshot' | 'testStatus'>
bundledRuntimePaths: BundledRuntimePaths
enqueueRuntimeSettingsUpdate: <T>(transaction: () => Promise<T>) => Promise<T>
onRuntimeSettingsChanged: () => Promise<void>
repairRuntimeSelections: (settings: RuntimeSettings) => void
resolveSnapshotExecutionSpace: (
projectId?: string
) => Promise<ReturnType<ExecutionSpaceResolver['resolveProject']>>
}
): void {
registerHandler(
ipcChannels.runtimeSettingsGet,
(event): Promise<RuntimeSettings> => {
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<RuntimeSettings> => {
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<string | undefined> => {
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<AgentRuntimeDetection> => {
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<string | undefined> => {
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<void> => {
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
}
)
}