feat(app): configure initial servers and add QR pairing (#47799)

This commit is contained in:
Brendan Allan
2026-09-08 14:32:17 +08:00
committed by GitHub
parent 64684b118f
commit 90dd682e66
24 changed files with 609 additions and 97 deletions
+5
View File
@@ -86,6 +86,7 @@
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
@@ -3228,6 +3229,8 @@
"@types/npmlog": ["@types/npmlog@7.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-hJWbrKFvxKyWwSUXjZMYTINsSOY6IclhvGOZ97M8ac2tmR9hMwmTnYaMdpGhvju9ctWLTPhCS+eLfQNluiEjQQ=="],
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
"@types/pacote": ["@types/pacote@11.1.8", "", { "dependencies": { "@types/node": "*", "@types/npm-registry-fetch": "*", "@types/npmlog": "*", "@types/ssri": "*" } }, "sha512-/XLR0VoTh2JEO0jJg1q/e6Rh9bxjBq9vorJuQmtT7rRrXSiWz7e7NsvXVYJQ0i8JxMlBMPPYDTnrRe7MZRFA8Q=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
@@ -5160,6 +5163,8 @@
"pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="],
"qr-scanner": ["qr-scanner@1.4.2", "", { "dependencies": { "@types/offscreencanvas": "^2019.6.4" } }, "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw=="],
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
+1
View File
@@ -6,6 +6,7 @@ export function createWebApp(domain: string) {
$app.stage === "beta"
? {
OPENCODE_CHANNEL: "beta",
VITE_OPENCODE_SERVER_MODE: "none",
VITE_SENTRY_ENVIRONMENT: "beta",
}
: undefined,
+33 -2
View File
@@ -83,8 +83,39 @@ Changes merged into `v2` reach the beta site when they are promoted to `beta`. T
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
The hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
override this default. Connecting still requires the service's credentials.
`VITE_OPENCODE_SERVER_MODE` controls which server the web build provides at startup:
| Mode | Initial server |
| ------------------ | ------------------------------------------------------------------------------------ |
| `none` | No initial server. The beta deployment uses this mode. |
| `origin` (default) | The current page's origin. CLI builds explicitly use this mode for `opencode serve`. |
In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPENCODE_SERVER_PORT`
(default: `http://localhost:4096`) instead of the frontend origin. Both modes restore user-added servers
from storage. Desktop provides the local server it discovers or starts through native initialization.
With no configured servers, the app shows a full-screen connection form. Enter a server address and password,
or choose **Scan QR code** to open the camera and read the JSON pairing code from `opencode pair`.
Scanning fills the form and immediately attempts to connect. Failed connections leave the details available
to edit and retry with **Connect**. Credentials are checked before saving the server. Camera access requires
HTTPS (or localhost) and browser permission. Saved offline servers continue to use the normal app UI.
When the service is exposed through an HTTPS reverse proxy, advertise its external address at runtime:
```bash
opencode pair --url https://your-machine.your-tailnet.ts.net
```
This replaces the addresses printed and encoded in the QR code while retaining the local service password.
The proxy URL must reach the OpenCode API, not just the frontend. For separate frontend and API processes,
route `/api` to the service while preserving the `/api` prefix. No machine-specific app or CLI build is required.
When an HTTPS page fails to connect to a non-loopback HTTP server, the connection forms show a specific
HTTPS-to-HTTP error instead of the generic connection failure. HTTP servers on `localhost`, `*.localhost`,
`127.0.0.0/8`, or `::1` are treated as trustworthy loopback targets. Connection attempts still run, since
browser local-network permissions can allow some HTTP LAN connections.
QR scanning is enabled only in a browser-reported secure context with camera support and an available
video input; insecure pages and unavailable cameras show an explanation beside the disabled action.
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
+1
View File
@@ -87,6 +87,7 @@
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
+1 -1
View File
@@ -95,7 +95,7 @@ export function AppBaseProviders(
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
+13 -11
View File
@@ -76,22 +76,24 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
const standalone = isStandalone()
root.dataset.standalone = String(standalone)
if (standalone) restorePwaRoute()
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
const server: ServerConnection.Http | undefined = web.currentServerUrl
? {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
: undefined
render(
() => (
<PlatformProvider value={web.platform}>
<AppBaseProviders locale={locale}>
<AppInterface
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
defaultServer={web.defaultServerUrl ? ServerConnection.Key.make(web.defaultServerUrl) : undefined}
canonicalLocalServer={server ? ServerConnection.key(server) : undefined}
servers={server ? [server] : []}
>
<KeyboardInsets />
{standalone && <PwaRoutePersistence />}
+1
View File
@@ -1,4 +1,5 @@
interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_MODE?: "none" | "origin"
readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string
readonly VITE_OPENCODE_CHANNEL?: "local" | "dev" | "beta" | "prod"
+23 -19
View File
@@ -198,26 +198,30 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
<Show
when={props.servers.length > 1}
fallback={
<div class={props.dropdown ? "" : "pr-3"}>
<Show
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
>
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
<Show when={props.dropdown}>
<HomeProjectNavButton
type="button"
data-action="home-add-project-row"
class="mt-1 disabled:opacity-60"
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
<Show when={props.servers[0]}>
{(server) => (
<div class={props.dropdown ? "" : "pr-3"}>
<Show
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={server()} items={props.recentlyClosed} />}
>
<Icon name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
</Show>
</Show>
</div>
<HomeProjectList {...props} {...contextMenuProps} server={server()} items={props.projects} />
<Show when={props.dropdown}>
<HomeProjectNavButton
type="button"
data-action="home-add-project-row"
class="mt-1 disabled:opacity-60"
disabled={props.serverHealth(server())?.healthy === false}
onClick={() => props.onChooseProject(server())}
>
<Icon name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
</Show>
</Show>
</div>
)}
</Show>
}
>
<div class={`flex min-w-0 flex-col ${props.dropdown ? "gap-1" : "gap-4 pr-3"}`}>
@@ -311,8 +311,10 @@ export function createHomeSessionsController(home: HomeController) {
dialog.show(() => <DeleteDialog server={server} session={session} />),
},
tab: {
isOpen: (record: HomeSessionRecord) =>
sessionHasOpenTab(tabs.store, home.selection.value().server, record.session),
isOpen: (record: HomeSessionRecord) => {
const server = home.selection.value().server
return !!server && sessionHasOpenTab(tabs.store, server, record.session)
},
},
}
}
+43 -38
View File
@@ -2,6 +2,7 @@ import type { HomeScrollController } from "../scroll"
import type { HomeSessionSearchController } from "./search"
import type { HomeSessionsController } from "./controller"
import { HomeSessionsView } from "./view"
import { Show } from "solid-js"
export function HomeSessions(props: {
sessions: HomeSessionsController
@@ -9,43 +10,47 @@ export function HomeSessions(props: {
scroll: HomeScrollController
}) {
return (
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
showProjectName={props.sessions.session.showProjectName()}
server={props.sessions.session.server()}
canCreateSession={props.sessions.session.canCreate()}
searchValue={props.search.query.value()}
searchPlaceholder={props.search.query.placeholder()}
searchOpen={props.search.query.open()}
searchLoading={props.search.result.loading()}
searchResults={props.search.result.list()}
searchActive={props.search.result.active()}
searchNoResultsLabel={props.search.result.noResultsLabel()}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
onSetHeader={props.scroll.header.setHeader}
onWheel={props.scroll.viewport.containWheel}
onSetSearchRoot={props.search.element.setRoot}
onSetSearchInput={props.search.element.setInput}
onSetSearchList={props.search.element.setList}
onSearchFocus={props.search.query.focus}
onSearchInput={props.search.query.input}
onSearchClose={props.search.query.close}
onSearchMove={props.search.result.move}
onSearchSelectActive={props.search.result.selectActive}
onSearchHighlight={props.search.result.highlight}
onSearchSelect={props.search.result.select}
/>
<Show when={props.sessions.session.server()}>
{(server) => (
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
showProjectName={props.sessions.session.showProjectName()}
server={server()}
canCreateSession={props.sessions.session.canCreate()}
searchValue={props.search.query.value()}
searchPlaceholder={props.search.query.placeholder()}
searchOpen={props.search.query.open()}
searchLoading={props.search.result.loading()}
searchResults={props.search.result.list()}
searchActive={props.search.result.active()}
searchNoResultsLabel={props.search.result.noResultsLabel()}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
onSetHeader={props.scroll.header.setHeader}
onWheel={props.scroll.viewport.containWheel}
onSetSearchRoot={props.search.element.setRoot}
onSetSearchInput={props.search.element.setInput}
onSetSearchList={props.search.element.setList}
onSearchFocus={props.search.query.focus}
onSearchInput={props.search.query.input}
onSearchClose={props.search.query.close}
onSearchMove={props.search.result.move}
onSearchSelectActive={props.search.result.selectActive}
onSearchHighlight={props.search.result.highlight}
onSearchSelect={props.search.result.select}
/>
)}
</Show>
)
}
+19
View File
@@ -366,6 +366,25 @@ export const dict = {
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.password": "Password",
"dialog.server.add.passwordPlaceholder": "password",
"server.connect.title": "Connect to a server",
"server.connect.description": "Enter your server address and password to get started.",
"server.connect.button": "Connect",
"server.connect.address.invalid": "Enter a valid HTTP or HTTPS server address.",
"server.connect.failed": "Could not connect. Check the server address and password, then try again.",
"server.connect.pair.description": "Run this command on your computer to get your connection details.",
"server.connect.scan": "Scan QR code",
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
"server.connect.camera": "Pairing camera",
"server.connect.camera.starting": "Opening camera…",
"server.connect.mixedContent":
"Could not connect to this HTTP server from an HTTPS page. Use an HTTPS server address instead.",
"server.connect.camera.insecure": "QR scanning requires opening this page over HTTPS or on localhost.",
"server.connect.camera.unavailable":
"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.",
"dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
+1 -1
View File
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
}
function getCurrentServerUrl() {
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
+1 -1
View File
@@ -194,7 +194,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
name: "Server",
gate: true,
init: (props: {
defaultServer: ServerConnection.Key
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
@@ -0,0 +1,12 @@
import { serverAddress } from "./pairing"
export function isMixedContent(page: string, address: string) {
if (new URL(page).protocol !== "https:") return false
const normalized = serverAddress(address)
if (!normalized) return false
const url = new URL(normalized)
if (url.protocol !== "http:") return false
// Secure Contexts treats loopback HTTP origins as potentially trustworthy.
const host = url.hostname.replace(/\.$/, "")
return !(host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d+){3}$/.test(host))
}
+15 -2
View File
@@ -16,6 +16,8 @@ import { useLanguage } from "@/runtime/i18n/language"
import { normalizeServerUrl, ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { useCheckServerHealth } from "@/runtime/server/health"
import { usePlatform } from "@/runtime/platform/platform"
import { isMixedContent } from "./browser"
import "@/settings/settings.css"
type FormMode = "list" | "add" | "edit"
@@ -81,11 +83,14 @@ export const DialogServer: Component<{
invalid={!!form.state.error()}
disabled={form.state.busy()}
autofocus
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<Show when={form.state.error()}>
<span class="settings-server-dialog-error">{form.state.error()}</span>
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
{form.state.error()}
</span>
</Show>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
@@ -129,6 +134,7 @@ export const DialogServer: Component<{
}
function createFormController(options: { onSelect?: () => void } = {}) {
const platform = usePlatform()
const server = useServers()
const tabs = useTabs()
const global = useGlobal()
@@ -201,7 +207,14 @@ function createFormController(options: { onSelect?: () => void } = {}) {
}
const result = await checkServerHealth(connection.http)
if (!result.healthy) {
setStore("error", language.t("dialog.server.add.error"))
setStore(
"error",
language.t(
platform.platform === "web" && isMixedContent(location.href, normalized)
? "server.connect.mixedContent"
: "dialog.server.add.error",
),
)
return
}
if (original?.type === "http") {
@@ -0,0 +1,28 @@
import { Option, Schema } from "effect"
import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.Array(Schema.String),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
)
export function serverAddress(value: string) {
if (value.includes("://") && !/^https?:\/\//.test(value.trim())) return
const normalized = normalizeServerUrl(value)
if (!normalized || !URL.canParse(normalized)) return
const url = new URL(normalized)
if (url.protocol !== "http:" && url.protocol !== "https:") return
if (url.username || url.password || url.search || url.hash) return
return normalized
}
export function decodePairingCode(value: 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))]
if (!urls.length) return
return { urls, password: result.value.password }
}
@@ -0,0 +1,62 @@
import QrScanner from "qr-scanner"
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"
export function PairingScanner(props: {
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
onCancel: () => void
}) {
const language = useLanguage()
const [state, setState] = createStore({ error: "", ready: false })
const video = document.createElement("video")
video.setAttribute("aria-label", language.t("server.connect.camera"))
video.setAttribute("playsinline", "")
video.muted = true
onMount(() => {
// QrScanner hides detached videos, so initialize only after this preview is mounted.
const scanner = new QrScanner(
video,
(result) => {
const pairing = decodePairingCode(result.data)
if (!pairing) {
setState("error", language.t("server.connect.scan.invalid"))
return
}
scanner.stop()
props.onScan(pairing)
},
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
)
// Terminal QR codes can be light-on-dark depending on the terminal theme.
scanner.setInversionMode("both")
onCleanup(() => scanner.destroy())
void scanner.start().then(
() => setState("ready", true),
() => setState("error", language.t("server.connect.camera.error")),
)
})
return (
<section class="server-connect-scanner" aria-label={language.t("server.connect.scan")}>
<p>{language.t("server.connect.scan.description")}</p>
<div class="server-connect-video">
{video}
<Show when={!state.ready && !state.error}>
<span role="status">{language.t("server.connect.camera.starting")}</span>
</Show>
</div>
<Show when={state.error}>
<p class="server-connect-error" role="alert">
{state.error}
</p>
</Show>
<Button variant="neutral" size="large" onClick={props.onCancel}>
{language.t("common.cancel")}
</Button>
</section>
)
}
+119
View File
@@ -0,0 +1,119 @@
[data-component="connect-server"] {
display: flex;
flex: 1;
min-height: 0;
width: 100%;
overflow: auto;
padding: max(48px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right))
max(32px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
background: var(--v2-background-bg-base);
color: var(--v2-text-text-base);
.server-connect-content {
display: flex;
flex-direction: column;
width: min(100%, 360px);
margin: auto;
gap: 16px;
}
.server-connect-brand {
width: 160px;
margin-inline: auto;
margin-block-end: 16px;
}
header {
text-align: center;
margin-block-end: 16px;
}
h1 {
font-size: 20px;
line-height: 28px;
font-weight: 530;
margin-block-end: 8px;
}
p {
font-size: 13px;
line-height: var(--line-height-base);
color: var(--v2-text-text-muted);
}
form,
.server-connect-scanner {
display: flex;
flex-direction: column;
gap: 20px;
}
.server-connect-field {
display: flex;
flex-direction: column;
gap: 8px;
}
label {
font-size: 13px;
line-height: var(--line-height-compact);
font-weight: 530;
}
[data-component="text-input-v2"] {
width: 100%;
height: 44px;
}
[data-slot="text-input-v2-input"] {
font-size: 16px;
line-height: 24px;
}
button {
min-height: 44px;
width: 100%;
}
.server-connect-error {
color: var(--v2-state-fg-danger);
}
footer {
text-align: center;
margin-block-start: 16px;
}
code {
display: inline-block;
margin-block-start: 12px;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
padding: 8px 16px;
font-size: 13px;
line-height: var(--line-height-base);
background: var(--v2-background-bg-layer-01);
user-select: all;
}
.server-connect-video {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 12px;
background: var(--v2-background-bg-deep);
}
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.server-connect-video [role="status"] {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
}
+179
View File
@@ -0,0 +1,179 @@
import { createResource, lazy, Show, Suspense } from "solid-js"
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode/ui/button"
import { TextInput } from "@opencode/ui/text-input"
import { Wordmark } from "@opencode/ui/wordmark"
import { useLanguage } from "@/runtime/i18n/language"
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 "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
export function ConnectServerScreen() {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const cameraSupported =
platform.platform === "web" && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
const [camera, cameraActions] = createResource(
async () => {
if (!cameraSupported || !navigator.mediaDevices.enumerateDevices) return false
const denied = await navigator.permissions?.query({ name: "camera" }).then(
(permission) => permission.state === "denied",
() => false,
)
if (denied) return false
return navigator.mediaDevices.enumerateDevices().then(
(devices) => devices.some((device) => device.kind === "videoinput"),
() => false,
)
},
{ initialValue: false },
)
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
const connectionError = () =>
language.t(
platform.platform === "web" && isMixedContent(location.href, state.url)
? "server.connect.mixedContent"
: "server.connect.failed",
)
const request = useMutation(() => ({
mutationFn: async () => {
const url = serverAddress(state.url)
if (!url) {
setState("error", language.t("server.connect.address.invalid"))
return
}
const http = { url, password: state.password || undefined }
const result = await check(http)
if (!result.healthy) {
setState("error", connectionError())
return
}
servers.add({ type: "http", http })
},
onError: () => setState("error", connectionError()),
}))
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("server.connect.title")}</h1>
<p>{language.t("server.connect.description")}</p>
</header>
<Show
when={!state.scanning}
fallback={
<Suspense fallback={<p role="status">{language.t("server.connect.camera.starting")}</p>}>
<PairingScanner
onCancel={() => {
setState("scanning", false)
void cameraActions.refetch()
}}
onScan={(pairing) => {
setState({
url: pairing.urls[0],
urls: pairing.urls,
password: pairing.password,
error: "",
scanning: false,
})
request.mutate()
}}
/>
</Suspense>
}
>
<form
onSubmit={(event) => {
event.preventDefault()
if (request.isPending) return
setState("error", "")
request.mutate()
}}
>
<div class="server-connect-field">
<label for="server-connect-url">{language.t("dialog.server.add.url")}</label>
<TextInput
id="server-connect-url"
name="server"
dir="ltr"
type="text"
inputMode="url"
autocomplete="url"
autocapitalize="off"
spellcheck={false}
required
appearance="large"
list="server-connect-addresses"
placeholder={language.t("dialog.server.add.placeholder")}
value={state.url}
disabled={request.isPending}
aria-describedby={state.error ? "server-connect-error" : undefined}
onInput={(event) => setState({ url: event.currentTarget.value, error: "" })}
/>
<datalist id="server-connect-addresses">
{state.urls.map((url) => (
<option value={url} />
))}
</datalist>
</div>
<div class="server-connect-field">
<label for="server-connect-password">{language.t("dialog.server.add.password")}</label>
<TextInput
id="server-connect-password"
name="password"
type="password"
autocomplete="current-password"
appearance="large"
value={state.password}
disabled={request.isPending}
onInput={(event) => setState({ password: event.currentTarget.value, error: "" })}
/>
</div>
<Show when={state.error}>
<p id="server-connect-error" class="server-connect-error" role="alert">
{state.error}
</p>
</Show>
<Button type="submit" variant="contrast" size="large" disabled={request.isPending || !state.url.trim()}>
{language.t(request.isPending ? "dialog.server.add.checking" : "server.connect.button")}
</Button>
</form>
<Show when={platform.platform === "web"}>
<Button
variant="neutral"
size="large"
disabled={request.isPending || !camera.latest}
aria-describedby={!camera.latest && !camera.loading ? "server-connect-camera-unavailable" : undefined}
onClick={() => setState("scanning", true)}
>
{language.t("server.connect.scan")}
</Button>
<Show when={!camera.latest && !camera.loading}>
<p id="server-connect-camera-unavailable">
{language.t(
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
)}
</p>
</Show>
</Show>
<footer>
<p>{language.t("server.connect.pair.description")}</p>
<code dir="ltr">opencode pair</code>
</footer>
</Show>
</div>
</main>
)
}
+14 -8
View File
@@ -3,7 +3,7 @@ import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { BrowserAttachmentsProvider } from "@/session/browser/attachments"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { LayoutProvider } from "@/shell/state/layout"
@@ -15,6 +15,9 @@ export const File = lazy(() => import("@opencode/session-ui/file").then((module)
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
const ConnectServerScreen = lazy(() =>
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectServerScreen })),
)
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
@@ -71,13 +74,16 @@ function TargetServerRoute(props: ParentProps) {
}
function AppLayout(props: ParentProps) {
const servers = useServers()
return (
<LayoutProvider>
<SettingsSurfaceProvider>
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
</SettingsSurfaceProvider>
</LayoutProvider>
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
</SettingsSurfaceProvider>
</LayoutProvider>
</Show>
)
}
+4 -4
View File
@@ -165,7 +165,7 @@ export const layoutSchema = Persistence.struct({
sessionView: Persistence.record(Persistence.fallback(sessionViewSchema, () => ({ scroll: {} }))),
home: Persistence.struct({
selection: Persistence.struct({
server: TabStorage.ServerKey,
server: Schema.optional(TabStorage.ServerKey),
directory: Schema.optional(Schema.String),
}),
}),
@@ -223,7 +223,7 @@ export const layoutPersistence = Persistence.migrate(
),
)
export function initialLayout(server: ServerConnection.Key): typeof layoutSchema.Type {
export function initialLayout(server?: ServerConnection.Key): typeof layoutSchema.Type {
return {
sidebar: { opened: false, width: DEFAULT_SIDEBAR_WIDTH, workspaces: {}, workspacesDefault: false },
terminal: { height: DEFAULT_TERMINAL_HEIGHT, opened: false },
@@ -233,7 +233,7 @@ export function initialLayout(server: ServerConnection.Key): typeof layoutSchema
mobileSidebar: { opened: false },
sessionTabs: {},
sessionView: {},
home: { selection: { server } },
home: { selection: server ? { server } : {} },
}
}
@@ -247,7 +247,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const [store, setStore, _, ready] = persisted(
{ ...Persist.global("layout"), previousKey: "layout.v6" },
layoutPersistence,
initialLayout(ServerConnection.key(servers.list[0])),
initialLayout(servers.list[0] ? ServerConnection.key(servers.list[0]) : undefined),
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
+3 -1
View File
@@ -6,7 +6,9 @@ import { collectFiles } from "./files"
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
if (options?.skipBuild) return compress({})
const root = path.resolve(import.meta.dirname, "../../app")
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
await $`bun run build`
.cwd(root)
.env({ ...process.env, OPENCODE_CHANNEL: channel, VITE_OPENCODE_SERVER_MODE: "origin" })
const assets = Object.fromEntries(
await Promise.all(
(await collectFiles(path.join(root, "dist")))
+18 -1
View File
@@ -431,7 +431,24 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
}),
],
}),
Spec.make("pair", { description: "Show server pairing information" }),
Spec.make("pair", {
description: "Show server pairing information",
params: {
url: Flag.string("url").pipe(
Flag.withDescription("Advertise an external HTTP(S) server URL in the pairing QR code"),
Flag.mapTryCatch(
(value) => {
const url = new URL(value)
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
throw new Error("Invalid pairing URL")
return url.href.replace(/\/+$/, "")
},
() => "Expected an HTTP(S) server URL without credentials, query parameters, or a fragment",
),
Flag.optional,
),
},
}),
Spec.make("serve", {
description: "Start the v2 API and web server",
params: {
+9 -6
View File
@@ -1,5 +1,5 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { renderUnicodeCompact } from "uqr"
@@ -9,13 +9,15 @@ import { ServiceConfig } from "../../services/service-config"
export default Runtime.handler(
Commands.commands.pair,
Effect.fn("cli.pair")(function* () {
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const password = yield* ServiceConfig.password()
const server = yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.get(),
)
const info = { urls: server.urls, username: "opencode", password }
const urls = Option.isSome(input.url)
? [input.url.value]
: (yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.get(),
)).urls
const info = { urls, username: "opencode", password }
process.stdout.write(
[
"",
@@ -34,6 +36,7 @@ export default Runtime.handler(
].join(EOL) + EOL,
)
if (Option.isSome(input.url)) return
const hostname = new URL(endpoint.url).hostname
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
process.stderr.write(` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`)