Compare commits

...
44 changed files with 1356 additions and 99 deletions
+1
View File
@@ -93,6 +93,7 @@
"solid-js": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3",
"uqr": "0.1.3",
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
@@ -0,0 +1,83 @@
import { expect, test } from "@playwright/test"
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
const password = "pairing-secret"
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
const requests: { origin: string; authorization: string | undefined }[] = []
await page.addInitScript((origin) => {
if (localStorage.getItem("opencode.global.dat:server")) return
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
)
}, origin)
await page.route("**/api/**", async (route) => {
requests.push({
origin: new URL(route.request().url()).origin,
authorization: route.request().headers().authorization,
})
// Pairing must succeed even when the API is unavailable.
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
})
await page.goto(`/connect?data=${encodeURIComponent(JSON.stringify({ username: "opencode", password }))}`)
await expect(page).toHaveURL(`${origin}/`)
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
.toEqual([{ type: "http", http: { url: origin, password } }])
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
requests.length = 0
await page.reload()
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
})
test("the hosted app hands HTTP-only pairing links over to the server's own web UI", async ({ page, baseURL }) => {
const dev = new URL(baseURL ?? "http://127.0.0.1:3000").origin
const hosted = "https://app.opencode.ai"
const lan = "http://192.168.1.20:49374"
// Serve the dev build under the hosted HTTPS origin so mixed-content rules apply to the page.
await page.route(`${hosted}/**`, async (route) => {
const response = await page.request.fetch(route.request().url().replace(hosted, dev))
await route.fulfill({ response })
})
await page.route(`${lan}/**`, (route) =>
route.fulfill({ status: 200, contentType: "text/html", body: "<title>served</title>" }),
)
const requests: string[] = []
await page.route("**/api/**", async (route) => {
requests.push(route.request().url())
await route.abort()
})
const info = { urls: [lan], username: "opencode", password: "lan-secret" }
const fragment = Buffer.from(JSON.stringify(info)).toString("base64url")
await page.goto(`${hosted}/connect#${fragment}`)
await expect(page.getByRole("heading", { name: "This server is on a local network" })).toBeVisible()
await expect(page.getByText(lan, { exact: true })).toBeVisible()
expect(requests).toEqual([])
await page.getByRole("button", { name: "Open on local network" }).click()
await expect(page).toHaveURL(`${lan}/connect#${fragment}`)
})
test("the unpaired page loads without starting server requests", async ({ page }) => {
const requests: string[] = []
await page.route("**/api/**", async (route) => {
requests.push(route.request().url())
await route.abort()
})
await page.goto("/connect")
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
expect(requests).toEqual([])
})
+2 -1
View File
@@ -93,6 +93,7 @@
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3"
"tailwindcss": "4.3.3",
"uqr": "0.1.3"
}
}
+24 -16
View File
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
import { Font } from "@opencode/ui/font"
import { ThemeProvider } from "@opencode/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
@@ -107,21 +107,29 @@ export function AppInterface(props: {
// The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<GlobalProvider>
const Root = (rootProps: ParentProps) => {
const location = useLocation()
// Pairing saves credentials before mounting any server connections or health checks.
return (
<>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
)
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
<TabsProvider>
<GlobalProvider>
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
</Show>
</>
)
}
return (
<ServersProvider
+38
View File
@@ -440,6 +440,44 @@ export const dict = {
"No camera is available to this browser. Enter your connection details manually.",
"server.connect.camera.error":
"Could not open the camera. Allow camera access or enter your connection details manually.",
"server.connect.local.title": "This server is on a local network",
"server.connect.local.description":
"This page can't reach HTTP servers on your local network. Open the server's own web interface to continue on this device.",
"server.connect.local.open": "Open on local network",
"server.connect.local.loopback.title": "This server only listens on localhost",
"server.connect.local.loopback.description":
"Other devices can't reach a server that only listens on localhost. Opening it will only work on the computer running OpenCode.",
"server.connect.local.loopback.open": "Open on this computer",
"server.connect.local.loopback.fix": "Run this command on your computer, then pair again.",
"command.server.pair": "Pair device",
"settings.pairing.title": "Pairing",
"settings.pairing.connection": "Local Network",
"pair.local.description": "View connection details and a QR code to connect a device on the same network.",
"pair.local.open": "Show details",
"pair.screenActive.title": "Keep screen active",
"pair.screenActive.description": "Prevent this computers display from sleeping while OpenCode is running.",
"pair.screenActive.error": "Could not update the screen activity setting. Try again.",
"pair.qr.open": "Show details",
"pair.tailscale.enable": "Enable Tailscale",
"pair.tailscale.serve": "Tailscale Serve",
"pair.description": "Connect another device to this machine's OpenCode server.",
"pair.loading": "Loading connection details…",
"pair.qr": "Pairing QR code",
"pair.copy": "Copy details",
"pair.copy.error": "Could not copy pairing details. Try again.",
"pair.urls": "URLs",
"pair.username": "Username",
"pair.password": "Password",
"pair.error": "Could not update pairing details. Try again.",
"pair.tailscale.open": "Share with Tailscale",
"pair.tailscale.opening": "Enabling Tailscale Serve…",
"pair.tailscale.title": "Tailscale",
"pair.tailscale.description": "Use Tailscale Serve to share this server over HTTPS with devices on your tailnet.",
"pair.tailscale.checking": "Checking Tailscale Serve…",
"pair.tailscale.inactive": "This server is not shared with Tailscale yet.",
"pair.tailscale.error": "Could not load Tailscale Serve status. Try again.",
"pair.tailscale.disable": "Disable",
"pair.tailscale.disabling": "Disabling Tailscale Serve…",
"dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
@@ -22,6 +22,12 @@ type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type PlatformName = "web" | "desktop"
type DesktopOS = "macos" | "windows" | "linux"
export type PairingInfo = {
readonly urls: readonly string[]
readonly username: "opencode"
readonly password: string
}
export type FatalRendererErrorLog = {
error: string
url: string
@@ -101,6 +107,10 @@ type PlatformBase = {
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
/** Prevent the local display from sleeping while the desktop app is running. */
getKeepScreenActive?(): Promise<boolean>
setKeepScreenActive?(enabled: boolean): Promise<void>
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
@@ -124,6 +134,15 @@ type PlatformBase = {
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
/** Pair another device with the local desktop server. */
pair?: {
info(): Promise<PairingInfo>
tailscaleAvailable(): Promise<boolean>
tailscaleStatus(): Promise<PairingInfo | null>
openTailscale(): Promise<PairingInfo>
disableTailscale(): Promise<void>
}
}
export type Platform = PlatformBase &
+5 -1
View File
@@ -7,6 +7,10 @@ export function isMixedContent(page: string, address: string) {
const url = new URL(normalized)
if (url.protocol !== "http:") return false
// Secure Contexts treats loopback HTTP origins as potentially trustworthy.
return !isLoopback(url)
}
export function isLoopback(url: URL) {
const host = url.hostname.replace(/\.$/, "")
return !(host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d+){3}$/.test(host))
return host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d+){3}$/.test(host)
}
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test"
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
describe("pairing URL", () => {
test("pairs with the current origin using credentials without server URLs", () => {
const info = { username: "opencode" as const, password: "a+b & café" }
const origin = "https://computer.tailnet.ts.net:49709"
const url = new URL(pairingUrl(info, origin))
expect(url.origin).toBe(origin)
expect(url.pathname).toBe("/connect")
expect(JSON.parse(url.searchParams.get("data") ?? "")).toEqual(info)
expect(decodePairingUrl(url.search, origin)).toEqual({ urls: [origin], password: info.password })
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
})
test("encodes the pairing JSON in the data query parameter and decodes it", () => {
const info = {
urls: ["http://192.168.1.2:4096"],
username: "opencode" as const,
password: "a+b & café",
}
const url = new URL(pairingUrl(info, "https://example.com"))
expect(url.origin).toBe("https://example.com")
expect(url.pathname).toBe("/connect")
expect(url.searchParams.get("data")).toBe(JSON.stringify(info))
expect(decodePairingUrl(url.search)).toEqual({
urls: ["http://192.168.1.2:4096"],
password: "a+b & café",
})
})
test("defaults to the hosted app for desktop pairing", () => {
expect(new URL(pairingUrl({ urls: [], username: "opencode", password: "secret" })).origin).toBe(
"https://app.opencode.ai",
)
})
test("accepts CLI base64url fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects invalid query data", () => {
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
expect(decodePairingUrl("?other=value")).toBeUndefined()
})
test("accepts legacy JSON fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects an invalid fragment", () => {
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
})
})
describe("pairing scan", () => {
const info = {
urls: ["http://192.168.1.2:49374", "http://127.0.0.1:49374"],
username: "opencode" as const,
password: "a+b & café",
}
const decoded = { urls: info.urls, password: info.password }
test("decodes raw JSON, desktop query links, and the CLI fragment link", () => {
expect(decodePairingScan(JSON.stringify(info))).toEqual(decoded)
expect(decodePairingScan(pairingUrl(info, "http://192.168.1.2:49374"))).toEqual(decoded)
const encoded = Buffer.from(JSON.stringify(info)).toString("base64url")
expect(decodePairingScan(`https://app.opencode.ai/connect#${encoded}`)).toEqual(decoded)
})
test("rejects URLs without pairing data and non-http schemes", () => {
expect(decodePairingScan("http://192.168.1.2:49374/connect")).toBeUndefined()
expect(decodePairingScan("opencode-ios://connect?password=secret")).toBeUndefined()
expect(decodePairingScan("not a code")).toBeUndefined()
})
})
+39 -3
View File
@@ -3,7 +3,7 @@ import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.Array(Schema.String),
urls: Schema.optional(Schema.Array(Schema.String)),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
@@ -19,10 +19,46 @@ export function serverAddress(value: string) {
return normalized
}
export function decodePairingCode(value: string) {
export function decodePairingCode(value: string, origin?: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
const urls = [
...new Set(
(result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined),
),
]
if (!urls.length) return
return { urls, password: result.value.password }
}
export function pairingUrl(
value: { urls?: readonly string[]; username: "opencode"; password: string },
host = "https://app.opencode.ai",
) {
return `${new URL("/connect", host)}?data=${encodeURIComponent(JSON.stringify(value))}`
}
export function decodePairingScan(value: string) {
const url = URL.parse(value)
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value)
return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash)
}
export function decodePairingUrl(value: string, origin?: string) {
if (value.startsWith("?")) {
const data = new URLSearchParams(value).get("data")
return data === null ? undefined : decodePairingCode(data, origin)
}
const encoded = value.startsWith("#") ? value.slice(1) : value
if (!encoded) return
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
if (legacy.startsWith("{")) return decodePairingCode(legacy)
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
const binary = atob(
encoded
.replaceAll("-", "+")
.replaceAll("_", "/")
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
)
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))))
}
+3 -3
View File
@@ -3,11 +3,11 @@ import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { decodePairingCode } from "./pairing"
import { decodePairingScan } from "./pairing"
import "./scanner.css"
export function PairingScanner(props: {
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
onScan: (value: NonNullable<ReturnType<typeof decodePairingScan>>) => void
onCancel: () => void
}) {
const language = useLanguage()
@@ -22,7 +22,7 @@ export function PairingScanner(props: {
const scanner = new QrScanner(
video,
(result) => {
const pairing = decodePairingCode(result.data)
const pairing = decodePairingScan(result.data)
if (!pairing) {
setState("error", language.t("server.connect.scan.invalid"))
return
+47 -3
View File
@@ -9,19 +9,28 @@ import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { serverAddress } from "./pairing"
import { isMixedContent } from "./browser"
import type { decodePairingCode } from "./pairing"
import { isLoopback, isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
export function ConnectServerScreen() {
export function ConnectServerScreen(
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
) {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const camera = createCameraAvailability()
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
const [state, setState] = createStore({
url: props.pairing?.urls[0] ?? "",
password: props.pairing?.password ?? "",
urls: props.pairing?.urls ?? ([] as string[]),
error: "",
scanning: false,
})
const connectionError = () =>
language.t(
platform.platform === "web" && isMixedContent(location.href, state.url)
@@ -42,6 +51,7 @@ export function ConnectServerScreen() {
return
}
servers.add({ type: "http", http })
props.onConnect?.()
},
onError: () => setState("error", connectionError()),
}))
@@ -164,3 +174,37 @@ export function ConnectServerScreen() {
</main>
)
}
export function ConnectLocalScreen(props: { urls: readonly string[] }) {
const language = useLanguage()
const target = props.urls[0]
const loopback = isLoopback(new URL(target))
return (
<main data-component="connect-server" aria-labelledby="server-connect-title">
<div class="server-connect-content">
<div class="server-connect-brand" role="img" aria-label="OpenCode">
<Wordmark />
</div>
<header>
<h1 id="server-connect-title">
{language.t(loopback ? "server.connect.local.loopback.title" : "server.connect.local.title")}
</h1>
<p>{language.t(loopback ? "server.connect.local.loopback.description" : "server.connect.local.description")}</p>
</header>
<Button
variant="contrast"
size="large"
onClick={() => location.assign(`${new URL("/connect", target)}${location.search}${location.hash}`)}
>
{language.t(loopback ? "server.connect.local.loopback.open" : "server.connect.local.open")}
</Button>
<footer>
<Show when={loopback}>
<p>{language.t("server.connect.local.loopback.fix")}</p>
</Show>
<code dir="ltr">{loopback ? "opencode service set hostname 0.0.0.0" : target}</code>
</footer>
</div>
</main>
)
}
+2
View File
@@ -7,6 +7,7 @@ export const pageIcons = {
appearance: "appearance",
notifications: "notifications",
shortcuts: "keyboard",
pairing: "server",
projects: "folder",
workspaces: "outline-worktree",
providers: "providers",
@@ -22,6 +23,7 @@ export const pageLabels = {
appearance: "settings.general.section.appearance",
notifications: "settings.tab.notifications",
shortcuts: "settings.shortcuts.title",
pairing: "settings.pairing.title",
projects: "settings.tab.projects",
workspaces: "settings.tab.workspaces",
providers: "settings.providers.title",
@@ -0,0 +1,303 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import { Tooltip } from "@opencode/ui/tooltip"
import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { renderSVG } from "uqr"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
import { pairingUrl } from "@/servers/connect/pairing"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
export function SettingsPairing() {
const language = useLanguage()
const dialog = useDialog()
const platform = usePlatform()
const queryClient = useQueryClient()
const pair = platform.pair
if (!pair) return null
const local = useQuery(() => ({
queryKey: ["pairing", "local"],
queryFn: pair.info,
}))
// Reading pending query data would suspend the entire settings surface.
const localInfo = () => (local.isSuccess ? local.data : undefined)
const localHost = createMemo(() =>
localInfo()?.urls.find((value) => {
const host = new URL(value).hostname
return (
host !== "localhost" &&
!host.endsWith(".localhost") &&
!host.startsWith("127.") &&
host !== "[::1]" &&
host !== "0.0.0.0" &&
host !== "[::]"
)
}),
)
const screenActive = useQuery(() => ({
queryKey: ["pairing", "screen-active"],
queryFn: () => platform.getKeepScreenActive!(),
enabled: !!platform.getKeepScreenActive,
}))
const screenActivity = useMutation(() => ({
mutationFn: async (enabled: boolean) => platform.setKeepScreenActive?.(enabled),
onSuccess: (_, enabled) => queryClient.setQueryData(["pairing", "screen-active"], enabled),
}))
const tailscale = useQuery(() => ({
queryKey: ["pairing", "tailscale-available"],
queryFn: pair.tailscaleAvailable,
}))
const tailscaleStatus = useQuery(() => ({
queryKey: ["pairing", "tailscale-status"],
queryFn: pair.tailscaleStatus,
enabled: tailscale.isSuccess && tailscale.data === true,
}))
const tailscaleServe = useMutation(() => ({
mutationFn: pair.openTailscale,
onSuccess: (value) => queryClient.setQueryData(["pairing", "tailscale-status"], value),
}))
const tailscaleDisable = useMutation(() => ({
mutationFn: pair.disableTailscale,
onSuccess: () => queryClient.setQueryData(["pairing", "tailscale-status"], null),
}))
const tailscaleInfo = () => (tailscaleStatus.isSuccess ? tailscaleStatus.data : undefined)
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.pairing.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("pair.description")}</span>
</div>
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<section class="settings-section" aria-label={language.t("settings.pairing.connection")}>
<SettingsList>
<SettingsRow
title={language.t("settings.pairing.connection")}
description={language.t("pair.local.description")}
>
<Button
variant="neutral"
disabled={!localHost()}
onClick={() =>
dialog.push(() => (
<DialogPairing
title={language.t("settings.pairing.connection")}
info={localInfo()}
host={localHost()}
/>
))
}
>
{language.t("pair.local.open")}
</Button>
</SettingsRow>
<Show when={platform.getKeepScreenActive && platform.setKeepScreenActive}>
<div data-action="settings-keep-screen-active">
<SettingsRow
title={language.t("pair.screenActive.title")}
description={language.t("pair.screenActive.description")}
>
<Switch
hideLabel
checked={screenActive.isSuccess && screenActive.data}
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
onChange={(enabled) => screenActivity.mutate(enabled)}
>
{language.t("pair.screenActive.title")}
</Switch>
</SettingsRow>
</div>
</Show>
</SettingsList>
<Show when={screenActive.error || screenActivity.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.screenActive.error")}
</p>
</Show>
<Show when={local.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.error")}
</p>
</Show>
</section>
<Show when={tailscale.isSuccess && tailscale.data === true}>
<section class="settings-section" aria-label={language.t("pair.tailscale.title")}>
<Show
when={tailscaleInfo()}
fallback={
<>
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
<SettingsList>
<div class="flex min-h-24 flex-col items-center justify-center gap-3 px-4 py-6">
<Button
variant="neutral"
disabled={!localInfo() || tailscaleStatus.isPending || tailscaleServe.isPending}
aria-busy={tailscaleServe.isPending}
onClick={() => tailscaleServe.mutate()}
>
<svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<g opacity="0.3">
<circle cx="3" cy="3" r="3" />
<circle cx="12" cy="3" r="3" />
<circle cx="21" cy="3" r="3" />
<circle cx="3" cy="21" r="3" />
<circle cx="21" cy="21" r="3" />
</g>
<circle cx="3" cy="12" r="3" />
<circle cx="12" cy="12" r="3" />
<circle cx="21" cy="12" r="3" />
<circle cx="12" cy="21" r="3" />
</svg>
{language.t(tailscaleServe.isPending ? "pair.tailscale.opening" : "pair.tailscale.enable")}
</Button>
<span class="text-center text-[13px] leading-text-base text-v2-text-text-muted">
{language.t("pair.tailscale.description")}
</span>
</div>
</SettingsList>
</>
}
>
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
<SettingsList>
<SettingsRow
title={language.t("pair.tailscale.serve")}
description={language.t("pair.tailscale.description")}
>
<div class="flex flex-col items-end gap-2">
<Button
variant="neutral"
disabled={!tailscaleInfo() || tailscaleDisable.isPending}
onClick={() =>
dialog.push(() => (
<DialogPairing
title={language.t("pair.tailscale.title")}
info={tailscaleInfo()}
host={tailscaleInfo()?.urls[0]}
/>
))
}
>
{language.t("pair.qr.open")}
</Button>
<Button
variant="neutral"
disabled={
!localInfo() ||
tailscaleStatus.isPending ||
tailscaleServe.isPending ||
tailscaleDisable.isPending
}
onClick={() => tailscaleDisable.mutate()}
>
{language.t("pair.tailscale.disable")}
</Button>
</div>
</SettingsRow>
</SettingsList>
</Show>
<Show when={tailscaleStatus.error || tailscaleServe.error || tailscaleDisable.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.tailscale.error")}
</p>
</Show>
</section>
</Show>
</div>
</>
)
}
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host?: string }) {
const language = useLanguage()
const platform = usePlatform()
const url = createMemo(() => {
if (!props.info) return
const host = props.host ?? (platform.platform === "web" ? location.origin : undefined)
return pairingUrl(
{
urls: host ? [host] : props.info.urls.slice(0, 1),
username: props.info.username,
password: props.info.password,
},
host,
)
})
const origin = createMemo(() => {
const value = url()
if (!value) return
return new URL(value).origin
})
const copy = useMutation(() => ({
mutationFn: async () => {
const value = url()
if (!value) return
await (platform.writeClipboardText?.(value) ?? navigator.clipboard.writeText(value))
},
}))
createEffect(() => {
if (!copy.isSuccess) return
const timeout = setTimeout(() => copy.reset(), 2000)
onCleanup(() => clearTimeout(timeout))
})
const qr = createMemo(() => {
const value = url()
if (!value) return
return renderSVG(value, { border: 4, blackColor: "currentColor", whiteColor: "transparent" })
})
return (
<Dialog fit containerClass="max-w-[min(400px,calc(100vw-32px),calc(100dvh-180px))]">
<DialogHeader>
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
</DialogHeader>
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
<Show when={props.info}>
<div
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
role="img"
aria-label={language.t("pair.qr")}
innerHTML={qr()}
/>
<div class="flex min-w-0 justify-center pb-2">
<Tooltip
class="min-w-0 max-w-full"
value={language.t(copy.isSuccess ? "common.copied" : "pair.copy")}
placement="top"
forceOpen={copy.isSuccess ? true : undefined}
>
<button
type="button"
class="inline-flex min-h-8 max-w-full select-none items-center justify-center gap-2 rounded-[6px] px-2 py-1 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted transition-colors hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base focus-visible:bg-v2-background-bg-layer-02 focus-visible:outline-none disabled:opacity-50"
disabled={copy.isPending}
aria-label={language.t("pair.copy")}
onClick={() => copy.mutate()}
>
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
<bdi dir="ltr" class="min-w-0 break-all text-start">
{origin()}
</bdi>
</button>
</Tooltip>
</div>
</Show>
<Show when={copy.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.copy.error")}
</p>
</Show>
</DialogBody>
</Dialog>
)
}
@@ -20,6 +20,20 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
{ tab: "appearance", label: "settings.general.section.appearance" },
{ tab: "notifications", label: "settings.tab.notifications" },
{ tab: "shortcuts", label: "settings.shortcuts.title", keywords: "keybind keyboard hotkey" },
{
tab: "pairing",
label: "settings.pairing.title",
keywords: "pair device qr tailscale",
available: "desktop",
},
{
tab: "pairing",
label: "pair.screenActive.title",
description: "pair.screenActive.description",
target: "settings-keep-screen-active",
keywords: "display sleep awake local",
available: "desktop",
},
{ tab: "experimental", label: "settings.tab.experimental" },
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
+12 -1
View File
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { createEffect, createMemo, on, onCleanup, onMount, Show, Switch, Match, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { displayName } from "@/shell/layout/helpers"
@@ -18,6 +19,7 @@ import { SettingsAppearance } from "./appearance/appearance"
import { SettingsExperimental } from "./experimental/experimental"
import { SettingsKeybinds } from "./keybinds/keybinds"
import { SettingsNotifications } from "./notifications/notifications"
import { SettingsPairing } from "./pairing/pairing"
import { SettingsProviders } from "./providers/providers"
import { SettingsModels } from "./models/models"
import { SettingsServerGeneral } from "./servers/servers"
@@ -41,6 +43,7 @@ const rootClientTabs = [
{ value: "appearance", icon: pageIcons.appearance, label: "settings.general.section.appearance" },
{ value: "notifications", icon: pageIcons.notifications, label: "settings.tab.notifications" },
{ value: "shortcuts", icon: pageIcons.shortcuts, label: "settings.tab.shortcuts" },
{ value: "pairing", icon: pageIcons.pairing, label: "settings.pairing.title" },
] as const
const serverTabs = [
@@ -187,6 +190,7 @@ function RootSettings() {
const tabs = useTabs()
const servers = useServerCollectionController()
const inventory = useSettingsServers()
const platform = usePlatform()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const list = servers.collection.items
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
@@ -216,7 +220,11 @@ function RootSettings() {
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
))
const groups = createMemo<SettingsNavGroup[]>(() => [
{ items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
{
items: rootClientTabs
.filter((item) => item.value !== "pairing" || !!platform.pair)
.map((item) => ({ ...item, label: language.t(item.label) })),
},
...(multiple()
? [
{
@@ -282,6 +290,9 @@ function RootSettings() {
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds active={surface.view().tab === "shortcuts"} autofocus={!surface.search.state.selected} />
</Tabs.Content>
<Tabs.Content value="pairing" class="settings-panel">
<SettingsPairing />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
+2
View File
@@ -11,6 +11,7 @@ export type SettingsRootTab =
| "appearance"
| "notifications"
| "shortcuts"
| "pairing"
| "projects"
| "workspaces"
| "providers"
@@ -44,6 +45,7 @@ const rootTabs: Record<SettingsRootTab, true> = {
appearance: true,
notifications: true,
shortcuts: true,
pairing: true,
projects: true,
workspaces: true,
providers: true,
@@ -4,6 +4,7 @@ import { useCommand, type CommandOption } from "./command"
import { useDialog } from "@opencode/ui/context/dialog"
import { DialogSsh } from "@/servers/ssh/dialog"
import { useUpdaterAction } from "@/shell/updates/action"
import { useSettingsSurface } from "@/settings/surface"
export function DesktopCommands() {
const command = useCommand()
@@ -40,3 +41,25 @@ export function DesktopCommands() {
return null
}
export function DesktopPairingCommand() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettingsSurface()
command.register("desktop-pairing", () =>
platform.platform === "desktop" && platform.pair
? [
{
id: "server.pair",
title: language.t("command.server.pair"),
category: language.t("command.category.server"),
onSelect: () => settings.open("pairing"),
},
]
: [],
)
return null
}
+53 -25
View File
@@ -1,5 +1,5 @@
import { Route, useParams } from "@solidjs/router"
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { Route, useNavigate, useParams } from "@solidjs/router"
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
@@ -10,6 +10,8 @@ import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
import { decodePairingUrl } from "@/servers/connect/pairing"
import { DesktopPairingCommand } from "@/shell/commands/desktop"
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
@@ -18,6 +20,9 @@ const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({
const ConnectServerScreen = lazy(() =>
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectServerScreen })),
)
const ConnectLocalScreen = lazy(() =>
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectLocalScreen })),
)
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
@@ -26,6 +31,7 @@ export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
@@ -33,32 +39,53 @@ export function preloadRoute(url: string) {
export function AppRoutes() {
return (
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
<>
<Route path="/connect" component={ConnectRoute} />
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
</>
)
}
function ConnectRoute() {
const navigate = useNavigate()
const servers = useServers()
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash)
// From the hosted https page, http is mixed content and loopback is the scanning device itself.
const url =
pairing?.urls.find((item) => item === location.origin) ??
pairing?.urls.find((item) => location.protocol !== "https:" || item.startsWith("https:"))
onMount(() => {
if (!pairing || !url) return
servers.add({ type: "http", http: { url, password: pairing.password } })
navigate("/", { replace: true })
})
if (!pairing) return <ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
if (!url) return <ConnectLocalScreen urls={pairing.urls} />
return null
}
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string }>()
const global = useGlobal()
@@ -79,6 +106,7 @@ function AppLayout(props: ParentProps) {
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<DesktopPairingCommand />
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
@@ -10,6 +10,10 @@ test("settings has its own layout route", () => {
expect(currentRoute("/settings", "")).toEqual({ type: "settings" })
})
test("connect has its own layout route", () => {
expect(currentRoute("/connect", "")).toEqual({ type: "connect" })
})
describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
+2
View File
@@ -67,6 +67,7 @@ export type TabPanes = {
export type LayoutRoute =
| { type: "home" }
| { type: "settings" }
| { type: "connect" }
| { type: "draft"; draftID: string }
| { type: "session"; sessionId: string; server: ServerConnection.Key }
@@ -106,6 +107,7 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
if (parts[0] === "settings") return { type: "settings" }
if (parts[0] === "connect") return { type: "connect" }
if (parts[0] === "new-session") {
const draftID = new URLSearchParams(search).get("draftId")
@@ -303,6 +303,7 @@ export function Titlebar(props: {
return
}
case "settings":
case "connect":
case "home": {
const selection = layout.home.selection()
const conn =
+6 -1
View File
@@ -2,6 +2,7 @@ import { EOL } from "os"
import { Effect, Option } from "effect"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { renderUnicodeCompact } from "uqr"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
@@ -18,6 +19,8 @@ export default Runtime.handler(
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
)).urls
const info = { urls, username: "opencode", password }
// Fragment, not query: the credential must never reach app.opencode.ai.
const link = `https://app.opencode.ai/connect#${base64Encode(JSON.stringify(info))}`
process.stdout.write(
[
"",
@@ -28,11 +31,13 @@ export default Runtime.handler(
"",
" Scan to pair",
"",
renderUnicodeCompact(JSON.stringify(info), { border: 2 })
renderUnicodeCompact(link, { border: 2 })
.split(EOL)
.map((line) => " " + line)
.join(EOL),
"",
` Link ${link}`,
"",
].join(EOL) + EOL,
)
+10 -12
View File
@@ -10,18 +10,16 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
? Effect.succeed(options.assets)
: yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)))
return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
api.pipe(
Effect.catchIf(isRouteNotFound, () =>
HttpServerRequest.HttpServerRequest.pipe(
Effect.flatMap((request) => {
const url = new URL(request.url, "http://localhost")
if (url.pathname === "/api" || url.pathname.startsWith("/api/"))
return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
return assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
}),
),
),
)
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
return yield* api.pipe(
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
)
return yield* assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
})
})
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
+59
View File
@@ -1,4 +1,5 @@
import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
import { ServerProcess } from "@opencode/server/process"
import { afterAll, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -7,11 +8,69 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { WebUi } from "../src/services/web-ui"
import { it } from "../../core/test/lib/effect"
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
afterAll(() => rm(root, { recursive: true, force: true }))
describe("web UI", () => {
it.live("serves the web shell and assets before server authentication", () =>
Effect.gen(function* () {
const transform = yield* WebUi.handler({
assets: {
"index.html": "<html><body>connect</body></html>",
"_assets/app.js": "console.log('connect')",
"_assets/app.css": "body { color: black; }",
"icons/icon.svg": "<svg></svg>",
"font.woff2": new Uint8Array([0, 1, 2, 255]),
"sw.js": "service worker",
},
})
const server = yield* ServerProcess.start<never, never>(
{ hostname: "127.0.0.1", port: 0, password: "secret", database: { path: ":memory:" } },
undefined,
transform,
)
const origin = HttpServer.formatAddress(server.address)
yield* Effect.forEach(
[
"/",
"/connect?data=%7B%7D",
"/workspace/example",
"/_assets/app.js",
"/_assets/app.css",
"/icons/icon.svg",
"/font.woff2",
"/sw.js",
],
(pathname) =>
Effect.gen(function* () {
yield* Effect.forEach(["GET", "HEAD"], (method) =>
Effect.gen(function* () {
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin), { method }))
expect(response.status).toBe(200)
expect(response.headers.get("www-authenticate")).toBeNull()
yield* Effect.promise(() => response.arrayBuffer())
}),
)
}),
)
yield* Effect.forEach(["/api", "/api/info", "/api/event", "/api/missing", "/openapi.json"], (pathname) =>
Effect.gen(function* () {
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin)))
expect(response.status).toBe(401)
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
yield* Effect.promise(() => response.arrayBuffer())
}),
)
const response = yield* Effect.promise(() =>
fetch(new URL("/api/info", origin), { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid")
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
test("falls back from API routes to assets and the SPA index", async () => {
const index = path.join(root, "index.html")
const asset = path.join(root, "app.js")
+4
View File
@@ -45,6 +45,10 @@ function selectOptions(): DevOptions {
async function prepareServer(source: ServerSource) {
if (source.type === "download")
return downloadCliToResources(source.version, windowsify("resources/opencode-cli-dev"))
await $`bun run --cwd ${join(import.meta.dirname, "../../app")} build`.env({
...process.env,
VITE_OPENCODE_SERVER_MODE: "origin",
})
process.env.OPENCODE_DESKTOP_CLI_DEV = join(import.meta.dirname, "../../cli")
await $`bun run --cwd ${process.env.OPENCODE_DESKTOP_CLI_DEV} --define=OPENCODE_VERSION=${JSON.stringify(process.env.OPENCODE_VERSION)} src/index.ts --version`
if (process.platform !== "win32") return
@@ -5,6 +5,7 @@ import { AppRpcs } from "../../shared/ipc-rpc"
import { openExternalURL } from "../files"
import { checkAppExists, resolveAppPath } from "../files/apps"
import { setForceFocus } from "../native/debug"
import { createScreenActivity } from "../native/screen-activity"
import { showCliInstaller } from "../native/install-cli"
import { DesktopLogging, scoped } from "../native/logging"
import { createMenu, sendMenuCommand } from "../native/menu"
@@ -17,6 +18,8 @@ import { DesktopCli } from "../service/desktop-cli"
import { SidecarCredentials } from "../service/sidecar-credentials"
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
import { Updater } from "../updater"
import { DesktopStorage } from "../storage"
import { createPairing } from "../service/pairing"
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
import { sender } from "./context"
@@ -28,6 +31,10 @@ export const appHandlers = AppRpcs.toLayer(
const desktopCli = yield* DesktopCli.Service
const updater = yield* Updater.Service
const logging = yield* DesktopLogging.Service
const storage = yield* DesktopStorage.Service
const screenActivity = createScreenActivity(storage)
yield* Effect.addFinalizer(() => Effect.sync(screenActivity.dispose))
const pairing = createPairing(storage)
const runFork = Effect.runForkWith(yield* Effect.context())
return AppRpcs.of({
AppAwaitInitialization: () => background.connection.pipe(Effect.map(SidecarCredentials.ready)),
@@ -68,6 +75,14 @@ export const appHandlers = AppRpcs.toLayer(
})
}),
AppRelaunch: () => Effect.sync(lifecycle.relaunch),
AppPairInfo: () => pair(pairing.info),
AppGetKeepScreenActive: () => Effect.sync(screenActivity.get),
AppSetKeepScreenActive: ({ enabled }) =>
Effect.try(() => screenActivity.set(enabled)).pipe(Effect.mapError(String)),
AppPairTailscaleAvailable: () => Effect.promise(pairing.tailscaleAvailable),
AppPairTailscaleStatus: () => pair(pairing.tailscaleStatus),
AppPairOpenTailscale: () => pair(pairing.openTailscale),
AppPairDisableTailscale: () => pair(pairing.disableTailscale),
})
}),
)
@@ -75,3 +90,10 @@ export const appHandlers = AppRpcs.toLayer(
function promise<A>(evaluate: () => A | Promise<A>) {
return Effect.tryPromise(async () => evaluate()).pipe(Effect.orDie)
}
function pair<A>(evaluate: () => Promise<A>) {
return Effect.tryPromise({
try: evaluate,
catch: (cause) => (cause instanceof Error ? cause.message : String(cause)),
})
}
@@ -0,0 +1,25 @@
import { powerSaveBlocker } from "electron"
import type { DesktopStorage } from "../storage"
import { KEEP_SCREEN_ACTIVE_KEY, SETTINGS_STORE } from "../storage/keys"
export function createScreenActivity(storage: DesktopStorage.Interface) {
const state = { blocker: undefined as number | undefined }
const dispose = () => {
if (state.blocker === undefined) return
powerSaveBlocker.stop(state.blocker)
state.blocker = undefined
}
const set = (enabled: boolean) => {
if (enabled && state.blocker === undefined) {
state.blocker = powerSaveBlocker.start("prevent-display-sleep")
}
if (!enabled) dispose()
storage.state.set(SETTINGS_STORE, KEEP_SCREEN_ACTIVE_KEY, JSON.stringify(enabled))
}
if (storage.state.get(SETTINGS_STORE, KEEP_SCREEN_ACTIVE_KEY) === "true") set(true)
return {
get: () => state.blocker !== undefined && powerSaveBlocker.isStarted(state.blocker),
set,
dispose,
}
}
@@ -43,7 +43,7 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
? path.join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version,
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
command: [...cli.command, "serve", "--service", ...(isolated ? ["--hostname", "0.0.0.0", "--port", "0"] : [])],
onStart: (reason, previousVersion) =>
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
}),
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { tailscaleUrls } from "./pairing"
describe("Tailscale Serve output", () => {
test("extracts and deduplicates HTTPS addresses", () => {
expect(
tailscaleUrls(`
Available within your tailnet:
https://computer.example.ts.net/
|-- https://computer.example.ts.net
|--> http://127.0.0.1:4096
`),
).toEqual(["https://computer.example.ts.net"])
})
test("ignores non-HTTPS addresses", () => {
expect(tailscaleUrls("http://computer:80\ntcp://100.64.0.1:443")).toEqual([])
})
test("selects only the OpenCode HTTPS port", () => {
expect(
tailscaleUrls(
"https://computer.example.ts.net\nhttps://computer.example.ts.net:8443\nhttps://computer.example.ts.net:49152",
49152,
),
).toEqual(["https://computer.example.ts.net:49152"])
})
})
@@ -0,0 +1,164 @@
import { eq } from "drizzle-orm"
import { execFile } from "node:child_process"
import { constants } from "node:fs"
import { access } from "node:fs/promises"
import { createServer } from "node:net"
import { promisify } from "node:util"
import type { DesktopStorage } from "../storage"
import { pairing } from "../storage/schema"
import { SidecarCredentials } from "./sidecar-credentials"
const tailscalePortKey = "tailscale_https_port"
const execFileAsync = promisify(execFile)
export function createPairing(storage: DesktopStorage.Interface) {
let tailscale: Promise<string | undefined> | undefined
const getTailscale = () => (tailscale ??= resolveTailscale())
let tailscalePort: Promise<number> | undefined
const storedTailscalePort = () => {
const value = storage.db
.select({ value: pairing.value })
.from(pairing)
.where(eq(pairing.key, tailscalePortKey))
.get()?.value
if (!value) return
const port = Number(value)
if (Number.isInteger(port) && port > 0 && port <= 65_535) return port
}
const getTailscalePort = () => (tailscalePort ??= Promise.resolve(storedTailscalePort() ?? availablePort()))
const saveTailscalePort = (port: number) =>
storage.db
.insert(pairing)
.values({ key: tailscalePortKey, value: String(port) })
.onConflictDoUpdate({ target: pairing.key, set: { value: String(port) } })
.run()
const requireCredentials = () => {
const credentials = SidecarCredentials.get()
if (!credentials) throw new Error("The local desktop server is not ready")
return credentials
}
const readInfo = async (credentials: ReturnType<typeof requireCredentials>) => {
const { OpenCode } = await import("@opencode/client/promise")
const info = await OpenCode.make({
baseUrl: credentials.url,
headers: credentials.password
? { Authorization: `Basic ${Buffer.from(`opencode:${credentials.password}`).toString("base64")}` }
: undefined,
}).server.info()
return { urls: info.urls, username: "opencode" as const, password: credentials.password ?? "" }
}
const info = () => readInfo(requireCredentials())
const serveTailscale = async (executable: string, port: number) => {
const credentials = requireCredentials()
const local = await readInfo(credentials)
const endpoint = new URL(credentials.url)
const options = { env: { ...process.env, TAILSCALE_BE_CLI: "1" }, windowsHide: true }
const served = await execFileAsync(
executable,
["serve", `--https=${port}`, "--bg", "--yes", `http://127.0.0.1:${endpoint.port}`],
options,
)
saveTailscalePort(port)
const direct = tailscaleUrls(`${served.stdout}\n${served.stderr}`, port)
const urls = direct.length
? direct
: tailscaleUrls(
await execFileAsync(executable, ["serve", "status"], options).then(
(result) => `${result.stdout}\n${result.stderr}`,
),
port,
)
if (!urls.length) throw new Error("Tailscale Serve did not report an HTTPS address")
return { ...local, urls: [...urls, ...local.urls] }
}
return {
info,
async tailscaleAvailable() {
return (await getTailscale()) !== undefined
},
async tailscaleStatus() {
const executable = await getTailscale()
const port = storedTailscalePort()
if (!executable || !port) return null
return serveTailscale(executable, port)
},
async openTailscale() {
const executable = await getTailscale()
if (!executable) throw new Error("Tailscale is not installed")
return serveTailscale(executable, await getTailscalePort())
},
async disableTailscale() {
const executable = await getTailscale()
const port = storedTailscalePort()
if (!executable || !port) return
await execFileAsync(executable, ["serve", `--https=${port}`, "--yes", "off"], {
env: { ...process.env, TAILSCALE_BE_CLI: "1" },
windowsHide: true,
})
storage.db.delete(pairing).where(eq(pairing.key, tailscalePortKey)).run()
tailscalePort = undefined
},
}
}
async function resolveTailscale() {
const candidates =
process.platform === "darwin"
? [
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
...(process.env.HOME ? [`${process.env.HOME}/Applications/Tailscale.app/Contents/MacOS/Tailscale`] : []),
"/opt/homebrew/bin/tailscale",
"/usr/local/bin/tailscale",
]
: []
const installed = (
await Promise.all(
candidates.map((file) =>
access(file, constants.X_OK).then(
() => file,
() => undefined,
),
),
)
).find((file) => file !== undefined)
if (installed) return installed
const result = await execFileAsync(process.platform === "win32" ? "where.exe" : "which", ["tailscale"], {
windowsHide: true,
}).then(
(value) => value.stdout,
() => undefined,
)
return result
?.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean)
}
export function tailscaleUrls(output: string, port?: number) {
return [
...new Set(
(output.match(/https:\/\/[^\s|]+/g) ?? [])
.map((value) => URL.parse(value.replace(/[),;]+$/, "")))
.filter((url): url is URL => url?.protocol === "https:" && (port === undefined || url.port === String(port)))
.map((url) => url.href.replace(/\/$/, "")),
),
]
}
function availablePort() {
return new Promise<number>((resolve, reject) => {
const server = createServer()
server.once("error", reject)
server.listen(0, "127.0.0.1", () => {
const address = server.address()
if (address === null || typeof address === "string") {
server.close()
reject(new Error("Could not allocate a Tailscale HTTPS port"))
return
}
server.close((error) => (error ? reject(error) : resolve(address.port)))
})
})
}
@@ -14,7 +14,7 @@ const tables = (db: ReturnType<typeof drizzle>) =>
describe("database", () => {
test("bootstraps every table on a fresh database and is idempotent", () => {
const database = openDatabase(":memory:")
expect(tables(database.db)).toEqual(["blob", "document", "migration", "state"])
expect(tables(database.db)).toEqual(["blob", "document", "migration", "pairing", "state"])
expect(migrate(database.db)).toEqual([])
database.close()
})
@@ -26,7 +26,7 @@ describe("database", () => {
)
const db = drizzle({ client: native })
expect(migrate(db)).toEqual(migrations.map((migration) => migration.id))
expect(tables(db)).toEqual(["blob", "document", "migration", "state"])
expect(tables(db)).toEqual(["blob", "document", "migration", "pairing", "state"])
expect(db.all<{ value: string }>(sql`SELECT value FROM document`)).toEqual([{ value: "v" }])
expect(migrate(db)).toEqual([])
})
@@ -3,5 +3,6 @@ export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const KEEP_SCREEN_ACTIVE_KEY = "keepScreenActive"
export const BACKGROUND_COLOR_KEY = "backgroundColor"
export const WINDOW_IDS_KEY = "windowIds"
@@ -18,4 +18,8 @@ export const migrations = [
id: "20260907031611_blob-touched",
statements: ["ALTER TABLE `blob` ADD `touched_at` integer DEFAULT 0 NOT NULL;"],
},
{
id: "20260915071310_pairing-state",
statements: ["CREATE TABLE `pairing` (\n\t`key` text PRIMARY KEY,\n\t`value` text NOT NULL\n);"],
},
]
@@ -0,0 +1,4 @@
CREATE TABLE `pairing` (
`key` text PRIMARY KEY,
`value` text NOT NULL
);
@@ -0,0 +1,174 @@
{
"version": "7",
"dialect": "sqlite",
"id": "bfb5a007-b9ab-4835-be4e-45f0bd6d3ddb",
"prevIds": [
"53c65132-8703-42d6-8464-64356145dfb4"
],
"ddl": [
{
"name": "blob",
"entityType": "tables"
},
{
"name": "document",
"entityType": "tables"
},
{
"name": "pairing",
"entityType": "tables"
},
{
"name": "state",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "blob"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "blob"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "touched_at",
"entityType": "columns",
"table": "blob"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "pairing"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "pairing"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "state"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "state"
},
{
"columns": [
"name",
"key"
],
"nameExplicit": false,
"name": "state_pk",
"entityType": "pks",
"table": "state"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "blob_pk",
"table": "blob",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "document_pk",
"table": "document",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "pairing_pk",
"table": "pairing",
"entityType": "pks"
}
],
"renames": []
}
@@ -27,3 +27,10 @@ export const state = sqliteTable(
},
(table) => [primaryKey({ columns: [table.name, table.key] })],
)
// Main-process pairing integration state. Tailscale Serve persists its configuration, and this
// remembers which HTTPS port belongs to OpenCode so it can be recovered without touching other routes.
export const pairing = sqliteTable("pairing", {
key: text().primaryKey(),
value: text().notNull(),
})
@@ -15,6 +15,7 @@ import type {
ServerReadyData,
TitlebarTheme,
} from "../shared/ipc-contract"
import type { PairingInfo } from "../shared/ipc-rpc/app"
export type WslServersAPI = WslServersPlatform
export type UpdaterAPI = {
@@ -87,4 +88,11 @@ export type ElectronAPI = {
setForceFocus(enabled: boolean): Promise<void>
recordFatalRendererError(error: FatalRendererError): Promise<void>
setNativeTranslations(bundle: DesktopNativeBundle): Promise<void>
pairInfo(): Promise<typeof PairingInfo.Type>
getKeepScreenActive(): Promise<boolean>
setKeepScreenActive(enabled: boolean): Promise<void>
pairTailscaleAvailable(): Promise<boolean>
pairTailscaleStatus(): Promise<typeof PairingInfo.Type | null>
pairOpenTailscale(): Promise<typeof PairingInfo.Type>
pairDisableTailscale(): Promise<void>
}
+7
View File
@@ -149,4 +149,11 @@ export const api: ElectronAPI = {
setForceFocus: (enabled) => invoke("AppSetForceFocus", { enabled }),
recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }),
setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }),
pairInfo: () => invoke("AppPairInfo").then(mutable),
getKeepScreenActive: () => invoke("AppGetKeepScreenActive"),
setKeepScreenActive: (enabled) => invoke("AppSetKeepScreenActive", { enabled }),
pairTailscaleAvailable: () => invoke("AppPairTailscaleAvailable"),
pairTailscaleStatus: () => invoke("AppPairTailscaleStatus").then(mutable),
pairOpenTailscale: () => invoke("AppPairOpenTailscale").then(mutable),
pairDisableTailscale: () => invoke("AppPairDisableTailscale"),
}
@@ -79,6 +79,8 @@ export function createDesktopPlatform(
windowFullscreen,
getPinchZoomEnabled: () => api.getPinchZoomEnabled(),
setPinchZoomEnabled,
getKeepScreenActive: () => api.getKeepScreenActive(),
setKeepScreenActive: (enabled) => api.setKeepScreenActive(enabled),
onDragCancel: (callback) => {
window.addEventListener(DragCancelEvent, callback)
return () => window.removeEventListener(DragCancelEvent, callback)
@@ -87,6 +89,13 @@ export function createDesktopPlatform(
checkAppExists: async (appName) => {
return api.checkAppExists(appName)
},
pair: {
info: () => api.pairInfo(),
tailscaleAvailable: () => api.pairTailscaleAvailable(),
tailscaleStatus: () => api.pairTailscaleStatus(),
openTailscale: () => api.pairOpenTailscale(),
disableTailscale: () => api.pairDisableTailscale(),
},
}
}
@@ -5,6 +5,12 @@ const ServerReadyData = Schema.Struct({
url: Schema.String,
})
export const PairingInfo = Schema.Struct({
urls: Schema.Array(Schema.String),
username: Schema.Literal("opencode"),
password: Schema.String,
})
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
export const AppReconnectService = Rpc.make("AppReconnectService", { success: ServerReadyData })
export const AppConsumeInitialDeepLinks = Rpc.make("AppConsumeInitialDeepLinks", {
@@ -53,6 +59,19 @@ export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
payload: { value: Schema.Unknown },
})
export const AppRelaunch = Rpc.make("AppRelaunch")
export const AppPairInfo = Rpc.make("AppPairInfo", { success: PairingInfo, error: Schema.String })
export const AppPairTailscaleAvailable = Rpc.make("AppPairTailscaleAvailable", { success: Schema.Boolean })
export const AppPairTailscaleStatus = Rpc.make("AppPairTailscaleStatus", {
success: Schema.NullOr(PairingInfo),
error: Schema.String,
})
export const AppPairOpenTailscale = Rpc.make("AppPairOpenTailscale", { success: PairingInfo, error: Schema.String })
export const AppPairDisableTailscale = Rpc.make("AppPairDisableTailscale", { error: Schema.String })
export const AppGetKeepScreenActive = Rpc.make("AppGetKeepScreenActive", { success: Schema.Boolean })
export const AppSetKeepScreenActive = Rpc.make("AppSetKeepScreenActive", {
payload: { enabled: Schema.Boolean },
error: Schema.String,
})
export const AppRpcs = RpcGroup.make(
AppAwaitInitialization,
AppReconnectService,
@@ -69,4 +88,11 @@ export const AppRpcs = RpcGroup.make(
AppRecordFatalRendererError,
AppSetNativeTranslations,
AppRelaunch,
AppPairInfo,
AppPairTailscaleAvailable,
AppPairTailscaleStatus,
AppPairOpenTailscale,
AppPairDisableTailscale,
AppGetKeepScreenActive,
AppSetKeepScreenActive,
)
+6 -17
View File
@@ -8,14 +8,7 @@ import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
import { Global } from "@opencode/util/global"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
import {
HttpMiddleware,
HttpPlatform,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { isAllowedCorsOrigin } from "./cors"
@@ -68,15 +61,17 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
}
const application = yield* Ref.make(Option.none<App>())
const app = dispatch(password, status, application, options.app?.version ?? "unknown", urls, Global.Path.tmp)
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
yield* bound.http
.serve(
dispatch(password, status, application, options.app?.version ?? "unknown", urls, Global.Path.tmp).pipe(
(transform ? transform(app) : app).pipe(
HttpMiddleware.compression(),
HttpMiddleware.cors({ allowedOrigins: (origin) => isAllowedCorsOrigin(origin, options), maxAge: 86_400 }),
),
errorResponseLogger,
)
.pipe(withoutParentSpan)
.pipe(Effect.provide(NodeHttpServer.layerHttpServices), withoutParentSpan)
if (lifecycle)
yield* lifecycle.onListen(bound.http.address, shutdown.open.pipe(Effect.asVoid)).pipe(
Effect.flatMap((cleanup) =>
@@ -110,13 +105,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
Effect.provideService(Scope.Scope, applicationScope),
)
}
const app = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(
HttpMiddleware.compression(),
Effect.provideService(HttpPlatform.HttpPlatform, Context.get(context, HttpPlatform.HttpPlatform)),
)
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect()))
yield* status.ready
const bus = Context.get(context, Bus.Service)
return {
+12 -11
View File
@@ -1,10 +1,10 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
it.live("authenticates API and frontend requests while allowing browser preflight", () =>
it.live("authenticates API requests behind the frontend transform while allowing browser preflight", () =>
Effect.gen(function* () {
const fallback = "fallback".repeat(256)
const server = yield* ServerProcess.start<never, never>(
@@ -18,12 +18,13 @@ it.live("authenticates API and frontend requests while allowing browser prefligh
},
undefined,
(api) =>
api.pipe(
Effect.catchIf(
(error) => error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound",
() => Effect.succeed(HttpServerResponse.raw(fallback, { contentType: "text/plain" })),
),
),
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
return yield* api
return HttpServerResponse.raw(fallback, { contentType: "text/plain" })
}),
)
const response = yield* Effect.promise(() =>
fetch(new URL("/api/info", HttpServer.formatAddress(server.address)), {
@@ -143,9 +144,9 @@ it.live("authenticates API and frontend requests while allowing browser prefligh
headers: authorization ? { authorization } : undefined,
}),
)
expect(response.status).toBe(401)
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
expect(yield* Effect.promise(() => response.text())).toBe("")
expect(response.status).toBe(200)
expect(response.headers.get("www-authenticate")).toBeNull()
expect(yield* Effect.promise(() => response.text())).toBe(method === "HEAD" ? "" : fallback)
}),
)
const response = yield* Effect.promise(() =>
@@ -362,13 +362,15 @@ $ opencode serve --help
## pair
Shows server pairing information, including URLs, credentials, and a QR code.
Shows server pairing information, including URLs, credentials, and a QR code. The
QR code is an `app.opencode.ai` link carrying the connection details, so a phone
camera can open it.
```bash
$ opencode pair
```
Advertise an external URL in the QR code.
Advertise an external URL in the QR code and link.
```bash
$ opencode pair --url https://dev.example.com
+10
View File
@@ -14,8 +14,18 @@ $ opencode pair
URLs http://127.0.0.1:49374
Username opencode
Password ********
Scan to pair
█▀▀▀▀▀█ ...
Link https://app.opencode.ai/connect#...
```
The QR code is an `app.opencode.ai` link carrying the connection details, so a
phone camera can open it. For a server on your local network, the page offers to
open the server's own web UI instead.
By default the server runs on port 49374 and listens only on localhost. You can
change this config with the `opencode service` command.