feat(desktop): pair devices through remote access (#55306)

This commit is contained in:
Luke Parker
2026-10-14 15:56:17 +10:00
committed by GitHub
Unverified
parent 5aabb619c0
commit 165cd2e832
13 changed files with 375 additions and 486 deletions
+68 -18
View File
@@ -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:
<!-- source: src/pairing/page.tsx#display -->
<!-- source: src/pairing/page.tsx#main -->
```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.
<!-- source: src/pairing/contract.ts -->
@@ -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.
<!-- source: src/pairing/index.ts -->
@@ -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<typeof definition>` types `ctx.stores`, which main reads synchronously. A finalizer releases the blocker when the instance goes away.
3. **Provide it from main.** `MainSetup<typeof definition>` 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.
<!-- source: src/pairing/main.ts -->
```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<unknown> }
const setup: MainSetup<typeof definition> = (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<typeof definition> = (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<string>((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<typeof definition> = (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)).
<!-- source: src/pairing/renderer.tsx -->
@@ -564,13 +608,19 @@ const setup: Setup<typeof definition> = (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"),
@@ -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. */
+15 -22
View File
@@ -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",
}
@@ -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 },
})
@@ -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<unknown> }
const setup: MainSetup<typeof definition> = (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<typeof definition> = (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<string>((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),
})
@@ -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 (
<PairingHost links={links}>
<PairingHost>
<PairingPage
server={() => 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" }
}
/>
</PairingHost>
@@ -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)
+169 -263
View File
@@ -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<typeof Pairing.spec>
@@ -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<PairingServer | undefined>
@@ -39,35 +35,63 @@ export default function PairingPage(props: {
const ctx = useExtension<typeof definition>()
// 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(() => (
<DialogPairing
setup={setup}
onRetry={(restart) => 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 (
<>
<div class="settings-tab-header">
@@ -149,99 +151,30 @@ export default function PairingPage(props: {
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<section class="settings-section" aria-label={ctx.t("connection")}>
<section class="settings-section" aria-label={ctx.t("title")}>
<div data-component="settings-list">
<Row title={ctx.t("connection")} description={ctx.t("local.description")}>
<Show when={routes().length > 0}>
<Button
variant="neutral"
onClick={() => {
saveCustom()
dialogs.open(() => (
<DialogPairing
title={ctx.t("connection")}
routes={routes}
selected={() => links.value.selected}
onSelect={(url) =>
links.update((value) => {
value.selected = url
})
}
code={code}
/>
))
}}
>
{ctx.t("local.open")}
</Button>
</Show>
<Row title={ctx.t("pair.title")} description={ctx.t("pair.description")}>
<Button
variant="neutral"
disabled={!main() || !props.server() || (change.isPending && change.variables?.enabled === false)}
onClick={pair}
>
{ctx.t("pair.open")}
</Button>
</Row>
<Show when={listensLocally()}>
<div class="flex flex-col gap-2 py-4" role="status">
<div class="flex items-center gap-2 text-[13px] font-[530] leading-[var(--line-height-compact)] text-v2-text-text-base">
<Icon name="lock" size="small" class="shrink-0 text-v2-icon-icon-muted" />
{ctx.t("unreachable.title")}
</div>
<p class="text-[13px] leading-[var(--line-height-base)] text-v2-text-text-muted">
{ctx.t("unreachable.description")}
</p>
<div class="flex min-w-0 items-center gap-2">
<code
dir="ltr"
class="min-w-0 truncate rounded-[6px] border border-v2-border-border-base bg-v2-background-bg-layer-01 px-2 py-1 text-12-mono text-v2-text-text-base"
<Show when={main()}>
<div data-action="settings-remote-access">
<Row title={ctx.t("remote.title")} description={ctx.t("remote.description")}>
<Switch
hideLabel
checked={change.isPending ? change.variables?.enabled === true : remote.data === true}
disabled={!remote.isSuccess || change.isPending}
onChange={(enabled) => change.mutate({ enabled })}
>
{LISTEN_COMMAND}
</code>
<Tooltip
value={ctx.t(copyCommand.isSuccess ? "common.copied" : "unreachable.copy")}
placement="top"
forceOpen={copyCommand.isSuccess ? true : undefined}
>
<Button
variant="ghost"
size="small"
aria-label={ctx.t("unreachable.copy")}
onClick={() => copyCommand.mutate()}
>
<Icon name={copyCommand.isSuccess ? "check" : "copy"} size="small" />
</Button>
</Tooltip>
</div>
{ctx.t("remote.title")}
</Switch>
</Row>
</div>
</Show>
{/* The address field spans the row so the description keeps its width. */}
<div data-component="settings-row">
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title">{ctx.t("custom.title")}</div>
<div data-slot="settings-row-description">{ctx.t("custom.description")}</div>
<TextInput
type="url"
dir="ltr"
spellcheck={false}
autocapitalize="off"
aria-label={ctx.t("custom.title")}
placeholder={ctx.t("custom.placeholder")}
value={custom.draft}
invalid={custom.invalid}
aria-describedby={custom.invalid ? "pairing-custom-error" : undefined}
onInput={(event) => setCustom({ draft: event.currentTarget.value, invalid: false })}
onChange={saveCustom}
onKeyDown={(event) => {
if (event.key === "Enter") saveCustom()
}}
/>
<Show when={custom.invalid}>
<span
id="pairing-custom-error"
class="text-11-regular leading-[var(--line-height-compact)] text-v2-state-fg-danger"
role="alert"
>
{ctx.t("custom.invalid")}
</span>
</Show>
</div>
</div>
<Show when={display()}>
<div data-action="settings-keep-screen-active">
<Row title={ctx.t("screenActive.title")} description={ctx.t("screenActive.description")}>
<Switch
@@ -256,14 +189,20 @@ export default function PairingPage(props: {
</div>
</Show>
</div>
<Show when={display() && (screenActive.error || screenActivity.error)}>
<Show when={main() && (remote.error || change.error)}>
<p class="text-text-danger-base" role="alert">
{ctx.t("remote.error")}
</p>
</Show>
<Show when={main() && (screenActive.error || screenActivity.error)}>
<p class="text-text-danger-base" role="alert">
{ctx.t("screenActive.error")}
</p>
</Show>
<Show when={local.error || !props.server()}>
{/* Pending main is still starting; anything else that leaves it inactive keeps pairing from turning on. */}
<Show when={props.pairing().status === "inactive" || !props.server()}>
<p class="text-text-danger-base" role="alert">
{ctx.t("error")}
{ctx.t("pair.unavailable")}
</p>
</Show>
</section>
@@ -275,34 +214,33 @@ export default function PairingPage(props: {
/** The pending return from "Copied" to the copy label. */
type CopiedTimer = { timeout?: ReturnType<typeof setTimeout> }
const routeLabel: Record<RouteKind, string> = {
custom: "route.custom",
local: "route.local",
vpn: "route.vpn",
other: "route.other",
}
const routeDescription: Record<RouteKind, string> = {
custom: "route.custom.description",
local: "route.local.description",
vpn: "route.vpn.description",
other: "route.other.description",
}
function DialogPairing(props: {
title: string
routes: Accessor<readonly PairingRoute[]>
selected: Accessor<string>
onSelect: (url: string) => void
setup: Accessor<Setup>
/** Turns remote access on again; `restart` restarts the service even when it is already on. */
onRetry: (restart: boolean) => void
tunnel: (signal: AbortSignal) => Promise<string>
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 = () => (
<button
type="button"
class="rounded-[4px] px-1 text-12-regular leading-[var(--line-height-compact)] text-v2-text-text-muted underline-offset-2 hover:text-v2-text-text-base hover:underline focus-visible:underline focus-visible:outline-none disabled:opacity-50"
disabled={code.isFetching}
onClick={() => {
forgetCopy()
clearTimeout(copied.timeout)
copy.reset()
void code.refetch()
}}
>
@@ -368,98 +300,43 @@ function DialogPairing(props: {
</button>
)
// 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 (
<Dialog fit containerClass="max-w-[min(400px,calc(100vw-32px))]">
<DialogHeader>
<DialogTitleGroup title={props.title} description={ctx.t("description")} />
<DialogTitleGroup title={ctx.t("pair.title")} description={ctx.t("dialog.description")} />
</DialogHeader>
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
<Show
when={props.routes().length > 1}
fallback={
<Show when={route()}>
{(item) => (
<div class="flex flex-col gap-0.5">
<span class="text-[13px] font-[530] leading-[var(--line-height-compact)] text-v2-text-text-base">
{ctx.t(routeLabel[item().kind])}
</span>
<span class="text-12-regular leading-[var(--line-height-compact)] text-v2-text-text-muted">
{ctx.t(routeDescription[item().kind])}
</span>
</div>
)}
</Show>
}
>
<RadioGroup
hideLabel
label={ctx.t("routes")}
value={route()?.url}
onChange={(url) => {
forgetCopy()
props.onSelect(url)
}}
>
<For each={props.routes()}>
{(item) => (
<RadioItem
value={item.url}
label={ctx.t(routeLabel[item.kind])}
description={
<>
{ctx.t(routeDescription[item.kind])}
{" · "}
<bdi dir="ltr">{new URL(item.url).host}</bdi>
</>
}
/>
)}
</For>
</RadioGroup>
</Show>
<Show when={url()}>
<div
class="mx-auto aspect-square w-full max-w-[min(320px,calc(100dvh-360px))] shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
class={`${frame} bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full`}
role="img"
aria-label={ctx.t("qr")}
innerHTML={qr()}
/>
<div class="flex min-w-0 flex-col items-center gap-1 pb-2">
<Tooltip
class="min-w-0 max-w-full"
value={ctx.t(copy.isSuccess ? "common.copied" : "copyLink")}
placement="top"
forceOpen={copy.isSuccess ? true : undefined}
<div class="flex flex-col items-center gap-1 pb-2">
<Button
variant="neutral"
icon={copy.isSuccess ? "check" : "copy"}
disabled={copy.isPending}
onClick={() => copy.mutate()}
>
<button
type="button"
class="inline-flex min-h-8 max-w-full select-none items-center justify-center gap-2 rounded-[6px] px-2 py-1 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted transition-colors hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base focus-visible:bg-v2-background-bg-layer-02 focus-visible:outline-none disabled:opacity-50"
disabled={copy.isPending}
aria-label={ctx.t("copyLink")}
onClick={() => copy.mutate()}
>
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
{/* The whole link, code included, so it can be read out or typed on a device without a camera. */}
<bdi dir="ltr" class="min-w-0 break-all text-start text-12-mono">
{url()}
</bdi>
</button>
</Tooltip>
{ctx.t(copy.isSuccess ? "common.copied" : "copyLink")}
</Button>
<div class="flex items-center gap-2 text-12-regular leading-[var(--line-height-compact)] text-v2-text-text-muted">
<span role="timer" aria-live="off">
{ctx.t("expires", { time: countdown(remaining()) })}
@@ -469,8 +346,37 @@ function DialogPairing(props: {
</div>
</div>
</Show>
{/* A failed fetch hides the link, so the way to try again stays next to the error. */}
<Show when={!url() && code.error}>
<Show when={!url() && !failed() && !code.error}>
<div
class={`${frame} flex flex-col items-center justify-center gap-2 bg-v2-background-bg-layer-01 p-6 text-center`}
role="status"
>
<Spinner class="size-5 text-v2-icon-icon-muted" />
<Show when={!tunnel.isSuccess}>
<span class="text-[13px] font-[530] leading-[var(--line-height-compact)] text-v2-text-text-base">
{ctx.t("setup.title")}
</span>
<span class="text-12-regular leading-[var(--line-height-compact)] text-v2-text-text-muted">
{ctx.t("setup.description")}
</span>
</Show>
</div>
</Show>
<Show when={failed()}>
<div
class={`${frame} flex flex-col items-center justify-center gap-3 bg-v2-background-bg-layer-01 p-6 text-center`}
>
<p class="text-text-danger-base" role="alert">
{ctx.t("setup.error")}
</p>
{/* A tunnel that never connected comes back when the service starts it again. */}
<Button variant="neutral" onClick={() => props.onRetry(tunnel.isError)}>
{ctx.t("retry")}
</Button>
</div>
</Show>
{/* A failed code fetch hides the link, so the way to try again stays next to the error. */}
<Show when={!url() && !failed() && code.error}>
<div class="flex flex-col items-center gap-1">
<p class="text-text-danger-base" role="alert">
{ctx.t("error")}
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test"
import { remoteAddress } from "./remote"
test.each([
[
"the tunnel among the server's own addresses",
[
"http://127.0.0.1:49374",
"http://192.168.10.248:49374",
"http://100.87.251.43:49374",
"https://cbf8424f2044b16d.vcybm3xt4hhu.opentunnel.xyz",
],
"https://cbf8424f2044b16d.vcybm3xt4hhu.opentunnel.xyz",
],
["nothing while the tunnel is off or still connecting", ["http://127.0.0.1:49374", "http://192.168.10.248:49374"], undefined],
["nothing from a malformed address", ["not a url"], undefined],
])("remote address: %s", (_, urls, expected) => {
expect(remoteAddress(urls)).toBe(expected)
})
@@ -0,0 +1,7 @@
/**
* The origin another device opens to reach this machine from anywhere, or undefined while the server advertises none.
* The server itself listens over plain HTTP, so the HTTPS address among its URLs is its remote access tunnel.
*/
export function remoteAddress(urls: readonly string[]) {
return urls.map((value) => URL.parse(value)).find((url) => url?.protocol === "https:")?.origin
}
@@ -26,13 +26,19 @@ const setup: Setup<typeof definition> = (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"),
@@ -1,63 +0,0 @@
import { describe, expect, test } from "bun:test"
import { customAddress, pairingRoutes } from "./routes"
describe("pairing routes", () => {
test("keeps addresses other devices can reach, custom first, then local network, then VPN", () => {
expect(
pairingRoutes(
[
"http://100.87.251.43:49374",
"http://127.0.0.1:49374",
"http://169.254.10.2:49374",
"http://192.168.10.248:49374",
"http://[fe80::1]:49374",
"http://[fd7a:115c:a1e0::901:fb9d]:49374",
"http://[fd12:3456::1]:49374",
"http://203.0.113.4:49374",
"http://0.0.0.0:49374",
],
"https://opencode.example.com",
),
).toEqual([
{ url: "https://opencode.example.com", kind: "custom" },
{ url: "http://192.168.10.248:49374", kind: "local" },
{ url: "http://[fd12:3456::1]:49374", kind: "local" },
{ url: "http://169.254.10.2:49374", kind: "local" },
{ url: "http://100.87.251.43:49374", kind: "vpn" },
{ url: "http://[fd7a:115c:a1e0::901:fb9d]:49374", kind: "vpn" },
{ url: "http://203.0.113.4:49374", kind: "other" },
])
})
test("a stored custom address that is not an HTTP origin is ignored", () => {
expect(pairingRoutes(["http://192.168.1.2:4096"], "not a url")).toEqual([
{ url: "http://192.168.1.2:4096", kind: "local" },
])
})
test("a server that only listens on this computer has no route", () => {
expect(pairingRoutes(["http://127.0.0.1:49374", "http://localhost:49374"], "")).toEqual([])
})
test("a custom address that the server also advertises is listed once, as custom", () => {
expect(pairingRoutes(["http://192.168.1.2:4096"], "http://192.168.1.2:4096")).toEqual([
{ url: "http://192.168.1.2:4096", kind: "custom" },
])
})
})
describe("custom address", () => {
test("accepts an HTTP or HTTPS origin", () => {
expect(customAddress(" https://opencode.example.com/ ")).toBe("https://opencode.example.com")
expect(customAddress("http://10.0.0.5:4096")).toBe("http://10.0.0.5:4096")
})
test("rejects anything a pairing link would rewrite or leak", () => {
expect(customAddress("opencode.example.com")).toBeUndefined()
expect(customAddress("ftp://opencode.example.com")).toBeUndefined()
expect(customAddress("https://opencode.example.com/app")).toBeUndefined()
expect(customAddress("https://opencode.example.com/?a=1")).toBeUndefined()
expect(customAddress("https://opencode.example.com/#a")).toBeUndefined()
expect(customAddress("https://user:pass@opencode.example.com")).toBeUndefined()
})
})
@@ -1,65 +0,0 @@
/** How another device reaches this machine's server, which decides the label the pairing page shows. */
export type RouteKind = "custom" | "local" | "vpn" | "other"
/** An address another device can open, with the kind of network it travels over. */
export type PairingRoute = { readonly url: string; readonly kind: RouteKind }
const order: Record<RouteKind, number> = { custom: 0, local: 1, vpn: 2, other: 3 }
/**
* The addresses a pairing link can use: the saved custom address first, then the server's advertised addresses that
* another device could reach. Loopback and wildcard addresses only work on this machine, so they drop out.
*/
export function pairingRoutes(urls: readonly string[], custom: string) {
const advertised = urls.flatMap((value): PairingRoute[] => {
const url = URL.parse(value)
const kind = url ? routeKind(url.hostname) : undefined
return url && kind ? [{ url: url.origin, kind }] : []
})
// The stored address is checked again on read, so a malformed stored value cannot break the page.
const origin = customAddress(custom)
const routes: PairingRoute[] = origin ? [{ url: origin, kind: "custom" }, ...advertised] : advertised
return routes
.filter((route, index) => routes.findIndex((other) => other.url === route.url) === index)
.toSorted((a, b) => order[a.kind] - order[b.kind] || Number(linkLocal(a.url)) - Number(linkLocal(b.url)))
}
/**
* The origin of an address the user typed for other devices, or undefined when it is not one. Pairing links replace
* the path, so an address with a path, query, hash or credentials would send devices somewhere else.
*/
export function customAddress(value: string) {
const url = URL.parse(value.trim())
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return
if (url.username || url.password || url.search || url.hash || url.pathname !== "/") return
return url.origin
}
// A link-local address works only without a router in between, so it is offered after the other local addresses.
function linkLocal(url: string) {
return new URL(url).hostname.startsWith("169.254.")
}
function routeKind(hostname: string): RouteKind | undefined {
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase()
if (host === "localhost" || host.endsWith(".localhost") || host === "::1" || host === "::") return
// A browser cannot open an IPv6 link-local address without its zone, so fe80:: addresses drop out too.
if (/^(127|0)\./.test(host) || host.startsWith("fe80:")) return
// 100.64.0.0/10 is shared address space; on a computer it almost always belongs to a mesh VPN, as does fd7a:115c:a1e0::/48.
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) || host.startsWith("fd7a:115c:a1e0:")) return "vpn"
// 169.254.0.0/16 reaches devices on the same link, such as a direct cable without DHCP (RFC 3927).
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.)/.test(host) || /^f[cd][0-9a-f]{2}:/.test(host))
return "local"
return "other"
}
@@ -1,45 +1,56 @@
import { useDialog } from "@opencode/ui/context/dialog"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import type { JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { ExtensionContext, useExtension, type IpcClient } from "../../../../gui-extensions/src/sdk"
import type { Pairing } from "../../../../gui-extensions/src/pairing/contract"
import type { PairingServer } from "../../../../gui-extensions/src/pairing/page"
// The pairing page renders inside the app's settings page, which brings these styles.
import "@/settings/settings.css"
type Links = { custom: string; selected: string }
/** A local server that advertises fixed addresses and issues a fresh code each time. */
export function pairingServer(urls: readonly string[]): PairingServer {
return {
/**
* A local server and pairing main entry. Remote access starts on or off; turning it on takes a moment, after which
* the server advertises its tunnel, as the background service does once OpenTunnel attaches. `fails` makes turning
* it on fail, as when the tunnel cannot be created.
*/
export function pairingRemote(input: { enabled: boolean; fails?: boolean }) {
const state = { enabled: input.enabled }
const server: PairingServer = {
client: {
server: {
info: async () => ({ urls }),
info: async () => ({
urls: [
"http://127.0.0.1:49374",
...(state.enabled ? ["https://cbf8424f2044b16d.vcybm3xt4hhu.opentunnel.xyz"] : []),
],
}),
pair: async () => ({ code: pairingCode(), expires_in: 300 }),
},
},
}
}
const client: IpcClient<typeof Pairing.spec> = {
remote: async () => state.enabled,
setRemote: async (enabled) => {
await new Promise((resolve) => setTimeout(resolve, 2000))
/** A pairing main entry whose display sleep blocker is off. */
export function pairingClient(): IpcClient<typeof Pairing.spec> {
return {
if (input.fails) throw new Error("Timed out creating the remote access tunnel")
state.enabled = enabled
},
screenActive: async () => false,
setScreenActive: async () => undefined,
state: () => undefined,
on: () => () => undefined,
}
return { server, client }
}
/**
* Stands in for the HostApis the pairing page reads that extension stories do not provide: its window store, dialogs
* through the app's dialog stack, and a clipboard that accepts every copy.
* Stands in for the HostApis the pairing page reads that extension stories do not provide: dialogs through the app's
* dialog stack, and a clipboard that accepts every copy.
*/
export function PairingHost(props: { links?: Partial<Links>; children: JSX.Element }) {
export function PairingHost(props: { children: JSX.Element }) {
const base = useExtension()
const dialog = useDialog()
const [links, setLinks] = createStore<Links>({ custom: "", selected: "", ...props.links })
const client = new QueryClient()
// Dialogs render in the app's dialog stack, outside this subtree, so they get the context again as the host does.
const scoped = (render: () => JSX.Element) => (
@@ -48,18 +59,6 @@ export function PairingHost(props: { links?: Partial<Links>; children: JSX.Eleme
</QueryClientProvider>
)
const context = Object.create(base, {
stores: {
value: {
links: {
get value() {
return links
},
ready: () => true,
update: (mutate: (draft: Links) => undefined) => setLinks(produce(mutate)),
set: (next: Links) => setLinks(next),
},
},
},
dialogs: {
value: {
open: (render: () => JSX.Element) => {