Skip to content

Commit 28b09f9

Browse files
committed
feat(desktop-electron): add inbound connections
1 parent 00bb983 commit 28b09f9

10 files changed

Lines changed: 369 additions & 24 deletions

File tree

packages/app/src/components/settings-general.tsx

Lines changed: 201 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
1+
import { Component, Show, createEffect, createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
22
import { createStore } from "solid-js/store"
33
import { Button } from "@opencode-ai/ui/button"
44
import { Icon } from "@opencode-ai/ui/icon"
@@ -91,6 +91,11 @@ export const SettingsGeneral: Component = () => {
9191

9292
const [store, setStore] = createStore({
9393
checking: false,
94+
inboundEnabled: false,
95+
inboundUsername: "",
96+
inboundPassword: "",
97+
inboundPort: "",
98+
inboundSaving: false,
9499
})
95100

96101
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
@@ -120,6 +125,100 @@ export const SettingsGeneral: Component = () => {
120125
permission.disableAutoAccept(params.id, value)
121126
}
122127
const desktop = createMemo(() => platform.platform === "desktop")
128+
const [inboundConfig] = createResource(() => platform.getInboundServerConfig?.())
129+
const [inboundRuntimeConfig] = createResource(() => platform.getInboundRuntimeServerConfig?.())
130+
const [inboundHydrated, setInboundHydrated] = createSignal(false)
131+
const [savedInboundConfig, setSavedInboundConfig] = createSignal<{
132+
enabled: boolean
133+
username: string
134+
password: string
135+
port: number | null
136+
}>()
137+
138+
createEffect(() => {
139+
if (inboundHydrated()) return
140+
141+
const config = inboundConfig()
142+
if (!config) return
143+
setStore("inboundEnabled", config.enabled)
144+
setStore("inboundUsername", config.username)
145+
setStore("inboundPassword", config.password)
146+
setStore("inboundPort", config.port === null ? "" : String(config.port))
147+
setSavedInboundConfig(config)
148+
setInboundHydrated(true)
149+
})
150+
const inboundRuntime = createMemo(() => platform.inboundRuntimeServerConfig?.() ?? inboundRuntimeConfig())
151+
const inboundUsernamePlaceholder = createMemo(() => inboundRuntime()?.username ?? "opencode")
152+
const inboundPasswordPlaceholder = createMemo(
153+
() => inboundRuntime()?.password ?? language.t("settings.general.row.inboundPassword.placeholder"),
154+
)
155+
const inboundPortPlaceholder = createMemo(() => {
156+
const port = inboundRuntime()?.port
157+
return typeof port === "number" ? String(port) : ""
158+
})
159+
160+
const parseInboundPort = (value: string) => {
161+
const trimmed = value.trim()
162+
if (!trimmed) return null
163+
164+
const parsed = Number.parseInt(trimmed, 10)
165+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) return undefined
166+
167+
return parsed
168+
}
169+
170+
const saveInboundConfig = (config: { enabled: boolean; username: string; password: string; port: string }) => {
171+
if (!platform.setInboundServerConfig) return
172+
173+
const current = savedInboundConfig() ?? inboundConfig.latest
174+
const port = parseInboundPort(config.port)
175+
if (config.enabled && port === undefined) {
176+
showToast({
177+
title: language.t("common.requestFailed"),
178+
description: language.t("settings.general.row.inboundPort.invalid"),
179+
})
180+
return
181+
}
182+
183+
const next = {
184+
enabled: config.enabled,
185+
username: config.username.trim(),
186+
password: config.password,
187+
port: port ?? null,
188+
}
189+
190+
const changed =
191+
!current ||
192+
current.enabled !== next.enabled ||
193+
current.username !== next.username ||
194+
current.password !== next.password ||
195+
current.port !== next.port
196+
197+
if (!changed) return
198+
199+
setStore("inboundSaving", true)
200+
void platform
201+
.setInboundServerConfig(next)
202+
.then(() => {
203+
setSavedInboundConfig(next)
204+
})
205+
.finally(() => setStore("inboundSaving", false))
206+
}
207+
208+
const saveInboundOnBlur = () => {
209+
saveInboundConfig({
210+
enabled: store.inboundEnabled,
211+
username: store.inboundUsername,
212+
password: store.inboundPassword,
213+
port: store.inboundPort,
214+
})
215+
}
216+
217+
const saveInboundOnEnter = (event: KeyboardEvent) => {
218+
if (event.key !== "Enter") return
219+
event.preventDefault()
220+
saveInboundOnBlur()
221+
}
123222

124223
const check = () => {
125224
if (!platform.checkUpdate) return
@@ -728,7 +827,106 @@ export const SettingsGeneral: Component = () => {
728827
</div>
729828
)
730829

731-
console.log(import.meta.env)
830+
const DesktopNetworkSection = () => (
831+
<Show when={desktop() && platform.getInboundServerConfig && platform.setInboundServerConfig}>
832+
<div class="flex flex-col gap-1">
833+
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.network")}</h3>
834+
<SettingsList>
835+
<SettingsRow
836+
title={language.t("settings.general.row.inboundAccess.title")}
837+
description={language.t("settings.general.row.inboundAccess.description")}
838+
>
839+
<div data-action="settings-inbound-access">
840+
<Switch
841+
checked={store.inboundEnabled}
842+
disabled={inboundConfig.state === "pending" || store.inboundSaving}
843+
onChange={(checked) => {
844+
setStore("inboundEnabled", checked)
845+
saveInboundConfig({
846+
enabled: checked,
847+
username: store.inboundUsername,
848+
password: store.inboundPassword,
849+
port: store.inboundPort,
850+
})
851+
}}
852+
/>
853+
</div>
854+
</SettingsRow>
855+
<Show when={store.inboundEnabled}>
856+
<SettingsRow
857+
title={language.t("settings.general.row.inboundUsername.title")}
858+
description={language.t("settings.general.row.inboundUsername.description")}
859+
>
860+
<div class="w-full sm:w-[220px]">
861+
<TextField
862+
data-action="settings-inbound-username"
863+
label={language.t("settings.general.row.inboundUsername.title")}
864+
hideLabel
865+
type="text"
866+
value={store.inboundUsername || ""}
867+
onChange={(value) => setStore("inboundUsername", value)}
868+
onBlur={saveInboundOnBlur}
869+
onKeyDown={saveInboundOnEnter}
870+
placeholder={inboundUsernamePlaceholder()}
871+
spellcheck={false}
872+
autocorrect="off"
873+
autocomplete="off"
874+
autocapitalize="off"
875+
class="text-12-regular"
876+
/>
877+
</div>
878+
</SettingsRow>
879+
<SettingsRow
880+
title={language.t("settings.general.row.inboundPassword.title")}
881+
description={language.t("settings.general.row.inboundPassword.description")}
882+
>
883+
<div class="w-full sm:w-[220px]">
884+
<TextField
885+
data-action="settings-inbound-password"
886+
label={language.t("settings.general.row.inboundPassword.title")}
887+
hideLabel
888+
type="text"
889+
value={store.inboundPassword || ""}
890+
onChange={(value) => setStore("inboundPassword", value)}
891+
onBlur={saveInboundOnBlur}
892+
onKeyDown={saveInboundOnEnter}
893+
placeholder={inboundPasswordPlaceholder()}
894+
class="text-12-regular"
895+
/>
896+
</div>
897+
</SettingsRow>
898+
<SettingsRow
899+
title={language.t("settings.general.row.inboundPort.title")}
900+
description={language.t("settings.general.row.inboundPort.description")}
901+
>
902+
<div class="flex gap-2 items-center">
903+
<div class="w-full sm:w-[220px]">
904+
<TextField
905+
data-action="settings-inbound-port"
906+
label={language.t("settings.general.row.inboundPort.title")}
907+
hideLabel
908+
type="text"
909+
inputMode="numeric"
910+
value={store.inboundPort || ""}
911+
onChange={(value) => setStore("inboundPort", value)}
912+
onBlur={saveInboundOnBlur}
913+
onKeyDown={saveInboundOnEnter}
914+
placeholder={inboundPortPlaceholder()}
915+
spellcheck={false}
916+
autocorrect="off"
917+
autocomplete="off"
918+
autocapitalize="off"
919+
class="text-12-regular"
920+
/>
921+
</div>
922+
</div>
923+
</SettingsRow>
924+
</Show>
925+
</SettingsList>
926+
</div>
927+
</Show>
928+
)
929+
732930
return (
733931
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
734932
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -745,6 +943,7 @@ export const SettingsGeneral: Component = () => {
745943
<NotificationsSection />
746944

747945
<SoundsSection />
946+
<DesktopNetworkSection />
748947

749948
<UpdatesSection />
750949

packages/app/src/context/platform.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
88
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
99
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
1010
type UpdateInfo = { updateAvailable: boolean; version?: string }
11+
export type InboundServerConfig = {
12+
enabled: boolean
13+
username: string
14+
password: string
15+
port: number | null
16+
}
1117

1218
export type Platform = {
1319
/** Platform discriminator */
@@ -76,6 +82,18 @@ export type Platform = {
7682
/** Set the preferred display backend (desktop only) */
7783
setDisplayBackend?(backend: DisplayBackend): Promise<void>
7884

85+
/** Get inbound sidecar config (desktop only) */
86+
getInboundServerConfig?(): Promise<InboundServerConfig>
87+
88+
/** Get the inbound config currently used by the running sidecar (desktop only) */
89+
getInboundRuntimeServerConfig?(): Promise<Pick<InboundServerConfig, "username" | "password" | "port">>
90+
91+
/** The inbound config currently used by the running sidecar (desktop only) */
92+
inboundRuntimeServerConfig?: Accessor<Pick<InboundServerConfig, "username" | "password" | "port"> | undefined>
93+
94+
/** Set inbound sidecar config (desktop only) */
95+
setInboundServerConfig?(config: InboundServerConfig): Promise<void>
96+
7997
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
8098
parseMarkdown?(markdown: string): Promise<string>
8199

packages/app/src/i18n/en.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,7 @@ export const dict = {
725725
"settings.general.section.sounds": "Sound effects",
726726
"settings.general.section.feed": "Feed",
727727
"settings.general.section.display": "Display",
728+
"settings.general.section.network": "Network",
728729

729730
"settings.general.row.language.title": "Language",
730731
"settings.general.row.language.description": "Change the display language for OpenCode",
@@ -775,6 +776,22 @@ export const dict = {
775776
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
776777
"settings.general.row.wayland.tooltip":
777778
"On Linux with mixed refresh-rate monitors, native Wayland can be more stable.",
779+
"settings.general.row.inboundAccess.title": "Allow inbound connections",
780+
"settings.general.row.inboundAccess.description":
781+
"Allow OpenCode server access from other devices on your network. Requires restart.",
782+
"settings.general.row.inboundUsername.title": "Inbound username",
783+
"settings.general.row.inboundUsername.description": "Username for inbound clients. Leave blank to use the default.",
784+
"settings.general.row.inboundPassword.title": "Inbound password",
785+
"settings.general.row.inboundPassword.description": "Password for inbound clients. Leave blank to generate one automatically.",
786+
"settings.general.row.inboundPassword.placeholder": "Enter password",
787+
"settings.general.row.inboundPort.title": "Inbound port",
788+
"settings.general.row.inboundPort.description": "Leave blank to pick a free port automatically.",
789+
"settings.general.row.inboundPort.placeholder": "Auto",
790+
"settings.general.row.inboundPort.invalid": "Enter a port between 1 and 65535.",
791+
"settings.general.row.inbound.restartRequired": "Saved. Restart OpenCode to apply inbound changes.",
792+
"settings.general.row.inbound.save": "Save",
793+
"settings.general.row.inbound.missingCredentials":
794+
"Set both inbound username and password before enabling inbound connections.",
778795

779796
"settings.general.row.releaseNotes.title": "Release notes",
780797
"settings.general.row.releaseNotes.description": "Show What's New popups after updates",

packages/desktop-electron/src/main/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod
77
export const SETTINGS_STORE = "opencode.settings"
88
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
99
export const WSL_ENABLED_KEY = "wslEnabled"
10+
export const INBOUND_ENABLED_KEY = "inboundEnabled"
11+
export const INBOUND_USERNAME_KEY = "inboundUsername"
12+
export const INBOUND_PASSWORD_KEY = "inboundPassword"
13+
export const INBOUND_PORT_KEY = "inboundPort"
1014
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"

packages/desktop-electron/src/main/index.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,17 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio
4141
import { initLogging } from "./logging"
4242
import { parseMarkdown } from "./markdown"
4343
import { createMenu } from "./menu"
44-
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
44+
import {
45+
getDefaultServerUrl,
46+
getInboundServerConfig,
47+
getInboundRuntimeServerConfig,
48+
getWslConfig,
49+
setDefaultServerUrl,
50+
setInboundServerConfig,
51+
setRuntimeInboundServerConfig,
52+
setWslConfig,
53+
spawnLocalServer,
54+
} from "./server"
4555
import {
4656
createLoadingWindow,
4757
createMainWindow,
@@ -142,10 +152,18 @@ async function initialize() {
142152
const sqliteDone = needsMigration ? defer<void>() : undefined
143153
let overlay: BrowserWindow | null = null
144154

145-
const port = await getSidecarPort()
146-
const hostname = "127.0.0.1"
147-
const url = `http://${hostname}:${port}`
148-
const password = randomUUID()
155+
const inbound = getInboundServerConfig()
156+
const port = inbound.enabled && inbound.port !== null ? inbound.port : await getSidecarPort()
157+
const hostname = inbound.enabled ? "0.0.0.0" : "127.0.0.1"
158+
const url = `http://127.0.0.1:${port}`
159+
const username = inbound.username.trim() || "opencode"
160+
const password = inbound.password.trim() || randomUUID().replaceAll("-", "").slice(0, 16)
161+
setRuntimeInboundServerConfig({
162+
enabled: inbound.enabled,
163+
username,
164+
password,
165+
port,
166+
})
149167

150168
const loadingTask = (async () => {
151169
logger.log("sidecar connection started", { url })
@@ -175,12 +193,13 @@ async function initialize() {
175193
}
176194

177195
logger.log("spawning sidecar", { url })
178-
const { listener, health } = await spawnLocalServer(hostname, port, password)
196+
const { listener, health } = await spawnLocalServer(hostname, port, username, password)
179197
server = listener
180198
serverReady.resolve({
181199
url,
182-
username: "opencode",
200+
username,
183201
password,
202+
port,
184203
})
185204

186205
await Promise.race([
@@ -249,8 +268,11 @@ registerIpcHandlers({
249268
},
250269
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
251270
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
252-
getDefaultServerUrl: () => getDefaultServerUrl(),
253-
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
271+
getDefaultServerUrl: () => getDefaultServerUrl(),
272+
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
273+
getInboundServerConfig: () => getInboundServerConfig(),
274+
getInboundRuntimeServerConfig: () => getInboundRuntimeServerConfig(),
275+
setInboundServerConfig: (config) => setInboundServerConfig(config),
254276
getWslConfig: () => Promise.resolve(getWslConfig()),
255277
setWslConfig: (config: WslConfig) => setWslConfig(config),
256278
getDisplayBackend: async () => null,

0 commit comments

Comments
 (0)