From 90dd682e669c040f658e3f64c4f013337b8eebbf Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:32:17 +0800 Subject: [PATCH] feat(app): configure initial servers and add QR pairing (#47799) --- bun.lock | 5 + infra/webapp.ts | 1 + packages/app/README.md | 35 +++- packages/app/package.json | 1 + packages/app/src/app.tsx | 2 +- packages/app/src/entry.tsx | 24 +-- packages/app/src/env.d.ts | 1 + packages/app/src/home/projects/view.tsx | 42 ++-- packages/app/src/home/sessions/controller.tsx | 6 +- packages/app/src/home/sessions/region.tsx | 81 ++++---- packages/app/src/runtime/i18n/en.ts | 19 ++ packages/app/src/runtime/platform/web.ts | 2 +- packages/app/src/runtime/server/registry.tsx | 2 +- packages/app/src/servers/connect/browser.ts | 12 ++ packages/app/src/servers/connect/dialog.tsx | 17 +- packages/app/src/servers/connect/pairing.ts | 28 +++ packages/app/src/servers/connect/scanner.tsx | 62 ++++++ packages/app/src/servers/connect/screen.css | 119 ++++++++++++ packages/app/src/servers/connect/screen.tsx | 179 ++++++++++++++++++ packages/app/src/shell/routes/routes.tsx | 22 ++- packages/app/src/shell/state/layout.tsx | 8 +- packages/cli/script/app-assets.ts | 4 +- packages/cli/src/commands/commands.ts | 19 +- packages/cli/src/commands/handlers/pair.ts | 15 +- 24 files changed, 609 insertions(+), 97 deletions(-) create mode 100644 packages/app/src/servers/connect/browser.ts create mode 100644 packages/app/src/servers/connect/pairing.ts create mode 100644 packages/app/src/servers/connect/scanner.tsx create mode 100644 packages/app/src/servers/connect/screen.css create mode 100644 packages/app/src/servers/connect/screen.tsx diff --git a/bun.lock b/bun.lock index 1591a1d85ad..df5c15d1e40 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/infra/webapp.ts b/infra/webapp.ts index 7110a7b60b4..01001da851d 100644 --- a/infra/webapp.ts +++ b/infra/webapp.ts @@ -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, diff --git a/packages/app/README.md b/packages/app/README.md index 01c36687488..6f9cf4b8129 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -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 diff --git a/packages/app/package.json b/packages/app/package.json index 0292828d197..508f9f4774c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index ecfce010bdd..f2a443ae777 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -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 router?: Component diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index cc866e12a3a..d81c5df221e 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -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( () => ( {standalone && } diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index fd08a28c2c9..a55450a7e7a 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -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" diff --git a/packages/app/src/home/projects/view.tsx b/packages/app/src/home/projects/view.tsx index c2b4ed95c6f..8576b120066 100644 --- a/packages/app/src/home/projects/view.tsx +++ b/packages/app/src/home/projects/view.tsx @@ -198,26 +198,30 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) { 1} fallback={ -
- 0} - fallback={} - > - - - props.onChooseProject(props.servers[0])} + + {(server) => ( +
+ 0} + fallback={} > - - {props.language.t("home.project.add")} - - - -
+ + + props.onChooseProject(server())} + > + + {props.language.t("home.project.add")} + + +
+
+ )} +
} >
diff --git a/packages/app/src/home/sessions/controller.tsx b/packages/app/src/home/sessions/controller.tsx index 1c6e698e9ed..a6dde844ea6 100644 --- a/packages/app/src/home/sessions/controller.tsx +++ b/packages/app/src/home/sessions/controller.tsx @@ -311,8 +311,10 @@ export function createHomeSessionsController(home: HomeController) { dialog.show(() => ), }, 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) + }, }, } } diff --git a/packages/app/src/home/sessions/region.tsx b/packages/app/src/home/sessions/region.tsx index 05f619960e2..1b679d5d019 100644 --- a/packages/app/src/home/sessions/region.tsx +++ b/packages/app/src/home/sessions/region.tsx @@ -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 ( - + + {(server) => ( + + )} + ) } diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 6d382df9284..72e463c693c 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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": diff --git a/packages/app/src/runtime/platform/web.ts b/packages/app/src/runtime/platform/web.ts index fa9cab3cbdf..bd48f32a214 100644 --- a/packages/app/src/runtime/platform/web.ts +++ b/packages/app/src/runtime/platform/web.ts @@ -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 diff --git a/packages/app/src/runtime/server/registry.tsx b/packages/app/src/runtime/server/registry.tsx index d1e429b787b..4de6aeaf692 100644 --- a/packages/app/src/runtime/server/registry.tsx +++ b/packages/app/src/runtime/server/registry.tsx @@ -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 }) => { diff --git a/packages/app/src/servers/connect/browser.ts b/packages/app/src/servers/connect/browser.ts new file mode 100644 index 00000000000..4e87e5fcffd --- /dev/null +++ b/packages/app/src/servers/connect/browser.ts @@ -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)) +} diff --git a/packages/app/src/servers/connect/dialog.tsx b/packages/app/src/servers/connect/dialog.tsx index 68f763404fd..f24a6f3ae06 100644 --- a/packages/app/src/servers/connect/dialog.tsx +++ b/packages/app/src/servers/connect/dialog.tsx @@ -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} /> - {form.state.error()} + + {form.state.error()} +
@@ -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") { diff --git a/packages/app/src/servers/connect/pairing.ts b/packages/app/src/servers/connect/pairing.ts new file mode 100644 index 00000000000..66e3c6d3802 --- /dev/null +++ b/packages/app/src/servers/connect/pairing.ts @@ -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 } +} diff --git a/packages/app/src/servers/connect/scanner.tsx b/packages/app/src/servers/connect/scanner.tsx new file mode 100644 index 00000000000..efb9e945b44 --- /dev/null +++ b/packages/app/src/servers/connect/scanner.tsx @@ -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>) => 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 ( +
+

{language.t("server.connect.scan.description")}

+
+ {video} + + {language.t("server.connect.camera.starting")} + +
+ + + + +
+ ) +} diff --git a/packages/app/src/servers/connect/screen.css b/packages/app/src/servers/connect/screen.css new file mode 100644 index 00000000000..a63cbcaecf9 --- /dev/null +++ b/packages/app/src/servers/connect/screen.css @@ -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; + } +} diff --git a/packages/app/src/servers/connect/screen.tsx b/packages/app/src/servers/connect/screen.tsx new file mode 100644 index 00000000000..c2488728370 --- /dev/null +++ b/packages/app/src/servers/connect/screen.tsx @@ -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 ( +
+
+ +
+

{language.t("server.connect.title")}

+

{language.t("server.connect.description")}

+
+ {language.t("server.connect.camera.starting")}

}> + { + setState("scanning", false) + void cameraActions.refetch() + }} + onScan={(pairing) => { + setState({ + url: pairing.urls[0], + urls: pairing.urls, + password: pairing.password, + error: "", + scanning: false, + }) + request.mutate() + }} + /> + + } + > +
{ + event.preventDefault() + if (request.isPending) return + setState("error", "") + request.mutate() + }} + > +
+ + setState({ url: event.currentTarget.value, error: "" })} + /> + + {state.urls.map((url) => ( + +
+
+ + setState({ password: event.currentTarget.value, error: "" })} + /> +
+ + + + +
+ + + +

+ {language.t( + window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure", + )} +

+
+
+
+

{language.t("server.connect.pair.description")}

+ opencode pair +
+
+
+
+ ) +} diff --git a/packages/app/src/shell/routes/routes.tsx b/packages/app/src/shell/routes/routes.tsx index 3f6afa1c862..be8820dd08d 100644 --- a/packages/app/src/shell/routes/routes.tsx +++ b/packages/app/src/shell/routes/routes.tsx @@ -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 ( - - - - {props.children} - - - + 0} fallback={}> + + + + {props.children} + + + + ) } diff --git a/packages/app/src/shell/state/layout.tsx b/packages/app/src/shell/state/layout.tsx index 1210923b49e..4cf523b55c7 100644 --- a/packages/app/src/shell/state/layout.tsx +++ b/packages/app/src/shell/state/layout.tsx @@ -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, diff --git a/packages/cli/script/app-assets.ts b/packages/cli/script/app-assets.ts index ee4b3aa1c68..b57843f943d 100644 --- a/packages/cli/script/app-assets.ts +++ b/packages/cli/script/app-assets.ts @@ -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"))) diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 732dee7ba10..d6277087913 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -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: { diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index 8dee29b235b..a890a42b190 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -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) { 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}`)