diff --git a/packages/app/package.json b/packages/app/package.json index b572e7308b79..c5c2efacf18e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./desktop-config": "./src/desktop-config.ts", "./desktop-menu": "./src/desktop-menu.ts", "./updater": "./src/updater.ts", "./wsl/types": "./src/wsl/types.ts", diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index dce3a4049996..07254251715e 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -5,6 +5,7 @@ import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import { Persist, persisted } from "@/utils/persist" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "./server-sync" +import { usePlatform } from "./platform" import { useParams } from "@solidjs/router" import { decode64 } from "@/utils/base64" import { @@ -49,8 +50,12 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple gate: false, init: () => { const params = useParams() + const platform = usePlatform() const serverSDK = useServerSDK() const serverSync = useServerSync() + const autoApprove = createMemo( + () => platform.platform === "desktop" && platform.desktopConfig?.()?.permissions?.autoApprove === true, + ) const permissionsEnabled = createMemo(() => { const directory = decode64(params.dir) @@ -142,15 +147,18 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple } function isAutoAccepting(sessionID: string, directory?: string) { + if (autoApprove()) return true const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : [] return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory) } function isAutoAcceptingDirectory(directory: string) { + if (autoApprove()) return true return isDirectoryAutoAccepting(store.autoAccept, directory) } function shouldAutoRespond(permission: PermissionRequest, directory?: string) { + if (autoApprove()) return true const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : [] return autoRespondsPermission(store.autoAccept, session, permission, directory) } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index de12e8c3f66f..d11bf0420450 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -5,6 +5,7 @@ import type { DesktopMenuAction } from "../desktop-menu" import { ServerConnection } from "./server" import type { WslServersPlatform } from "../wsl/types" import type { UpdaterPlatform } from "../updater" +import type { DesktopConfig } from "../desktop-config" type PickerPaths = string | string[] | null type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } @@ -111,6 +112,9 @@ type PlatformBase = { /** Record a fatal renderer error in platform logs (desktop only) */ recordFatalRendererError?(error: FatalRendererErrorLog): Promise + + /** Desktop-only machine config loaded from ~/.config/opencode/desktop.json */ + desktopConfig?: Accessor } export type Platform = PlatformBase & diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 21c845d0d1ea..ba317f72b14d 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -2,6 +2,7 @@ import { createStore, reconcile } from "solid-js/store" import { createEffect, createMemo } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { persisted } from "@/utils/persist" +import { usePlatform } from "./platform" export interface NotificationSettings { agent: boolean @@ -148,19 +149,42 @@ function withFallback(read: () => T | undefined, fallback: T) { return createMemo(() => read() ?? fallback) } +function desktopFallback(read: () => T | undefined, config: () => T | undefined, fallback: T) { + return createMemo(() => config() ?? read() ?? fallback) +} + export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ name: "Settings", gate: false, init: () => { + const platform = usePlatform() + const desktopConfig = () => (platform.platform === "desktop" ? platform.desktopConfig?.() : undefined) const [store, setStore, _, ready] = persisted("settings.v3", createStore(defaultSettings)) - const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree) - const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch) - const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus) - const showCustomAgents = withFallback( + const showFileTree = desktopFallback( + () => store.general?.showFileTree, + () => desktopConfig()?.general?.showFileTree, + defaultSettings.general.showFileTree, + ) + const showSearch = desktopFallback( + () => store.general?.showSearch, + () => desktopConfig()?.general?.showSearch, + defaultSettings.general.showSearch, + ) + const showStatus = desktopFallback( + () => store.general?.showStatus, + () => desktopConfig()?.general?.showStatus, + defaultSettings.general.showStatus, + ) + const showCustomAgents = desktopFallback( () => store.general?.showCustomAgents, + () => desktopConfig()?.general?.showCustomAgents, defaultSettings.general.showCustomAgents, ) - const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault) + const newLayoutDesigns = desktopFallback( + () => store.general?.newLayoutDesigns, + () => desktopConfig()?.general?.newLayoutDesigns, + newLayoutDesignsDefault, + ) const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference()) createEffect(() => { @@ -181,16 +205,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont return store }, general: { - autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave), + autoSave: desktopFallback( + () => store.general?.autoSave, + () => desktopConfig()?.general?.autoSave, + defaultSettings.general.autoSave, + ), setAutoSave(value: boolean) { setStore("general", "autoSave", value) }, - releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes), + releaseNotes: desktopFallback( + () => store.general?.releaseNotes, + () => desktopConfig()?.general?.releaseNotes, + defaultSettings.general.releaseNotes, + ), setReleaseNotes(value: boolean) { setStore("general", "releaseNotes", value) }, - followup: withFallback( + followup: desktopFallback( () => (store.general?.followup === "queue" ? "steer" : store.general?.followup), + () => + desktopConfig()?.general?.followup === "queue" ? "steer" : desktopConfig()?.general?.followup, defaultSettings.general.followup, ), setFollowup(value: "queue" | "steer") { @@ -200,7 +234,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setShowFileTree(value: boolean) { setStore("general", "showFileTree", value) }, - showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation), + showNavigation: desktopFallback( + () => store.general?.showNavigation, + () => desktopConfig()?.general?.showNavigation, + defaultSettings.general.showNavigation, + ), setShowNavigation(value: boolean) { setStore("general", "showNavigation", value) }, @@ -212,33 +250,41 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setShowStatus(value: boolean) { setStore("general", "showStatus", value) }, - showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal), + showTerminal: desktopFallback( + () => store.general?.showTerminal, + () => desktopConfig()?.general?.showTerminal, + defaultSettings.general.showTerminal, + ), setShowTerminal(value: boolean) { setStore("general", "showTerminal", value) }, - showReasoningSummaries: withFallback( + showReasoningSummaries: desktopFallback( () => store.general?.showReasoningSummaries, + () => desktopConfig()?.general?.showReasoningSummaries, defaultSettings.general.showReasoningSummaries, ), setShowReasoningSummaries(value: boolean) { setStore("general", "showReasoningSummaries", value) }, - shellToolPartsExpanded: withFallback( + shellToolPartsExpanded: desktopFallback( () => store.general?.shellToolPartsExpanded, + () => desktopConfig()?.general?.shellToolPartsExpanded, defaultSettings.general.shellToolPartsExpanded, ), setShellToolPartsExpanded(value: boolean) { setStore("general", "shellToolPartsExpanded", value) }, - editToolPartsExpanded: withFallback( + editToolPartsExpanded: desktopFallback( () => store.general?.editToolPartsExpanded, + () => desktopConfig()?.general?.editToolPartsExpanded, defaultSettings.general.editToolPartsExpanded, ), setEditToolPartsExpanded(value: boolean) { setStore("general", "editToolPartsExpanded", value) }, - showSessionProgressBar: withFallback( + showSessionProgressBar: desktopFallback( () => store.general?.showSessionProgressBar, + () => desktopConfig()?.general?.showSessionProgressBar, defaultSettings.general.showSessionProgressBar, ), setShowSessionProgressBar(value: boolean) { @@ -295,7 +341,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont }, }, permissions: { - autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove), + autoApprove: desktopFallback( + () => store.permissions?.autoApprove, + () => desktopConfig()?.permissions?.autoApprove, + defaultSettings.permissions.autoApprove, + ), setAutoApprove(value: boolean) { setStore("permissions", "autoApprove", value) }, @@ -315,30 +365,51 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont }, }, sounds: { - agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled), + agentEnabled: desktopFallback( + () => store.sounds?.agentEnabled, + () => desktopConfig()?.sounds?.agentEnabled, + defaultSettings.sounds.agentEnabled, + ), setAgentEnabled(value: boolean) { setStore("sounds", "agentEnabled", value) }, - agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent), + agent: desktopFallback( + () => store.sounds?.agent, + () => desktopConfig()?.sounds?.agent, + defaultSettings.sounds.agent, + ), setAgent(value: string) { setStore("sounds", "agent", value) }, - permissionsEnabled: withFallback( + permissionsEnabled: desktopFallback( () => store.sounds?.permissionsEnabled, + () => desktopConfig()?.sounds?.permissionsEnabled, defaultSettings.sounds.permissionsEnabled, ), setPermissionsEnabled(value: boolean) { setStore("sounds", "permissionsEnabled", value) }, - permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions), + permissions: desktopFallback( + () => store.sounds?.permissions, + () => desktopConfig()?.sounds?.permissions, + defaultSettings.sounds.permissions, + ), setPermissions(value: string) { setStore("sounds", "permissions", value) }, - errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled), + errorsEnabled: desktopFallback( + () => store.sounds?.errorsEnabled, + () => desktopConfig()?.sounds?.errorsEnabled, + defaultSettings.sounds.errorsEnabled, + ), setErrorsEnabled(value: boolean) { setStore("sounds", "errorsEnabled", value) }, - errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors), + errors: desktopFallback( + () => store.sounds?.errors, + () => desktopConfig()?.sounds?.errors, + defaultSettings.sounds.errors, + ), setErrors(value: string) { setStore("sounds", "errors", value) }, diff --git a/packages/app/src/desktop-config.test.ts b/packages/app/src/desktop-config.test.ts new file mode 100644 index 000000000000..186d8457293f --- /dev/null +++ b/packages/app/src/desktop-config.test.ts @@ -0,0 +1,119 @@ +import { expect, test } from "bun:test" +import { decodeDesktopConfig, decodeDesktopConfigJson } from "./desktop-config" + +test("decodes supported desktop config values", () => { + expect( + decodeDesktopConfig({ + general: { + autoSave: false, + releaseNotes: false, + followup: "steer", + showFileTree: true, + showNavigation: true, + showSearch: true, + showStatus: true, + showTerminal: true, + showReasoningSummaries: true, + shellToolPartsExpanded: true, + editToolPartsExpanded: true, + showSessionProgressBar: false, + showCustomAgents: true, + newLayoutDesigns: false, + }, + permissions: { + autoApprove: true, + }, + sounds: { + agentEnabled: false, + agent: "glass", + permissionsEnabled: true, + permissions: "ping", + errorsEnabled: false, + errors: "alert", + }, + }), + ).toEqual({ + general: { + autoSave: false, + releaseNotes: false, + followup: "steer", + showFileTree: true, + showNavigation: true, + showSearch: true, + showStatus: true, + showTerminal: true, + showReasoningSummaries: true, + shellToolPartsExpanded: true, + editToolPartsExpanded: true, + showSessionProgressBar: false, + showCustomAgents: true, + newLayoutDesigns: false, + }, + permissions: { + autoApprove: true, + }, + sounds: { + agentEnabled: false, + agent: "glass", + permissionsEnabled: true, + permissions: "ping", + errorsEnabled: false, + errors: "alert", + }, + }) +}) + +test("ignores unknown keys and wrong primitive types", () => { + expect( + decodeDesktopConfig({ + general: { + autoSave: "no", + releaseNotes: false, + followup: "invalid", + showFileTree: true, + showSearch: 1, + extra: true, + }, + permissions: { + autoApprove: "yes", + extra: true, + }, + sounds: { + agentEnabled: 1, + agent: "glass", + permissionsEnabled: true, + permissions: false, + errorsEnabled: false, + errors: "alert", + extra: "ignored", + }, + extra: { + value: true, + }, + }), + ).toEqual({ + general: { + releaseNotes: false, + showFileTree: true, + }, + permissions: {}, + sounds: { + agent: "glass", + permissionsEnabled: true, + errorsEnabled: false, + errors: "alert", + }, + }) +}) + +test("decodes desktop config from JSON strings", () => { + expect(decodeDesktopConfigJson(`{"permissions":{"autoApprove":true}}`)).toEqual({ + permissions: { + autoApprove: true, + }, + }) +}) + +test("rejects malformed desktop config JSON", () => { + expect(decodeDesktopConfigJson("{")).toBeUndefined() +}) diff --git a/packages/app/src/desktop-config.ts b/packages/app/src/desktop-config.ts new file mode 100644 index 000000000000..e06fdd8f0c9c --- /dev/null +++ b/packages/app/src/desktop-config.ts @@ -0,0 +1,104 @@ +import { Option, Schema } from "effect" + +const FollowupSchema = Schema.Union([Schema.Literal("queue"), Schema.Literal("steer")]) + +const GeneralSchema = Schema.Struct({ + autoSave: Schema.optional(Schema.Boolean), + releaseNotes: Schema.optional(Schema.Boolean), + followup: Schema.optional(FollowupSchema), + showFileTree: Schema.optional(Schema.Boolean), + showNavigation: Schema.optional(Schema.Boolean), + showSearch: Schema.optional(Schema.Boolean), + showStatus: Schema.optional(Schema.Boolean), + showTerminal: Schema.optional(Schema.Boolean), + showReasoningSummaries: Schema.optional(Schema.Boolean), + shellToolPartsExpanded: Schema.optional(Schema.Boolean), + editToolPartsExpanded: Schema.optional(Schema.Boolean), + showSessionProgressBar: Schema.optional(Schema.Boolean), + showCustomAgents: Schema.optional(Schema.Boolean), + newLayoutDesigns: Schema.optional(Schema.Boolean), +}) + +const PermissionsSchema = Schema.Struct({ + autoApprove: Schema.optional(Schema.Boolean), +}) + +const SoundsSchema = Schema.Struct({ + agentEnabled: Schema.optional(Schema.Boolean), + agent: Schema.optional(Schema.String), + permissionsEnabled: Schema.optional(Schema.Boolean), + permissions: Schema.optional(Schema.String), + errorsEnabled: Schema.optional(Schema.Boolean), + errors: Schema.optional(Schema.String), +}) + +export const DesktopConfigSchema = Schema.Struct({ + general: Schema.optional(GeneralSchema), + permissions: Schema.optional(PermissionsSchema), + sounds: Schema.optional(SoundsSchema), +}) + +export type DesktopConfig = typeof DesktopConfigSchema.Type + +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown)) +const decodeBoolean = Schema.decodeUnknownOption(Schema.Boolean) +const decodeString = Schema.decodeUnknownOption(Schema.String) +const decodeFollowup = Schema.decodeUnknownOption(FollowupSchema) + +export function decodeDesktopConfigJson(value: string) { + const parsed = Option.getOrUndefined(decodeJson(value)) + if (parsed === undefined) return + return decodeDesktopConfig(parsed) +} + +export function decodeDesktopConfig(value: unknown): DesktopConfig | undefined { + const data = Option.getOrUndefined(decodeRecord(value)) + if (!data) return + + const general = Option.getOrUndefined(decodeRecord(data.general)) + const permissions = Option.getOrUndefined(decodeRecord(data.permissions)) + const sounds = Option.getOrUndefined(decodeRecord(data.sounds)) + + return stripUndefined({ + general: general + ? stripUndefined({ + autoSave: Option.getOrUndefined(decodeBoolean(general.autoSave)), + releaseNotes: Option.getOrUndefined(decodeBoolean(general.releaseNotes)), + followup: Option.getOrUndefined(decodeFollowup(general.followup)), + showFileTree: Option.getOrUndefined(decodeBoolean(general.showFileTree)), + showNavigation: Option.getOrUndefined(decodeBoolean(general.showNavigation)), + showSearch: Option.getOrUndefined(decodeBoolean(general.showSearch)), + showStatus: Option.getOrUndefined(decodeBoolean(general.showStatus)), + showTerminal: Option.getOrUndefined(decodeBoolean(general.showTerminal)), + showReasoningSummaries: Option.getOrUndefined(decodeBoolean(general.showReasoningSummaries)), + shellToolPartsExpanded: Option.getOrUndefined(decodeBoolean(general.shellToolPartsExpanded)), + editToolPartsExpanded: Option.getOrUndefined(decodeBoolean(general.editToolPartsExpanded)), + showSessionProgressBar: Option.getOrUndefined(decodeBoolean(general.showSessionProgressBar)), + showCustomAgents: Option.getOrUndefined(decodeBoolean(general.showCustomAgents)), + newLayoutDesigns: Option.getOrUndefined(decodeBoolean(general.newLayoutDesigns)), + }) + : undefined, + permissions: permissions + ? stripUndefined({ + autoApprove: Option.getOrUndefined(decodeBoolean(permissions.autoApprove)), + }) + : undefined, + sounds: sounds + ? stripUndefined({ + agentEnabled: Option.getOrUndefined(decodeBoolean(sounds.agentEnabled)), + agent: Option.getOrUndefined(decodeString(sounds.agent)), + permissionsEnabled: Option.getOrUndefined(decodeBoolean(sounds.permissionsEnabled)), + permissions: Option.getOrUndefined(decodeString(sounds.permissions)), + errorsEnabled: Option.getOrUndefined(decodeBoolean(sounds.errorsEnabled)), + errors: Option.getOrUndefined(decodeString(sounds.errors)), + }) + : undefined, + }) +} + +function stripUndefined>(value: T) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as { + [K in keyof T as undefined extends T[K] ? K : K]: Exclude + } +} diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 2bc9262fde63..3da384c1af10 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -3,6 +3,7 @@ export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from export { useCommand } from "./context/command" export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" export { useWslServers } from "./wsl/context" +export { type DesktopConfig } from "./desktop-config" export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform" export { type UpdaterPlatform, type UpdaterState } from "./updater" export { diff --git a/packages/desktop/src/main/desktop-config.test.ts b/packages/desktop/src/main/desktop-config.test.ts new file mode 100644 index 000000000000..1ed82e8d3247 --- /dev/null +++ b/packages/desktop/src/main/desktop-config.test.ts @@ -0,0 +1,60 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, expect, test } from "bun:test" +import { readDesktopConfig } from "./desktop-config" + +const previousXdgConfigHome = process.env.XDG_CONFIG_HOME + +afterEach(() => { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome +}) + +test("reads supported desktop config values", async () => { + process.env.XDG_CONFIG_HOME = await mkdtemp(join(tmpdir(), "opencode-desktop-config-")) + await mkdir(join(process.env.XDG_CONFIG_HOME, "opencode")) + await writeFile( + join(process.env.XDG_CONFIG_HOME, "opencode", "desktop.json"), + JSON.stringify({ + permissions: { + autoApprove: true, + unsupported: true, + }, + sounds: { + agentEnabled: false, + agent: "glass", + permissionsEnabled: true, + permissions: "ping", + errorsEnabled: false, + errors: "alert", + }, + }), + ) + + expect(await readDesktopConfig()).toEqual({ + permissions: { + autoApprove: true, + }, + sounds: { + agentEnabled: false, + agent: "glass", + permissionsEnabled: true, + permissions: "ping", + errorsEnabled: false, + errors: "alert", + }, + }) + + await rm(process.env.XDG_CONFIG_HOME, { recursive: true, force: true }) +}) + +test("ignores missing or malformed desktop config", async () => { + process.env.XDG_CONFIG_HOME = await mkdtemp(join(tmpdir(), "opencode-desktop-config-")) + expect(await readDesktopConfig()).toBeUndefined() + + await mkdir(join(process.env.XDG_CONFIG_HOME, "opencode")) + await writeFile(join(process.env.XDG_CONFIG_HOME, "opencode", "desktop.json"), "{") + expect(await readDesktopConfig()).toBeUndefined() + + await rm(process.env.XDG_CONFIG_HOME, { recursive: true, force: true }) +}) diff --git a/packages/desktop/src/main/desktop-config.ts b/packages/desktop/src/main/desktop-config.ts new file mode 100644 index 000000000000..819844e8d91e --- /dev/null +++ b/packages/desktop/src/main/desktop-config.ts @@ -0,0 +1,59 @@ +import { readFile } from "node:fs/promises" +import { homedir } from "node:os" +import { join } from "node:path" +import { decodeDesktopConfigJson } from "@opencode-ai/app/desktop-config" + +type Logger = { + log: (message: string, data?: Record) => void + warn: (message: string, data?: Record) => void +} + +function configDir() { + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "opencode") + if (process.platform === "win32" && process.env.APPDATA) return join(process.env.APPDATA, "opencode") + return join(homedir(), ".config", "opencode") +} + +function configPath() { + return join(configDir(), "desktop.json") +} + +export async function readDesktopConfig(logger?: Logger) { + const path = configPath() + logger?.log("desktop config: reading desktop.json", { + path, + xdgConfigHome: process.env.XDG_CONFIG_HOME, + appData: process.env.APPDATA, + }) + + const text = await readFile(path, "utf8").catch((error) => { + const data = { path, ...errorDetails(error) } + if (isNotFound(error)) { + logger?.log("desktop config: desktop.json not found", data) + return + } + logger?.warn("desktop config: failed to read desktop.json", data) + }) + if (text === undefined) return + + const config = decodeDesktopConfigJson(text) + if (!config) { + logger?.warn("desktop config: failed to parse or decode desktop.json", { path }) + return + } + logger?.log("desktop config: loaded desktop.json", { path, config }) + return config +} + +function isNotFound(error: unknown) { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT" +} + +function errorDetails(error: unknown) { + if (!(error instanceof Error)) return { error: String(error) } + return { + name: error.name, + message: error.message, + code: typeof error === "object" && "code" in error ? error.code : undefined, + } +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 383e4d994232..a547f0dd67a8 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -38,6 +38,7 @@ import { createWslServersController } from "./wsl/servers" import { registerWslIpcHandlers } from "./wsl/ipc" import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" +import { readDesktopConfig } from "./desktop-config" const APP_NAMES: Record = { dev: "OpenCode Dev", @@ -173,6 +174,7 @@ const main = Effect.gen(function* () { packaged: app.isPackaged, onboardingTest: Boolean(onboardingTestRoot), }) + void readDesktopConfig(logger) ensureLoopbackNoProxy() useEnvProxy() diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 4aaa64d250d1..22e25487794e 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -12,6 +12,8 @@ import { getStore } from "./store" import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows" import type { UpdaterController } from "./updater-controller" import { createUpdaterSubscriptions } from "./updater-subscriptions" +import { readDesktopConfig } from "./desktop-config" +import { getLogger } from "./logging" const pickerFilters = (ext?: string[]) => { if (!ext || ext.length === 0) return undefined @@ -51,6 +53,7 @@ export function registerIpcHandlers(deps: Deps) { deps.setDefaultServerUrl(url), ) ipcMain.handle("get-display-backend", () => deps.getDisplayBackend()) + ipcMain.handle("get-desktop-config", () => readDesktopConfig(getLogger())) ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => deps.setDisplayBackend(backend), ) diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index a5cb1632eb90..542c9749b8b7 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -61,6 +61,7 @@ const api: ElectronAPI = { getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"), setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url), getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"), + getDesktopConfig: () => ipcRenderer.invoke("get-desktop-config"), setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend), parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown), checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 6bb2e029ca2f..1ff572aa4d86 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -1,4 +1,5 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" +import type { DesktopConfig } from "@opencode-ai/app/desktop-config" import type { WslServersPlatform } from "@opencode-ai/app/wsl/types" import type { UpdaterState } from "@opencode-ai/app/updater" export type { @@ -50,6 +51,7 @@ export type ElectronAPI = { getDefaultServerUrl: () => Promise setDefaultServerUrl: (url: string | null) => Promise getDisplayBackend: () => Promise + getDesktopConfig: () => Promise setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise parseMarkdownCommand: (markdown: string) => Promise checkAppExists: (appName: string) => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index c9bd050e7097..c9384a76b609 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -14,11 +14,12 @@ import { useCommand, useWslServers, } from "@opencode-ai/app" +import type { DesktopConfig } from "@opencode-ai/app/desktop-config" import type { UpdaterState } from "@opencode-ai/app/updater" import * as Sentry from "@sentry/solid" import type { AsyncStorage } from "@solid-primitives/storage" import { MemoryRouter } from "@solidjs/router" -import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js" import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" @@ -77,7 +78,7 @@ const listenForDeepLinks = () => { return window.api.onDeepLink((urls) => emitDeepLinks(urls)) } -const createPlatform = (): Platform => { +const createPlatform = (desktopConfig: Accessor): Platform => { const attachmentPaths = new WeakMap() const os = (() => { const ua = navigator.userAgent @@ -136,6 +137,7 @@ const createPlatform = (): Platform => { platform: "desktop", os, version: pkg.version, + desktopConfig, async openDirectoryPickerDialog(opts) { return window.api.openDirectoryPicker({ @@ -283,7 +285,8 @@ window.api.onMenuCommand((id) => { listenForDeepLinks() render(() => { - const platform = createPlatform() + const [desktopConfig] = createResource(() => window.api.getDesktopConfig().catch(() => undefined)) + const platform = createPlatform(() => desktopConfig.latest) const loadLocale = async () => { const current = await platform.storage?.("opencode.global.dat").getItem("language") const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") @@ -339,7 +342,7 @@ render(() => { ) const ready = createMemo( - () => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading, + () => !defaultServer.loading && !desktopConfig.loading && !sidecar.loading && !windowCount.loading && !locale.loading, ) const servers = createMemo(() => { const data = initializationData(sidecar) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index c1a69f5a866d..a4bdc84e66b8 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -102,6 +102,28 @@ Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use glo For TUI-specific settings, use `~/.config/opencode/tui.json`. +For Desktop-specific settings, use `~/.config/opencode/desktop.json`. + +```json title="desktop.json" +{ + "general": { + "showReasoningSummaries": false, + "shellToolPartsExpanded": false, + "editToolPartsExpanded": false, + "showSessionProgressBar": true, + "showCustomAgents": false + }, + "permissions": { + "autoApprove": true + }, + "sounds": { + "agentEnabled": false, + "permissionsEnabled": false, + "errorsEnabled": false + } +} +``` + Global config overrides remote organizational defaults. ---