Skip to content

Commit 795d9e1

Browse files
committed
feat(desktop): add desktop config file
1 parent e6cdc54 commit 795d9e1

14 files changed

Lines changed: 256 additions & 11 deletions

File tree

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"type": "module",
66
"exports": {
77
".": "./src/index.ts",
8+
"./desktop-config": "./src/desktop-config.ts",
89
"./desktop-menu": "./src/desktop-menu.ts",
910
"./updater": "./src/updater.ts",
1011
"./wsl/types": "./src/wsl/types.ts",

packages/app/src/context/permission.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
55
import { Persist, persisted } from "@/utils/persist"
66
import { useServerSDK } from "@/context/server-sdk"
77
import { useServerSync } from "./server-sync"
8+
import { usePlatform } from "./platform"
89
import { useParams } from "@solidjs/router"
910
import { decode64 } from "@/utils/base64"
1011
import {
@@ -49,8 +50,12 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
4950
gate: false,
5051
init: () => {
5152
const params = useParams()
53+
const platform = usePlatform()
5254
const serverSDK = useServerSDK()
5355
const serverSync = useServerSync()
56+
const autoApprove = createMemo(
57+
() => platform.platform === "desktop" && platform.desktopConfig?.()?.permissions?.autoApprove === true,
58+
)
5459

5560
const permissionsEnabled = createMemo(() => {
5661
const directory = decode64(params.dir)
@@ -142,15 +147,18 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
142147
}
143148

144149
function isAutoAccepting(sessionID: string, directory?: string) {
150+
if (autoApprove()) return true
145151
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
146152
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
147153
}
148154

149155
function isAutoAcceptingDirectory(directory: string) {
156+
if (autoApprove()) return true
150157
return isDirectoryAutoAccepting(store.autoAccept, directory)
151158
}
152159

153160
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
161+
if (autoApprove()) return true
154162
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
155163
return autoRespondsPermission(store.autoAccept, session, permission, directory)
156164
}

packages/app/src/context/platform.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { DesktopMenuAction } from "../desktop-menu"
55
import { ServerConnection } from "./server"
66
import type { WslServersPlatform } from "../wsl/types"
77
import type { UpdaterPlatform } from "../updater"
8+
import type { DesktopConfig } from "../desktop-config"
89

910
type PickerPaths = string | string[] | null
1011
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -111,6 +112,9 @@ type PlatformBase = {
111112

112113
/** Record a fatal renderer error in platform logs (desktop only) */
113114
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
115+
116+
/** Desktop-only machine config loaded from ~/.config/opencode/desktop.json */
117+
desktopConfig?: Accessor<DesktopConfig | undefined>
114118
}
115119

116120
export type Platform = PlatformBase &

packages/app/src/context/settings.tsx

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createStore, reconcile } from "solid-js/store"
22
import { createEffect, createMemo } from "solid-js"
33
import { createSimpleContext } from "@opencode-ai/ui/context"
44
import { persisted } from "@/utils/persist"
5+
import { usePlatform } from "./platform"
56

67
export interface NotificationSettings {
78
agent: boolean
@@ -148,10 +149,16 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
148149
return createMemo(() => read() ?? fallback)
149150
}
150151

152+
function desktopFallback<T>(read: () => T | undefined, config: () => T | undefined, fallback: T) {
153+
return createMemo(() => config() ?? read() ?? fallback)
154+
}
155+
151156
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
152157
name: "Settings",
153158
gate: false,
154159
init: () => {
160+
const platform = usePlatform()
161+
const desktopConfig = () => (platform.platform === "desktop" ? platform.desktopConfig?.() : undefined)
155162
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
156163
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
157164
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
@@ -295,7 +302,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
295302
},
296303
},
297304
permissions: {
298-
autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
305+
autoApprove: desktopFallback(
306+
() => store.permissions?.autoApprove,
307+
() => desktopConfig()?.permissions?.autoApprove,
308+
defaultSettings.permissions.autoApprove,
309+
),
299310
setAutoApprove(value: boolean) {
300311
setStore("permissions", "autoApprove", value)
301312
},
@@ -315,30 +326,51 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
315326
},
316327
},
317328
sounds: {
318-
agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled),
329+
agentEnabled: desktopFallback(
330+
() => store.sounds?.agentEnabled,
331+
() => desktopConfig()?.sounds?.agentEnabled,
332+
defaultSettings.sounds.agentEnabled,
333+
),
319334
setAgentEnabled(value: boolean) {
320335
setStore("sounds", "agentEnabled", value)
321336
},
322-
agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
337+
agent: desktopFallback(
338+
() => store.sounds?.agent,
339+
() => desktopConfig()?.sounds?.agent,
340+
defaultSettings.sounds.agent,
341+
),
323342
setAgent(value: string) {
324343
setStore("sounds", "agent", value)
325344
},
326-
permissionsEnabled: withFallback(
345+
permissionsEnabled: desktopFallback(
327346
() => store.sounds?.permissionsEnabled,
347+
() => desktopConfig()?.sounds?.permissionsEnabled,
328348
defaultSettings.sounds.permissionsEnabled,
329349
),
330350
setPermissionsEnabled(value: boolean) {
331351
setStore("sounds", "permissionsEnabled", value)
332352
},
333-
permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
353+
permissions: desktopFallback(
354+
() => store.sounds?.permissions,
355+
() => desktopConfig()?.sounds?.permissions,
356+
defaultSettings.sounds.permissions,
357+
),
334358
setPermissions(value: string) {
335359
setStore("sounds", "permissions", value)
336360
},
337-
errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled),
361+
errorsEnabled: desktopFallback(
362+
() => store.sounds?.errorsEnabled,
363+
() => desktopConfig()?.sounds?.errorsEnabled,
364+
defaultSettings.sounds.errorsEnabled,
365+
),
338366
setErrorsEnabled(value: boolean) {
339367
setStore("sounds", "errorsEnabled", value)
340368
},
341-
errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
369+
errors: desktopFallback(
370+
() => store.sounds?.errors,
371+
() => desktopConfig()?.sounds?.errors,
372+
defaultSettings.sounds.errors,
373+
),
342374
setErrors(value: string) {
343375
setStore("sounds", "errors", value)
344376
},

packages/app/src/desktop-config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export type DesktopConfig = {
2+
permissions?: {
3+
autoApprove?: boolean
4+
}
5+
sounds?: {
6+
agentEnabled?: boolean
7+
agent?: string
8+
permissionsEnabled?: boolean
9+
permissions?: string
10+
errorsEnabled?: boolean
11+
errors?: string
12+
}
13+
}

packages/app/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from
33
export { useCommand } from "./context/command"
44
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
55
export { useWslServers } from "./wsl/context"
6+
export { type DesktopConfig } from "./desktop-config"
67
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
78
export { type UpdaterPlatform, type UpdaterState } from "./updater"
89
export {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
2+
import { tmpdir } from "node:os"
3+
import { join } from "node:path"
4+
import { afterEach, expect, test } from "bun:test"
5+
import { readDesktopConfig } from "./desktop-config"
6+
7+
const previousXdgConfigHome = process.env.XDG_CONFIG_HOME
8+
9+
afterEach(() => {
10+
process.env.XDG_CONFIG_HOME = previousXdgConfigHome
11+
})
12+
13+
test("reads supported desktop config values", async () => {
14+
process.env.XDG_CONFIG_HOME = await mkdtemp(join(tmpdir(), "opencode-desktop-config-"))
15+
await mkdir(join(process.env.XDG_CONFIG_HOME, "opencode"))
16+
await writeFile(
17+
join(process.env.XDG_CONFIG_HOME, "opencode", "desktop.json"),
18+
JSON.stringify({
19+
permissions: {
20+
autoApprove: true,
21+
unsupported: true,
22+
},
23+
sounds: {
24+
agentEnabled: false,
25+
agent: "glass",
26+
permissionsEnabled: true,
27+
permissions: "ping",
28+
errorsEnabled: false,
29+
errors: "alert",
30+
},
31+
}),
32+
)
33+
34+
expect(await readDesktopConfig()).toEqual({
35+
permissions: {
36+
autoApprove: true,
37+
},
38+
sounds: {
39+
agentEnabled: false,
40+
agent: "glass",
41+
permissionsEnabled: true,
42+
permissions: "ping",
43+
errorsEnabled: false,
44+
errors: "alert",
45+
},
46+
})
47+
48+
await rm(process.env.XDG_CONFIG_HOME, { recursive: true, force: true })
49+
})
50+
51+
test("ignores missing or malformed desktop config", async () => {
52+
process.env.XDG_CONFIG_HOME = await mkdtemp(join(tmpdir(), "opencode-desktop-config-"))
53+
expect(await readDesktopConfig()).toBeUndefined()
54+
55+
await mkdir(join(process.env.XDG_CONFIG_HOME, "opencode"))
56+
await writeFile(join(process.env.XDG_CONFIG_HOME, "opencode", "desktop.json"), "{")
57+
expect(await readDesktopConfig()).toBeUndefined()
58+
59+
await rm(process.env.XDG_CONFIG_HOME, { recursive: true, force: true })
60+
})
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { readFile } from "node:fs/promises"
2+
import { homedir } from "node:os"
3+
import { join } from "node:path"
4+
import type { DesktopConfig } from "@opencode-ai/app/desktop-config"
5+
6+
type Logger = {
7+
log: (message: string, data?: Record<string, unknown>) => void
8+
warn: (message: string, data?: Record<string, unknown>) => void
9+
}
10+
11+
function configDir() {
12+
if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "opencode")
13+
if (process.platform === "win32" && process.env.APPDATA) return join(process.env.APPDATA, "opencode")
14+
return join(homedir(), ".config", "opencode")
15+
}
16+
17+
function configPath() {
18+
return join(configDir(), "desktop.json")
19+
}
20+
21+
function boolean(value: unknown) {
22+
return typeof value === "boolean" ? value : undefined
23+
}
24+
25+
function string(value: unknown) {
26+
return typeof value === "string" ? value : undefined
27+
}
28+
29+
function object(value: unknown) {
30+
if (!value || typeof value !== "object") return
31+
if (Array.isArray(value)) return
32+
return value as Record<string, unknown>
33+
}
34+
35+
function normalize(value: unknown): DesktopConfig | undefined {
36+
const data = object(value)
37+
if (!data) return
38+
39+
const permissions = object(data.permissions)
40+
const sounds = object(data.sounds)
41+
42+
return {
43+
permissions: permissions
44+
? {
45+
autoApprove: boolean(permissions.autoApprove),
46+
}
47+
: undefined,
48+
sounds: sounds
49+
? {
50+
agentEnabled: boolean(sounds.agentEnabled),
51+
agent: string(sounds.agent),
52+
permissionsEnabled: boolean(sounds.permissionsEnabled),
53+
permissions: string(sounds.permissions),
54+
errorsEnabled: boolean(sounds.errorsEnabled),
55+
errors: string(sounds.errors),
56+
}
57+
: undefined,
58+
}
59+
}
60+
61+
export async function readDesktopConfig(logger?: Logger) {
62+
const path = configPath()
63+
logger?.log("desktop config: reading desktop.json", {
64+
path,
65+
xdgConfigHome: process.env.XDG_CONFIG_HOME,
66+
appData: process.env.APPDATA,
67+
})
68+
69+
const text = await readFile(path, "utf8").catch((error) => {
70+
const data = { path, ...errorDetails(error) }
71+
if (isNotFound(error)) {
72+
logger?.log("desktop config: desktop.json not found", data)
73+
return
74+
}
75+
logger?.warn("desktop config: failed to read desktop.json", data)
76+
})
77+
if (text === undefined) return
78+
79+
try {
80+
const config = normalize(JSON.parse(text))
81+
logger?.log("desktop config: loaded desktop.json", { path, config })
82+
return config
83+
} catch (error) {
84+
logger?.warn("desktop config: failed to parse desktop.json", { path, ...errorDetails(error) })
85+
return undefined
86+
}
87+
}
88+
89+
function isNotFound(error: unknown) {
90+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"
91+
}
92+
93+
function errorDetails(error: unknown) {
94+
if (!(error instanceof Error)) return { error: String(error) }
95+
return {
96+
name: error.name,
97+
message: error.message,
98+
code: typeof error === "object" && "code" in error ? error.code : undefined,
99+
}
100+
}

packages/desktop/src/main/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { createWslServersController } from "./wsl/servers"
3838
import { registerWslIpcHandlers } from "./wsl/ipc"
3939
import { spawnWslSidecar } from "./wsl/sidecar"
4040
import { migrate } from "./migrate"
41+
import { readDesktopConfig } from "./desktop-config"
4142

4243
const APP_NAMES: Record<string, string> = {
4344
dev: "OpenCode Dev",
@@ -173,6 +174,7 @@ const main = Effect.gen(function* () {
173174
packaged: app.isPackaged,
174175
onboardingTest: Boolean(onboardingTestRoot),
175176
})
177+
void readDesktopConfig(logger)
176178

177179
ensureLoopbackNoProxy()
178180
useEnvProxy()

packages/desktop/src/main/ipc.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { getStore } from "./store"
1212
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
1313
import type { UpdaterController } from "./updater-controller"
1414
import { createUpdaterSubscriptions } from "./updater-subscriptions"
15+
import { readDesktopConfig } from "./desktop-config"
16+
import { getLogger } from "./logging"
1517

1618
const pickerFilters = (ext?: string[]) => {
1719
if (!ext || ext.length === 0) return undefined
@@ -51,6 +53,7 @@ export function registerIpcHandlers(deps: Deps) {
5153
deps.setDefaultServerUrl(url),
5254
)
5355
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
56+
ipcMain.handle("get-desktop-config", () => readDesktopConfig(getLogger()))
5457
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
5558
deps.setDisplayBackend(backend),
5659
)

0 commit comments

Comments
 (0)