From 165cd2e832574ccdea3b311fe1a0f9db2e5c883e Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:56:17 +1000 Subject: [PATCH] feat(desktop): pair devices through remote access (#55306) --- packages/gui-extensions/README.md | 86 +++- .../gui-extensions/src/pairing/contract.ts | 12 +- .../gui-extensions/src/pairing/i18n/en.ts | 37 +- packages/gui-extensions/src/pairing/index.ts | 2 - packages/gui-extensions/src/pairing/main.ts | 38 ++ .../src/pairing/page.stories.tsx | 37 +- packages/gui-extensions/src/pairing/page.tsx | 432 +++++++----------- .../gui-extensions/src/pairing/remote.test.ts | 19 + packages/gui-extensions/src/pairing/remote.ts | 7 + .../gui-extensions/src/pairing/renderer.tsx | 10 +- .../gui-extensions/src/pairing/routes.test.ts | 63 --- packages/gui-extensions/src/pairing/routes.ts | 65 --- .../mocks/gui-extensions/pairing.tsx | 53 ++- 13 files changed, 375 insertions(+), 486 deletions(-) create mode 100644 packages/gui-extensions/src/pairing/remote.test.ts create mode 100644 packages/gui-extensions/src/pairing/remote.ts delete mode 100644 packages/gui-extensions/src/pairing/routes.test.ts delete mode 100644 packages/gui-extensions/src/pairing/routes.ts diff --git a/packages/gui-extensions/README.md b/packages/gui-extensions/README.md index 5900834e901..d7002dd2859 100644 --- a/packages/gui-extensions/README.md +++ b/packages/gui-extensions/README.md @@ -192,15 +192,15 @@ Pairing's shipping page uses a query, not `createLatest`. It passes the query's ```tsx const screenActive = useQuery(() => ({ - queryKey: [ctx.id, "screen-active", display()?.generation], + queryKey: [ctx.id, "screen-active", main()?.generation], queryFn: (input) => { - const live = display() + const live = main() if (!live) throw new Error("Pairing's main side is not active") return live.value.screenActive({ signal: input.signal }) }, - enabled: !!display(), + enabled: !!main(), gcTime: 0, })) ``` @@ -328,13 +328,13 @@ const act = (name: "check" | "install") => { } ``` -The pairing settings page waits for its main side only where it needs it. The link and QR code come from the local server's `ServerRef`, so they render without main; the display setting renders only while main is active, so it never offers a switch that cannot answer: +The pairing settings page waits for its main side only where it needs it. Pairing codes come from the local server's `ServerRef`, so they never wait for main; remote access and the display setting render only while main is active, so the page never offers a switch that cannot answer: - + ```tsx -// Only the display setting needs pairing's main side; the link and QR code come from the server, so they never wait. -const display = () => { +// Remote access and the display setting need pairing's main side; codes come from the server, so they never wait. +const main = () => { const live = props.pairing() return live.status === "active" ? live : undefined @@ -441,9 +441,9 @@ legacy: ["summary"], ## Build your first extension -Pairing shows another device how to reach this machine's server, and keeps the display awake. It is small and uses both processes: a main entry that owns a power-save blocker, and a window entry that adds a settings page and a command. The window asks the local server for its addresses and pairing codes through `ctx.servers`: that server's `ServerRef` is already authenticated in the window, so its credentials never leave main. Each block below is its shipping source. +Pairing connects another device to this machine's server from anywhere, and keeps the display awake. It is small and uses both processes: a main entry that turns on remote access through the CLI and owns a power-save blocker, and a window entry that adds a settings page and a command. The window asks the local server for its remote address and pairing codes through `ctx.servers`: that server's `ServerRef` is already authenticated in the window, so its credentials never leave main. Each block below is its shipping source. -1. **Define the contract.** `contract.ts` holds the tokens other code may import, here the Ipc between pairing's main and window entries. It carries only what needs the main process, the display sleep blocker. The schemas type both sides and encode every value. +1. **Define the contract.** `contract.ts` holds the tokens other code may import, here the Ipc between pairing's main and window entries. It carries only what needs the main process: remote access, a setting of the background service, and the display sleep blocker. The schemas type both sides and encode every value. @@ -452,12 +452,20 @@ import { Schema } from "effect" import { Ipc } from "../sdk" /** - * Keeps this machine's display awake while another device uses it. The window asks the local server for addresses and - * pairing codes through its own `ServerRef`; only the display sleep blocker needs the main process. + * Turns on remote access and keeps this machine's display awake while another device uses it. The window asks the + * local server for its remote address and pairing codes through its own `ServerRef`; remote access, which changes + * the background service's settings through the CLI, and the display sleep blocker need the main process. */ export const Pairing = Ipc.define({ id: "pairing", methods: { + /** Whether remote access is on: the background service serves an OpenTunnel address. */ + remote: { output: Schema.Boolean }, + /** + * Turns remote access on or off, then starts the background service again. Turning it on the first time creates + * the tunnel, which can take a minute. + */ + setRemote: { input: Schema.Boolean }, /** Whether main holds the display sleep blocker. */ screenActive: { output: Schema.Boolean }, /** Holds or releases the display sleep blocker, and remembers the choice. */ @@ -466,7 +474,7 @@ export const Pairing = Ipc.define({ }) ``` -2. **Write the definition.** Pairing provides its Ipc, so its window entry reads it as `ctx.uses.pairing` with no second declaration. Whether main keeps the display awake is a main store, imported once from the key the desktop kept it under before. The addresses pairing links use are a window store, which the settings page reads. +2. **Write the definition.** Pairing provides its Ipc, so its window entry reads it as `ctx.uses.pairing` with no second declaration. Whether main keeps the display awake is a main store, imported once from the key the desktop kept it under before. @@ -482,18 +490,17 @@ export default Extension.define({ stores: { // Whether main keeps the display awake; stored before in the desktop's own settings namespace. keepScreenActive: Store.main(Schema.Boolean, false, { state: ["opencode.settings", "keepScreenActive"] }), - // An address of this computer the server cannot see (a VPN, tunnel or proxy), and the address links use. - links: Store.global(Schema.Struct({ custom: Schema.String, selected: Schema.String }), { custom: "", selected: "" }), }, i18n: { en }, }) ``` -3. **Provide it from main.** `MainSetup` types `ctx.stores`, which main reads synchronously. A finalizer releases the blocker when the instance goes away. +3. **Provide it from main.** `MainSetup` types `ctx.stores`, which main reads synchronously. Remote access runs `opencode service` through `ctx.cli`, with the caller's signal for a read and `ctx.scope.signal` for a change a closing window must not cancel. A finalizer releases the blocker when the instance goes away. ```ts +import { execFile } from "node:child_process" import { powerSaveBlocker } from "electron" import type { MainSetup } from "../sdk/main" import { Pairing } from "./contract" @@ -502,9 +509,15 @@ import type definition from "./index" /** The display sleep blocker this instance holds, if any. */ type Blocker = { id?: number } +/** The last `opencode service` command; the next one starts after it settles. */ +type Queue = { tail: Promise } + const setup: MainSetup = (ctx) => { const stored = ctx.stores.keepScreenActive + const cli = ctx.cli + const log = ctx.log const blocker: Blocker = {} + const queue: Queue = { tail: Promise.resolve() } const release = () => { if (blocker.id === undefined) return @@ -519,10 +532,41 @@ const setup: MainSetup = (ctx) => { stored.set(enabled) } + // Remote access is a setting of the background service, so it changes through the CLI the app runs, as with + // `opencode pair --remote`. A change restarts the service, so commands run one at a time. + const service = (args: readonly string[], signal: AbortSignal) => { + const run = queue.tail.then( + () => + new Promise((resolve, reject) => { + execFile( + cli.command[0] ?? "opencode", + [...cli.command.slice(1), "service", ...args], + { signal, windowsHide: true }, + (error, stdout, stderr) => { + if (!error) return resolve(stdout.trim()) + log.write("error", "pairing service command failed", { args, error: error.message, stderr }) + reject(error) + }, + ) + }), + ) + + queue.tail = run.catch(() => undefined) + + return run + } + if (stored.value) keepScreenActive(true) ctx.scope.addFinalizer(release) ctx.provide(Pairing, { + remote: async (_, caller) => (await service(["get", "remote"], caller.signal)) === "true", + // Changing the setting stops the service. Starting it here brings it back, with the tunnel when remote access is + // on. A window that closes meanwhile does not cancel the change. + setRemote: async (enabled) => { + await service(["set", "remote", String(enabled)], ctx.scope.signal) + await service(["start"], ctx.scope.signal) + }, screenActive: () => blocker.id !== undefined && powerSaveBlocker.isStarted(blocker.id), setScreenActive: (enabled) => keepScreenActive(enabled), }) @@ -531,7 +575,7 @@ const setup: MainSetup = (ctx) => { export default setup ``` -4. **Write the window entry.** Pairing exists only on desktop, so setup returns at once on the web. The settings page is heavy, so it loads behind `lazy()` and compiles while the app idles; its search entries are indexed without mounting it. The page receives the desktop's own server, found through `ctx.servers` by `builtin`, and `ctx.uses.pairing`. The search entry for the display setting, like the setting itself, exists only while main answers (see `display` under [Live and failure handling](#live-and-failure-handling)). +4. **Write the window entry.** Pairing exists only on desktop, so setup returns at once on the web. The settings page is heavy, so it loads behind `lazy()` and compiles while the app idles; its search entries are indexed without mounting it. The page receives the desktop's own server, found through `ctx.servers` by `builtin`, and `ctx.uses.pairing`. The search entries for remote access and the display setting, like the settings themselves, exist only while main answers (see `main` under [Live and failure handling](#live-and-failure-handling)). @@ -564,13 +608,19 @@ const setup: Setup = (ctx) => { return ctx.t("title") }, get entries() { - const pairingEntry = { id: "pairing", title: ctx.t("title"), keywords: "pair device qr local" } + const pairingEntry = { id: "pairing", title: ctx.t("title"), keywords: "pair device qr phone remote" } - // The display setting exists only while pairing's main side answers. + // Remote access and the display setting exist only while pairing's main side answers. if (pairing().status !== "active") return [pairingEntry] return [ pairingEntry, + { + id: "settings-remote-access", + title: ctx.t("remote.title"), + description: ctx.t("remote.description"), + keywords: "internet tunnel opentunnel", + }, { id: "settings-keep-screen-active", title: ctx.t("screenActive.title"), diff --git a/packages/gui-extensions/src/pairing/contract.ts b/packages/gui-extensions/src/pairing/contract.ts index 1bb46cbfee7..d9af38201dc 100644 --- a/packages/gui-extensions/src/pairing/contract.ts +++ b/packages/gui-extensions/src/pairing/contract.ts @@ -2,12 +2,20 @@ import { Schema } from "effect" import { Ipc } from "../sdk" /** - * Keeps this machine's display awake while another device uses it. The window asks the local server for addresses and - * pairing codes through its own `ServerRef`; only the display sleep blocker needs the main process. + * Turns on remote access and keeps this machine's display awake while another device uses it. The window asks the + * local server for its remote address and pairing codes through its own `ServerRef`; remote access, which changes + * the background service's settings through the CLI, and the display sleep blocker need the main process. */ export const Pairing = Ipc.define({ id: "pairing", methods: { + /** Whether remote access is on: the background service serves an OpenTunnel address. */ + remote: { output: Schema.Boolean }, + /** + * Turns remote access on or off, then starts the background service again. Turning it on the first time creates + * the tunnel, which can take a minute. + */ + setRemote: { input: Schema.Boolean }, /** Whether main holds the display sleep blocker. */ screenActive: { output: Schema.Boolean }, /** Holds or releases the display sleep blocker, and remembers the choice. */ diff --git a/packages/gui-extensions/src/pairing/i18n/en.ts b/packages/gui-extensions/src/pairing/i18n/en.ts index b0d11ddaf9f..fe9118ca3fe 100644 --- a/packages/gui-extensions/src/pairing/i18n/en.ts +++ b/packages/gui-extensions/src/pairing/i18n/en.ts @@ -1,35 +1,28 @@ export default { "command.title": "Pair device", title: "Pairing", - connection: "Local Network", - "local.description": "View connection details and a QR code to connect a device on the same network.", - "local.open": "Show details", + description: "Connect another device to this machine's OpenCode server.", + "pair.title": "Pair a device", + "pair.description": + "Connect a phone, tablet, or another computer from anywhere through an end-to-end encrypted tunnel.", + "pair.open": "Pair device", + "pair.unavailable": "Pairing is unavailable right now.", + "dialog.description": "Scan the code with the OpenCode app on another device, or copy the link.", + "remote.title": "Remote access", + "remote.description": + "Paired devices reach this computer's OpenCode server over the internet. Turning it off disconnects them until you pair them again.", + "remote.error": "Could not update remote access. Try again.", + "setup.title": "Setting up remote access…", + "setup.description": "This can take a minute.", + "setup.error": "Could not set up remote access.", + retry: "Try again", "screenActive.title": "Keep screen active", "screenActive.description": "Prevent this computer’s display from sleeping while OpenCode is running.", "screenActive.error": "Could not update the screen activity setting. Try again.", - description: "Connect another device to this machine's OpenCode server.", qr: "Pairing QR code", copyLink: "Copy link", "copy.error": "Could not copy pairing details. Try again.", error: "Could not update pairing details. Try again.", - routes: "Connect with", - "route.local": "Local network", - "route.local.description": "Devices on the same network", - "route.vpn": "VPN", - "route.vpn.description": "Devices on the same VPN", - "route.custom": "Custom address", - "route.custom.description": "Devices that can open this address", - "route.other": "Network address", - "route.other.description": "Devices that can reach this address", expires: "Expires in {{time}}", refresh: "New code", - "custom.title": "Custom address", - "custom.description": - "An address that reaches this computer from other devices, such as a VPN, tunnel, or reverse proxy URL.", - "custom.placeholder": "https://opencode.example.com", - "custom.invalid": "Enter an HTTP or HTTPS address without a path, such as https://opencode.example.com.", - "unreachable.title": "Only this computer can reach the OpenCode server", - "unreachable.description": - "To pair a device on your network, run this command in a terminal, then reopen Pairing. You can also add a custom address below.", - "unreachable.copy": "Copy command", } diff --git a/packages/gui-extensions/src/pairing/index.ts b/packages/gui-extensions/src/pairing/index.ts index 90715e954ff..f930c985d79 100644 --- a/packages/gui-extensions/src/pairing/index.ts +++ b/packages/gui-extensions/src/pairing/index.ts @@ -9,8 +9,6 @@ export default Extension.define({ stores: { // Whether main keeps the display awake; stored before in the desktop's own settings namespace. keepScreenActive: Store.main(Schema.Boolean, false, { state: ["opencode.settings", "keepScreenActive"] }), - // An address of this computer the server cannot see (a VPN, tunnel or proxy), and the address links use. - links: Store.global(Schema.Struct({ custom: Schema.String, selected: Schema.String }), { custom: "", selected: "" }), }, i18n: { en }, }) diff --git a/packages/gui-extensions/src/pairing/main.ts b/packages/gui-extensions/src/pairing/main.ts index aab89479d1f..9388b3e2f32 100644 --- a/packages/gui-extensions/src/pairing/main.ts +++ b/packages/gui-extensions/src/pairing/main.ts @@ -1,3 +1,4 @@ +import { execFile } from "node:child_process" import { powerSaveBlocker } from "electron" import type { MainSetup } from "../sdk/main" import { Pairing } from "./contract" @@ -6,9 +7,15 @@ import type definition from "./index" /** The display sleep blocker this instance holds, if any. */ type Blocker = { id?: number } +/** The last `opencode service` command; the next one starts after it settles. */ +type Queue = { tail: Promise } + const setup: MainSetup = (ctx) => { const stored = ctx.stores.keepScreenActive + const cli = ctx.cli + const log = ctx.log const blocker: Blocker = {} + const queue: Queue = { tail: Promise.resolve() } const release = () => { if (blocker.id === undefined) return @@ -23,10 +30,41 @@ const setup: MainSetup = (ctx) => { stored.set(enabled) } + // Remote access is a setting of the background service, so it changes through the CLI the app runs, as with + // `opencode pair --remote`. A change restarts the service, so commands run one at a time. + const service = (args: readonly string[], signal: AbortSignal) => { + const run = queue.tail.then( + () => + new Promise((resolve, reject) => { + execFile( + cli.command[0] ?? "opencode", + [...cli.command.slice(1), "service", ...args], + { signal, windowsHide: true }, + (error, stdout, stderr) => { + if (!error) return resolve(stdout.trim()) + log.write("error", "pairing service command failed", { args, error: error.message, stderr }) + reject(error) + }, + ) + }), + ) + + queue.tail = run.catch(() => undefined) + + return run + } + if (stored.value) keepScreenActive(true) ctx.scope.addFinalizer(release) ctx.provide(Pairing, { + remote: async (_, caller) => (await service(["get", "remote"], caller.signal)) === "true", + // Changing the setting stops the service. Starting it here brings it back, with the tunnel when remote access is + // on. A window that closes meanwhile does not cancel the change. + setRemote: async (enabled) => { + await service(["set", "remote", String(enabled)], ctx.scope.signal) + await service(["start"], ctx.scope.signal) + }, screenActive: () => blocker.id !== undefined && powerSaveBlocker.isStarted(blocker.id), setScreenActive: (enabled) => keepScreenActive(enabled), }) diff --git a/packages/gui-extensions/src/pairing/page.stories.tsx b/packages/gui-extensions/src/pairing/page.stories.tsx index fc9ef6b270b..d48fb273ded 100644 --- a/packages/gui-extensions/src/pairing/page.stories.tsx +++ b/packages/gui-extensions/src/pairing/page.stories.tsx @@ -1,17 +1,16 @@ -import { PairingHost, pairingClient, pairingServer } from "../../../storybook/.storybook/mocks/gui-extensions/pairing" +import { PairingHost, pairingRemote } from "../../../storybook/.storybook/mocks/gui-extensions/pairing" import PairingPage from "./page" -const story = (urls: readonly string[], links?: { custom?: string; selected?: string }, main = true) => ({ +const story = (remote: { enabled: boolean; fails?: boolean }, main = true) => ({ render: () => { - const server = pairingServer(urls) - const client = pairingClient() + const mock = pairingRemote(remote) return ( - + server} + server={() => mock.server} pairing={() => - main ? { status: "active", value: client, generation: 1 } : { status: "inactive", reason: "disabled" } + main ? { status: "active", value: mock.client, generation: 1 } : { status: "inactive", reason: "disabled" } } /> @@ -25,26 +24,20 @@ export default { parameters: { docs: { description: { - component: "Settings → Pairing on desktop: the addresses a pairing link can use, and its QR code.", + component: "Settings → Pairing on desktop: pair a device from anywhere through remote access, and its QR code.", }, }, }, } -/** The server listens on the local network and on a VPN address. */ -export const NetworkAddresses = story([ - "http://127.0.0.1:49374", - "http://192.168.10.248:49374", - "http://100.87.251.43:49374", -]) +/** Remote access is on, so Pair device shows the QR code at once. */ +export const RemoteOn = story({ enabled: true }) -/** A saved custom address, such as a VPN, tunnel or reverse proxy URL, is offered first. */ -export const CustomAddress = story(["http://127.0.0.1:49374", "http://192.168.10.248:49374"], { - custom: "https://opencode.example.com", -}) +/** The first pairing turns remote access on, then shows the QR code once the tunnel connects. */ +export const FirstPairing = story({ enabled: false }) -/** The default service only listens on this computer, so the page explains how to let other devices in. */ -export const OnlyThisComputer = story(["http://127.0.0.1:49374"]) +/** Remote access cannot be turned on, so the dialog offers to try again. */ +export const SetupFails = story({ enabled: false, fails: true }) -/** Pairing's main side is not running: the link still works from the server, and only the display setting is gone. */ -export const WithoutMainProcess = story(["http://127.0.0.1:49374", "http://192.168.10.248:49374"], undefined, false) +/** Pairing's main side is not running, so remote access cannot be turned on and pairing is unavailable. */ +export const WithoutMainProcess = story({ enabled: false }, false) diff --git a/packages/gui-extensions/src/pairing/page.tsx b/packages/gui-extensions/src/pairing/page.tsx index f0af374c28f..d1fd7f8d201 100644 --- a/packages/gui-extensions/src/pairing/page.tsx +++ b/packages/gui-extensions/src/pairing/page.tsx @@ -1,18 +1,14 @@ import { Button } from "@opencode/ui/button" import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog" -import { Icon } from "@opencode/ui/icon" -import { RadioGroup, RadioItem } from "@opencode/ui/radio" +import { Spinner } from "@opencode/ui/spinner" import { Switch } from "@opencode/ui/switch" -import { TextInput } from "@opencode/ui/text-input" -import { Tooltip } from "@opencode/ui/tooltip" import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query" -import { createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSX } from "solid-js" -import { createStore } from "solid-js/store" +import { createMemo, createSignal, onCleanup, Show, type Accessor, type JSX } from "solid-js" import { renderSVG } from "uqr" import { useExtension, type Live, type IpcClient } from "../sdk" import type { Pairing } from "./contract" import type definition from "./index" -import { customAddress, pairingRoutes, type PairingRoute, type RouteKind } from "./routes" +import { remoteAddress } from "./remote" type Client = IpcClient @@ -29,8 +25,8 @@ export type PairingServer = { } } -/** The command that lets the local server accept connections from other devices. */ -const LISTEN_COMMAND = "opencode service set hostname 0.0.0.0" +/** Where remote access stands for a pairing: turning on, on, or failed to turn on. */ +type Setup = "pending" | "ready" | "failed" export default function PairingPage(props: { server: Accessor @@ -39,35 +35,63 @@ export default function PairingPage(props: { const ctx = useExtension() // The extension's dialogs close with it, so disabling pairing also stops the dialog's code polling. const dialogs = ctx.dialogs - const system = ctx.system - const links = ctx.stores.links const queryClient = useQueryClient() - const [custom, setCustom] = createStore({ draft: links.value.custom, invalid: false }) - const local = useQuery(() => ({ - queryKey: [ctx.id, "local"], + // Remote access and the display setting need pairing's main side; codes come from the server, so they never wait. + const main = () => { + const live = props.pairing() + + return live.status === "active" ? live : undefined + } + + const remoteKey = () => [ctx.id, "remote", main()?.generation] + + const remote = useQuery(() => ({ + queryKey: remoteKey(), queryFn: (input) => { - const server = props.server() + const live = main() - if (!server) throw new Error("The local server is not listed") + if (!live) throw new Error("Pairing's main side is not active") - return server.client.server.info({ signal: input.signal }) + return live.value.remote({ signal: input.signal }) }, - enabled: !!props.server(), - // Asks again each time the page opens: the guidance below tells the user to change the hostname and reopen Pairing. + enabled: !!main(), gcTime: 0, })) - // Reading pending query data would suspend the entire settings surface. - const localInfo = () => (local.isSuccess ? local.data : undefined) + // Main restarts the background service on every change, so a setting that already matches is left alone unless + // `restart` asks for it, as a tunnel that never connected needs. + const change = useMutation(() => ({ + mutationFn: async (input: { enabled: boolean; restart?: boolean }) => { + const live = main() - // The custom address field starts with the saved address, so a valid address being typed counts at once and a cleared - // or invalid one does not; it is saved on Enter, when the field loses focus, and when the dialog opens. - const routes = createMemo(() => pairingRoutes(localInfo()?.urls ?? [], customAddress(custom.draft.trim()) ?? "")) + if (!live) throw new Error("Pairing's main side is not active") - // Whether the server itself listens only on this computer. Not derived from the routes above, so typing a custom - // address does not remove the notice above the field while the user types. - const listensLocally = () => local.isSuccess && pairingRoutes(local.data.urls, "").length === 0 + if (input.restart || (await live.value.remote()) !== input.enabled) await live.value.setRemote(input.enabled) + }, + onSuccess: (_, input) => queryClient.setQueryData(remoteKey(), input.enabled), + })) + + const setup = (): Setup => { + if (change.isPending) return "pending" + + if (change.isError && change.variables?.enabled) return "failed" + + if (remote.data === true) return "ready" + + return remote.isError ? "failed" : "pending" + } + + const tunnel = async (signal: AbortSignal) => { + const server = props.server() + + if (!server) throw new Error("The local server is not listed") + const address = remoteAddress((await server.client.server.info({ signal })).urls) + + if (!address) throw new Error("The remote access tunnel is not connected yet") + + return address + } const code = async (signal: AbortSignal) => { const server = props.server() @@ -78,65 +102,43 @@ export default function PairingPage(props: { return { code: pairing.code, expiresIn: pairing.expires_in } } - // Only the display setting needs pairing's main side; the link and QR code come from the server, so they never wait. - const display = () => { - const live = props.pairing() - - return live.status === "active" ? live : undefined + // Every pairing goes through remote access, so pairing turns it on first. + const pair = () => { + if (remote.data !== true && !change.isPending) change.mutate({ enabled: true }) + dialogs.open(() => ( + change.mutate({ enabled: true, restart })} + tunnel={tunnel} + code={code} + /> + )) } const screenActive = useQuery(() => ({ - queryKey: [ctx.id, "screen-active", display()?.generation], + queryKey: [ctx.id, "screen-active", main()?.generation], queryFn: (input) => { - const live = display() + const live = main() if (!live) throw new Error("Pairing's main side is not active") return live.value.screenActive({ signal: input.signal }) }, - enabled: !!display(), + enabled: !!main(), gcTime: 0, })) const screenActivity = useMutation(() => ({ mutationFn: (enabled: boolean) => { - const live = display() + const live = main() if (!live) throw new Error("Pairing's main side is not active") return live.value.setScreenActive(enabled) }, - onSuccess: (_, enabled) => queryClient.setQueryData([ctx.id, "screen-active", display()?.generation], enabled), + onSuccess: (_, enabled) => queryClient.setQueryData([ctx.id, "screen-active", main()?.generation], enabled), })) - // "Copied" shows for two seconds after each copy. - const commandCopied: CopiedTimer = {} - onCleanup(() => clearTimeout(commandCopied.timeout)) - - const copyCommand = useMutation(() => ({ - mutationFn: () => system.copy(LISTEN_COMMAND), - onMutate: () => clearTimeout(commandCopied.timeout), - onSuccess: () => { - commandCopied.timeout = setTimeout(() => copyCommand.reset(), 2000) - }, - })) - - const saveCustom = () => { - const draft = custom.draft.trim() - const address = draft ? customAddress(draft) : "" - - if (address === undefined) return void setCustom("invalid", true) - setCustom({ draft: address, invalid: false }) - - if (address === links.value.custom) return - links.update((value) => { - value.custom = address - - // A new address is what the user wants to share next. - if (address) value.selected = address - }) - } - return ( <>
@@ -149,99 +151,30 @@ export default function PairingPage(props: {
-
+
- - 0}> - - + + - -
-
- - {ctx.t("unreachable.title")} -
-

- {ctx.t("unreachable.description")} -

-
- +
+ + change.mutate({ enabled })} > - {LISTEN_COMMAND} - - - - -
+ {ctx.t("remote.title")} + +
- - {/* The address field spans the row so the description keeps its width. */} -
-
-
{ctx.t("custom.title")}
-
{ctx.t("custom.description")}
- setCustom({ draft: event.currentTarget.value, invalid: false })} - onChange={saveCustom} - onKeyDown={(event) => { - if (event.key === "Enter") saveCustom() - }} - /> - - - {ctx.t("custom.invalid")} - - -
-
-
- + + + + - + {/* Pending main is still starting; anything else that leaves it inactive keeps pairing from turning on. */} +
@@ -275,34 +214,33 @@ export default function PairingPage(props: { /** The pending return from "Copied" to the copy label. */ type CopiedTimer = { timeout?: ReturnType } -const routeLabel: Record = { - custom: "route.custom", - local: "route.local", - vpn: "route.vpn", - other: "route.other", -} - -const routeDescription: Record = { - custom: "route.custom.description", - local: "route.local.description", - vpn: "route.vpn.description", - other: "route.other.description", -} - function DialogPairing(props: { - title: string - routes: Accessor - selected: Accessor - onSelect: (url: string) => void + setup: Accessor + /** Turns remote access on again; `restart` restarts the service even when it is already on. */ + onRetry: (restart: boolean) => void + tunnel: (signal: AbortSignal) => Promise code: (signal: AbortSignal) => Promise<{ readonly code: string; readonly expiresIn: number }> }) { const ctx = useExtension() const system = ctx.system + // The service attaches the tunnel in the background after it starts, so ask until the server advertises it, for + // about three minutes, as `opencode pair --remote` waits. + const tunnel = useQuery(() => ({ + queryKey: [ctx.id, "tunnel"], + queryFn: (input) => props.tunnel(input.signal), + enabled: props.setup() === "ready", + gcTime: 0, + staleTime: Infinity, + retry: 180, + retryDelay: 1000, + })) + // Codes are single-use, so replace the link as soon as the server forgets the current code. const code = useQuery(() => ({ - queryKey: [ctx.id, "code"], + queryKey: [ctx.id, "code", tunnel.data], queryFn: (input) => props.code(input.signal), + enabled: tunnel.isSuccess, gcTime: 0, refetchInterval: (query) => (query.state.data?.expiresIn ?? 60) * 1000, // A minimized window must not come back to a code that expired while it was hidden. @@ -321,16 +259,15 @@ function DialogPairing(props: { return Math.max(0, code.data.expiresIn - Math.floor(Math.max(0, now() - code.dataUpdatedAt) / 1000)) } - const route = createMemo(() => props.routes().find((item) => item.url === props.selected()) ?? props.routes()[0]) - const url = createMemo(() => { - const target = route() + if (!code.isSuccess || !tunnel.isSuccess) return - if (!code.isSuccess || !target) return - - return new URL(`/auth/connect/${code.data.code}`, target.url).href + return new URL(`/auth/connect/${code.data.code}`, tunnel.data).href }) + // A tunnel error from before a retry stays on the disabled query, so it counts only once setup is ready again. + const failed = () => props.setup() === "failed" || (props.setup() === "ready" && tunnel.isError) + // "Copied" shows for two seconds after each copy. const copied: CopiedTimer = {} onCleanup(() => clearTimeout(copied.timeout)) @@ -348,19 +285,14 @@ function DialogPairing(props: { }, })) - // "Copied" describes the link on screen, so it ends when the user switches to another link. - const forgetCopy = () => { - clearTimeout(copied.timeout) - copy.reset() - } - const refresh = () => ( ) - // The QR code carries every address as `opencode pair` prints it, {"code","urls"} JSON, so the app's scanner can - // reach this machine over whichever network it shares; the chosen address goes first. + // The QR code carries the remote address as `opencode pair --remote` prints it, {"code","urls"} JSON, which the + // app's scanner reads. const qr = createMemo(() => { - const target = route() + if (!code.isSuccess || !tunnel.isSuccess) return - if (!code.isSuccess || !target) return - const urls = [target.url, ...props.routes().flatMap((item) => (item.url === target.url ? [] : [item.url]))] - - return renderSVG(JSON.stringify({ code: code.data.code, urls }), { + return renderSVG(JSON.stringify({ code: code.data.code, urls: [tunnel.data] }), { border: 4, blackColor: "currentColor", whiteColor: "transparent", }) }) + // The QR code's frame, which also holds the setup progress and its failure, so the dialog keeps its size. + const frame = "mx-auto aspect-square w-full max-w-[min(320px,calc(100dvh-360px))] shrink-0 rounded-[6px]" + return ( - + - 1} - fallback={ - - {(item) => ( -
- - {ctx.t(routeLabel[item().kind])} - - - {ctx.t(routeDescription[item().kind])} - -
- )} -
- } - > - { - forgetCopy() - props.onSelect(url) - }} - > - - {(item) => ( - - {ctx.t(routeDescription[item.kind])} - {" · "} - {new URL(item.url).host} - - } - /> - )} - - -
svg]:size-full`} role="img" aria-label={ctx.t("qr")} innerHTML={qr()} /> -
- + - + {ctx.t(copy.isSuccess ? "common.copied" : "copyLink")} +
{ctx.t("expires", { time: countdown(remaining()) })} @@ -469,8 +346,37 @@ function DialogPairing(props: {
- {/* A failed fetch hides the link, so the way to try again stays next to the error. */} - + +
+ + + + {ctx.t("setup.title")} + + + {ctx.t("setup.description")} + + +
+
+ +
+ + {/* A tunnel that never connected comes back when the service starts it again. */} + +
+
+ {/* A failed code fetch hides the link, so the way to try again stays next to the error. */} +