diff --git a/bun.lock b/bun.lock index de8059a4115..5cb628a1d3f 100644 --- a/bun.lock +++ b/bun.lock @@ -92,6 +92,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", diff --git a/packages/app/README.md b/packages/app/README.md index 6f9cf4b8129..7d865ee66f4 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -95,7 +95,7 @@ In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPEN 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`. +or choose **Scan QR code** to open the camera and read the 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. @@ -103,7 +103,7 @@ HTTPS (or localhost) and browser permission. Saved offline servers continue to u 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 +opencode pair --url https://opencode.example.com ``` This replaces the addresses printed and encoded in the QR code while retaining the local service password. diff --git a/packages/app/e2e/regression/pairing.spec.ts b/packages/app/e2e/regression/pairing.spec.ts new file mode 100644 index 00000000000..661aba2ec02 --- /dev/null +++ b/packages/app/e2e/regression/pairing.spec.ts @@ -0,0 +1,54 @@ +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#${Buffer.from(JSON.stringify({ username: "opencode", password })).toString("base64url")}`) + 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 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([]) +}) diff --git a/packages/app/package.json b/packages/app/package.json index 4e4847fc166..909e290fefb 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -91,6 +91,7 @@ "remeda": "catalog:", "solid-js": "catalog:", "solid-presence": "0.2.0", - "tailwindcss": "4.3.3" + "tailwindcss": "4.3.3", + "uqr": "0.1.3" } } diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 8e0286a7522..fdb7780cf56 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -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) => ( - - + const Root = (rootProps: ParentProps) => { + const location = useLocation() + // Pairing saves credentials before mounting any server connections or health checks. + return ( + <> - - - - - {props.children} - {rootProps.children} - - - - - ) + + + + + + + + {props.children} + {rootProps.children} + + + + + + + ) + } return ( | void + /** Prevent the local display from sleeping while the desktop app is running. */ + getKeepScreenActive?(): Promise + setKeepScreenActive?(enabled: boolean): Promise + /** Run a desktop-only menu action from the app chrome */ runDesktopMenuAction?(action: DesktopMenuAction): Promise | void @@ -124,6 +134,11 @@ type PlatformBase = { /** Native browser pane hosted by the platform (desktop only). */ browserPane?: BrowserPanePlatform + + /** Pair another device with the local desktop server. */ + pair?: { + info(): Promise + } } export type Platform = PlatformBase & diff --git a/packages/app/src/servers/connect/pairing.test.ts b/packages/app/src/servers/connect/pairing.test.ts new file mode 100644 index 00000000000..647e227a4df --- /dev/null +++ b/packages/app/src/servers/connect/pairing.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { decodePairingCode, 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://opencode.example.com:49709" + const url = new URL(pairingUrl(info, origin)) + + expect(url.origin).toBe(origin) + expect(url.pathname).toBe("/connect") + expect(url.search).toBe("") + expect(url.hash).not.toBe("") + expect(decodePairingUrl(url.hash, origin)).toEqual({ urls: [origin], password: info.password }) + expect(decodePairingCode(JSON.stringify(info))).toBeUndefined() + }) + + test("keeps accepting query pairing data", () => { + const info = { urls: ["http://192.168.1.2:4096"], username: "opencode", password: "a+b & café" } + expect(decodePairingUrl(`?data=${encodeURIComponent(JSON.stringify(info))}`)).toEqual({ + urls: info.urls, + password: info.password, + }) + }) + + 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() + }) +}) diff --git a/packages/app/src/servers/connect/pairing.ts b/packages/app/src/servers/connect/pairing.ts index 66e3c6d3802..e659240b690 100644 --- a/packages/app/src/servers/connect/pairing.ts +++ b/packages/app/src/servers/connect/pairing.ts @@ -1,9 +1,10 @@ import { Option, Schema } from "effect" +import { base64Encode } from "@opencode/util/encode" 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 +20,35 @@ 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: { username: "opencode"; password: string }, host: string) { + return `${new URL("/connect", host)}#${base64Encode(JSON.stringify(value))}` +} + +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))), origin) +} diff --git a/packages/app/src/servers/connect/screen.tsx b/packages/app/src/servers/connect/screen.tsx index b860b11d8ec..0212e2a457c 100644 --- a/packages/app/src/servers/connect/screen.tsx +++ b/packages/app/src/servers/connect/screen.tsx @@ -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 type { decodePairingCode } from "./pairing" import { 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>; 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()), })) diff --git a/packages/app/src/settings/pages.ts b/packages/app/src/settings/pages.ts index 2aad8a9812f..cee7e82e22b 100644 --- a/packages/app/src/settings/pages.ts +++ b/packages/app/src/settings/pages.ts @@ -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", diff --git a/packages/app/src/settings/pairing/pairing.tsx b/packages/app/src/settings/pairing/pairing.tsx new file mode 100644 index 00000000000..4f2ad52ea13 --- /dev/null +++ b/packages/app/src/settings/pairing/pairing.tsx @@ -0,0 +1,193 @@ +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), + })) + + return ( + <> +
+
+
+

{language.t("settings.pairing.title")}

+ {language.t("pair.description")} +
+
+
+ +
+
+ + + + + +
+ + screenActivity.mutate(enabled)} + > + {language.t("pair.screenActive.title")} + + +
+
+
+ + + + + + +
+
+ + ) +} + +function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host: string }) { + const language = useLanguage() + const platform = usePlatform() + const url = createMemo(() => { + if (!props.info) return + return pairingUrl({ username: props.info.username, password: props.info.password }, props.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 ( + + + + + + + + ) +} diff --git a/packages/app/src/settings/search-catalog.ts b/packages/app/src/settings/search-catalog.ts index 62626edf2fa..95289681a70 100644 --- a/packages/app/src/settings/search-catalog.ts +++ b/packages/app/src/settings/search-catalog.ts @@ -20,6 +20,20 @@ export const clientSettings: Entry[] = [ { 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 local", + 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" }, diff --git a/packages/app/src/settings/shell.tsx b/packages/app/src/settings/shell.tsx index 42854b04cc0..4d0a267b2ff 100644 --- a/packages/app/src/settings/shell.tsx +++ b/packages/app/src/settings/shell.tsx @@ -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() { surface.openServer(ServerConnection.key(server))} /> )) const groups = createMemo(() => [ - { 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() { + + + diff --git a/packages/app/src/settings/surface.tsx b/packages/app/src/settings/surface.tsx index 3c22031ef07..d830eb719c3 100644 --- a/packages/app/src/settings/surface.tsx +++ b/packages/app/src/settings/surface.tsx @@ -11,6 +11,7 @@ export type SettingsRootTab = | "appearance" | "notifications" | "shortcuts" + | "pairing" | "projects" | "workspaces" | "providers" @@ -44,6 +45,7 @@ const rootTabs: Record = { appearance: true, notifications: true, shortcuts: true, + pairing: true, projects: true, workspaces: true, providers: true, diff --git a/packages/app/src/shell/commands/desktop.tsx b/packages/app/src/shell/commands/desktop.tsx index 0c217ea0e35..6783746d77c 100644 --- a/packages/app/src/shell/commands/desktop.tsx +++ b/packages/app/src/shell/commands/desktop.tsx @@ -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 +} diff --git a/packages/app/src/shell/routes/routes.tsx b/packages/app/src/shell/routes/routes.tsx index be8820dd08d..40261674ed5 100644 --- a/packages/app/src/shell/routes/routes.tsx +++ b/packages/app/src/shell/routes/routes.tsx @@ -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) @@ -26,6 +28,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,29 +36,48 @@ export function preloadRoute(url: string) { export function AppRoutes() { return ( - - - - ( - - - - - } - > - - - - - - )} - /> - - + <> + + + + + ( + + + + + } + > + + + + + + )} + /> + + + + ) +} + +function ConnectRoute() { + const navigate = useNavigate() + const servers = useServers() + const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash, location.origin) + onMount(() => { + if (!pairing) return + servers.add({ type: "http", http: { url: pairing.urls[0], password: pairing.password } }) + navigate("/", { replace: true }) + }) + return ( + + navigate("/", { replace: true })} /> + ) } @@ -79,6 +101,7 @@ function AppLayout(props: ParentProps) { 0} fallback={}> + {props.children} diff --git a/packages/app/src/shell/state/layout.test.ts b/packages/app/src/shell/state/layout.test.ts index e69bf23f28b..e214ba00986 100644 --- a/packages/app/src/shell/state/layout.test.ts +++ b/packages/app/src/shell/state/layout.test.ts @@ -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) diff --git a/packages/app/src/shell/state/layout.tsx b/packages/app/src/shell/state/layout.tsx index 87027d6d07a..e3221d1090b 100644 --- a/packages/app/src/shell/state/layout.tsx +++ b/packages/app/src/shell/state/layout.tsx @@ -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 } @@ -107,6 +108,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") diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx index b068bbfd4a5..6c352443350 100644 --- a/packages/app/src/shell/titlebar/titlebar.tsx +++ b/packages/app/src/shell/titlebar/titlebar.tsx @@ -303,6 +303,7 @@ export function Titlebar(props: { return } case "settings": + case "connect": case "home": { const selection = layout.home.selection() const conn = diff --git a/packages/cli/src/services/web-ui.ts b/packages/cli/src/services/web-ui.ts index 4378dd8d075..7043e904257 100644 --- a/packages/cli/src/services/web-ui.ts +++ b/packages/cli/src/services/web-ui.ts @@ -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 (api: Effect.Effect) => - 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) { diff --git a/packages/cli/test/web-ui.test.ts b/packages/cli/test/web-ui.test.ts index fa0e7270761..8e119091cc7 100644 --- a/packages/cli/test/web-ui.test.ts +++ b/packages/cli/test/web-ui.test.ts @@ -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": "connect", + "_assets/app.js": "console.log('connect')", + "_assets/app.css": "body { color: black; }", + "icons/icon.svg": "", + "font.woff2": new Uint8Array([0, 1, 2, 255]), + "sw.js": "service worker", + }, + }) + const server = yield* ServerProcess.start( + { 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") diff --git a/packages/desktop/scripts/dev.ts b/packages/desktop/scripts/dev.ts index 30023a057ba..d7e3f53f19a 100644 --- a/packages/desktop/scripts/dev.ts +++ b/packages/desktop/scripts/dev.ts @@ -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 diff --git a/packages/desktop/src/main/ipc-handlers/app.ts b/packages/desktop/src/main/ipc-handlers/app.ts index f605f4179d5..dcfeb85c11c 100644 --- a/packages/desktop/src/main/ipc-handlers/app.ts +++ b/packages/desktop/src/main/ipc-handlers/app.ts @@ -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() const runFork = Effect.runForkWith(yield* Effect.context()) return AppRpcs.of({ AppAwaitInitialization: () => background.connection.pipe(Effect.map(SidecarCredentials.ready)), @@ -68,6 +75,10 @@ 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)), }) }), ) @@ -75,3 +86,10 @@ export const appHandlers = AppRpcs.toLayer( function promise(evaluate: () => A | Promise) { return Effect.tryPromise(async () => evaluate()).pipe(Effect.orDie) } + +function pair(evaluate: () => Promise) { + return Effect.tryPromise({ + try: evaluate, + catch: (cause) => (cause instanceof Error ? cause.message : String(cause)), + }) +} diff --git a/packages/desktop/src/main/native/screen-activity.ts b/packages/desktop/src/main/native/screen-activity.ts new file mode 100644 index 00000000000..af8f5bcd907 --- /dev/null +++ b/packages/desktop/src/main/native/screen-activity.ts @@ -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, + } +} diff --git a/packages/desktop/src/main/service/background-service.ts b/packages/desktop/src/main/service/background-service.ts index 72aeb3a70e9..dbf1f575494 100644 --- a/packages/desktop/src/main/service/background-service.ts +++ b/packages/desktop/src/main/service/background-service.ts @@ -44,7 +44,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 })), }) diff --git a/packages/desktop/src/main/service/pairing.ts b/packages/desktop/src/main/service/pairing.ts new file mode 100644 index 00000000000..b682360dc07 --- /dev/null +++ b/packages/desktop/src/main/service/pairing.ts @@ -0,0 +1,21 @@ +import { SidecarCredentials } from "./sidecar-credentials" + +export function createPairing() { + 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) => { + 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()) + return { info } +} diff --git a/packages/desktop/src/main/storage/keys.ts b/packages/desktop/src/main/storage/keys.ts index 40183f430c3..5604a7f189d 100644 --- a/packages/desktop/src/main/storage/keys.ts +++ b/packages/desktop/src/main/storage/keys.ts @@ -3,6 +3,7 @@ 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" export const BUNDLED_CLI_VERSION_KEY = "bundledCliVersion" diff --git a/packages/desktop/src/renderer/api-types.ts b/packages/desktop/src/renderer/api-types.ts index e8f9ca5e8ea..0277d4a8fcb 100644 --- a/packages/desktop/src/renderer/api-types.ts +++ b/packages/desktop/src/renderer/api-types.ts @@ -16,6 +16,7 @@ import type { ServerReadyData, TitlebarTheme, } from "../shared/ipc-contract" +import type { PairingInfo } from "../shared/ipc-rpc/app" export type WslServersAPI = WslServersPlatform export type UpdaterAPI = { @@ -89,4 +90,7 @@ export type ElectronAPI = { setForceFocus(enabled: boolean): Promise recordFatalRendererError(error: FatalRendererError): Promise setNativeTranslations(bundle: DesktopNativeBundle): Promise + pairInfo(): Promise + getKeepScreenActive(): Promise + setKeepScreenActive(enabled: boolean): Promise } diff --git a/packages/desktop/src/renderer/api.ts b/packages/desktop/src/renderer/api.ts index a1b83542520..63a019b55a6 100644 --- a/packages/desktop/src/renderer/api.ts +++ b/packages/desktop/src/renderer/api.ts @@ -161,4 +161,7 @@ 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 }), } diff --git a/packages/desktop/src/renderer/platform/index.ts b/packages/desktop/src/renderer/platform/index.ts index 745d6aa7918..2dca8f9715c 100644 --- a/packages/desktop/src/renderer/platform/index.ts +++ b/packages/desktop/src/renderer/platform/index.ts @@ -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,9 @@ export function createDesktopPlatform( checkAppExists: async (appName) => { return api.checkAppExists(appName) }, + pair: { + info: () => api.pairInfo(), + }, } } diff --git a/packages/desktop/src/shared/ipc-rpc/app.ts b/packages/desktop/src/shared/ipc-rpc/app.ts index f964e8dc198..10b5d056d75 100644 --- a/packages/desktop/src/shared/ipc-rpc/app.ts +++ b/packages/desktop/src/shared/ipc-rpc/app.ts @@ -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,12 @@ 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 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 +81,7 @@ export const AppRpcs = RpcGroup.make( AppRecordFatalRendererError, AppSetNativeTranslations, AppRelaunch, + AppPairInfo, + AppGetKeepScreenActive, + AppSetKeepScreenActive, ) diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 3a70dbd39bf..87a8c391204 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -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* ( return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname) } const application = yield* Ref.make(Option.none()) + 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* ( 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 { diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index 82720818d3d..f3161cb023e 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -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( @@ -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(() =>