Skip to content

Commit 4bafee6

Browse files
committed
feat(desktop): add desktop config file
1 parent e6cdc54 commit 4bafee6

15 files changed

Lines changed: 341 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
},
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { expect, test } from "bun:test"
2+
import { decodeDesktopConfig, decodeDesktopConfigJson } from "./desktop-config"
3+
4+
test("decodes supported desktop config values", () => {
5+
expect(
6+
decodeDesktopConfig({
7+
permissions: {
8+
autoApprove: true,
9+
},
10+
sounds: {
11+
agentEnabled: false,
12+
agent: "glass",
13+
permissionsEnabled: true,
14+
permissions: "ping",
15+
errorsEnabled: false,
16+
errors: "alert",
17+
},
18+
}),
19+
).toEqual({
20+
permissions: {
21+
autoApprove: 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+
test("ignores unknown keys and wrong primitive types", () => {
35+
expect(
36+
decodeDesktopConfig({
37+
permissions: {
38+
autoApprove: "yes",
39+
extra: true,
40+
},
41+
sounds: {
42+
agentEnabled: 1,
43+
agent: "glass",
44+
permissionsEnabled: true,
45+
permissions: false,
46+
errorsEnabled: false,
47+
errors: "alert",
48+
extra: "ignored",
49+
},
50+
extra: {
51+
value: true,
52+
},
53+
}),
54+
).toEqual({
55+
permissions: {},
56+
sounds: {
57+
agent: "glass",
58+
permissionsEnabled: true,
59+
errorsEnabled: false,
60+
errors: "alert",
61+
},
62+
})
63+
})
64+
65+
test("decodes desktop config from JSON strings", () => {
66+
expect(decodeDesktopConfigJson(`{"permissions":{"autoApprove":true}}`)).toEqual({
67+
permissions: {
68+
autoApprove: true,
69+
},
70+
})
71+
})
72+
73+
test("rejects malformed desktop config JSON", () => {
74+
expect(decodeDesktopConfigJson("{")).toBeUndefined()
75+
})

packages/app/src/desktop-config.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Option, Schema } from "effect"
2+
3+
const PermissionsSchema = Schema.Struct({
4+
autoApprove: Schema.optional(Schema.Boolean),
5+
})
6+
7+
const SoundsSchema = Schema.Struct({
8+
agentEnabled: Schema.optional(Schema.Boolean),
9+
agent: Schema.optional(Schema.String),
10+
permissionsEnabled: Schema.optional(Schema.Boolean),
11+
permissions: Schema.optional(Schema.String),
12+
errorsEnabled: Schema.optional(Schema.Boolean),
13+
errors: Schema.optional(Schema.String),
14+
})
15+
16+
export const DesktopConfigSchema = Schema.Struct({
17+
permissions: Schema.optional(PermissionsSchema),
18+
sounds: Schema.optional(SoundsSchema),
19+
})
20+
21+
export type DesktopConfig = typeof DesktopConfigSchema.Type
22+
23+
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
24+
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
25+
const decodeBoolean = Schema.decodeUnknownOption(Schema.Boolean)
26+
const decodeString = Schema.decodeUnknownOption(Schema.String)
27+
28+
export function decodeDesktopConfigJson(value: string) {
29+
const parsed = Option.getOrUndefined(decodeJson(value))
30+
if (parsed === undefined) return
31+
return decodeDesktopConfig(parsed)
32+
}
33+
34+
export function decodeDesktopConfig(value: unknown): DesktopConfig | undefined {
35+
const data = Option.getOrUndefined(decodeRecord(value))
36+
if (!data) return
37+
38+
const permissions = Option.getOrUndefined(decodeRecord(data.permissions))
39+
const sounds = Option.getOrUndefined(decodeRecord(data.sounds))
40+
41+
return stripUndefined({
42+
permissions: permissions
43+
? stripUndefined({
44+
autoApprove: Option.getOrUndefined(decodeBoolean(permissions.autoApprove)),
45+
})
46+
: undefined,
47+
sounds: sounds
48+
? stripUndefined({
49+
agentEnabled: Option.getOrUndefined(decodeBoolean(sounds.agentEnabled)),
50+
agent: Option.getOrUndefined(decodeString(sounds.agent)),
51+
permissionsEnabled: Option.getOrUndefined(decodeBoolean(sounds.permissionsEnabled)),
52+
permissions: Option.getOrUndefined(decodeString(sounds.permissions)),
53+
errorsEnabled: Option.getOrUndefined(decodeBoolean(sounds.errorsEnabled)),
54+
errors: Option.getOrUndefined(decodeString(sounds.errors)),
55+
})
56+
: undefined,
57+
})
58+
}
59+
60+
function stripUndefined<T extends Record<string, unknown>>(value: T) {
61+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as {
62+
[K in keyof T as undefined extends T[K] ? K : K]: Exclude<T[K], undefined>
63+
}
64+
}

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+
})

0 commit comments

Comments
 (0)