Skip to content

Commit 165cd2e

Browse files
authored
feat(desktop): pair devices through remote access (#55306)
1 parent 5aabb61 commit 165cd2e

13 files changed

Lines changed: 375 additions & 486 deletions

File tree

‎packages/gui-extensions/README.md‎

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -192,15 +192,15 @@ Pairing's shipping page uses a query, not `createLatest`. It passes the query's
192192

193193
```tsx
194194
const screenActive = useQuery(() => ({
195-
queryKey: [ctx.id, "screen-active", display()?.generation],
195+
queryKey: [ctx.id, "screen-active", main()?.generation],
196196
queryFn: (input) => {
197-
const live = display()
197+
const live = main()
198198

199199
if (!live) throw new Error("Pairing's main side is not active")
200200

201201
return live.value.screenActive({ signal: input.signal })
202202
},
203-
enabled: !!display(),
203+
enabled: !!main(),
204204
gcTime: 0,
205205
}))
206206
```
@@ -328,13 +328,13 @@ const act = (name: "check" | "install") => {
328328
}
329329
```
330330

331-
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:
331+
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:
332332

333-
<!-- source: src/pairing/page.tsx#display -->
333+
<!-- source: src/pairing/page.tsx#main -->
334334

335335
```tsx
336-
// Only the display setting needs pairing's main side; the link and QR code come from the server, so they never wait.
337-
const display = () => {
336+
// Remote access and the display setting need pairing's main side; codes come from the server, so they never wait.
337+
const main = () => {
338338
const live = props.pairing()
339339

340340
return live.status === "active" ? live : undefined
@@ -441,9 +441,9 @@ legacy: ["summary"],
441441

442442
## Build your first extension
443443

444-
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.
444+
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.
445445

446-
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.
446+
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.
447447

448448
<!-- source: src/pairing/contract.ts -->
449449

@@ -452,12 +452,20 @@ import { Schema } from "effect"
452452
import { Ipc } from "../sdk"
453453

454454
/**
455-
* Keeps this machine's display awake while another device uses it. The window asks the local server for addresses and
456-
* pairing codes through its own `ServerRef`; only the display sleep blocker needs the main process.
455+
* Turns on remote access and keeps this machine's display awake while another device uses it. The window asks the
456+
* local server for its remote address and pairing codes through its own `ServerRef`; remote access, which changes
457+
* the background service's settings through the CLI, and the display sleep blocker need the main process.
457458
*/
458459
export const Pairing = Ipc.define({
459460
id: "pairing",
460461
methods: {
462+
/** Whether remote access is on: the background service serves an OpenTunnel address. */
463+
remote: { output: Schema.Boolean },
464+
/**
465+
* Turns remote access on or off, then starts the background service again. Turning it on the first time creates
466+
* the tunnel, which can take a minute.
467+
*/
468+
setRemote: { input: Schema.Boolean },
461469
/** Whether main holds the display sleep blocker. */
462470
screenActive: { output: Schema.Boolean },
463471
/** Holds or releases the display sleep blocker, and remembers the choice. */
@@ -466,7 +474,7 @@ export const Pairing = Ipc.define({
466474
})
467475
```
468476

469-
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.
477+
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.
470478

471479
<!-- source: src/pairing/index.ts -->
472480

@@ -482,18 +490,17 @@ export default Extension.define({
482490
stores: {
483491
// Whether main keeps the display awake; stored before in the desktop's own settings namespace.
484492
keepScreenActive: Store.main(Schema.Boolean, false, { state: ["opencode.settings", "keepScreenActive"] }),
485-
// An address of this computer the server cannot see (a VPN, tunnel or proxy), and the address links use.
486-
links: Store.global(Schema.Struct({ custom: Schema.String, selected: Schema.String }), { custom: "", selected: "" }),
487493
},
488494
i18n: { en },
489495
})
490496
```
491497

492-
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.
498+
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.
493499

494500
<!-- source: src/pairing/main.ts -->
495501

496502
```ts
503+
import { execFile } from "node:child_process"
497504
import { powerSaveBlocker } from "electron"
498505
import type { MainSetup } from "../sdk/main"
499506
import { Pairing } from "./contract"
@@ -502,9 +509,15 @@ import type definition from "./index"
502509
/** The display sleep blocker this instance holds, if any. */
503510
type Blocker = { id?: number }
504511

512+
/** The last `opencode service` command; the next one starts after it settles. */
513+
type Queue = { tail: Promise<unknown> }
514+
505515
const setup: MainSetup<typeof definition> = (ctx) => {
506516
const stored = ctx.stores.keepScreenActive
517+
const cli = ctx.cli
518+
const log = ctx.log
507519
const blocker: Blocker = {}
520+
const queue: Queue = { tail: Promise.resolve() }
508521

509522
const release = () => {
510523
if (blocker.id === undefined) return
@@ -519,10 +532,41 @@ const setup: MainSetup<typeof definition> = (ctx) => {
519532
stored.set(enabled)
520533
}
521534

535+
// Remote access is a setting of the background service, so it changes through the CLI the app runs, as with
536+
// `opencode pair --remote`. A change restarts the service, so commands run one at a time.
537+
const service = (args: readonly string[], signal: AbortSignal) => {
538+
const run = queue.tail.then(
539+
() =>
540+
new Promise<string>((resolve, reject) => {
541+
execFile(
542+
cli.command[0] ?? "opencode",
543+
[...cli.command.slice(1), "service", ...args],
544+
{ signal, windowsHide: true },
545+
(error, stdout, stderr) => {
546+
if (!error) return resolve(stdout.trim())
547+
log.write("error", "pairing service command failed", { args, error: error.message, stderr })
548+
reject(error)
549+
},
550+
)
551+
}),
552+
)
553+
554+
queue.tail = run.catch(() => undefined)
555+
556+
return run
557+
}
558+
522559
if (stored.value) keepScreenActive(true)
523560
ctx.scope.addFinalizer(release)
524561

525562
ctx.provide(Pairing, {
563+
remote: async (_, caller) => (await service(["get", "remote"], caller.signal)) === "true",
564+
// Changing the setting stops the service. Starting it here brings it back, with the tunnel when remote access is
565+
// on. A window that closes meanwhile does not cancel the change.
566+
setRemote: async (enabled) => {
567+
await service(["set", "remote", String(enabled)], ctx.scope.signal)
568+
await service(["start"], ctx.scope.signal)
569+
},
526570
screenActive: () => blocker.id !== undefined && powerSaveBlocker.isStarted(blocker.id),
527571
setScreenActive: (enabled) => keepScreenActive(enabled),
528572
})
@@ -531,7 +575,7 @@ const setup: MainSetup<typeof definition> = (ctx) => {
531575
export default setup
532576
```
533577

534-
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)).
578+
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)).
535579

536580
<!-- source: src/pairing/renderer.tsx -->
537581

@@ -564,13 +608,19 @@ const setup: Setup<typeof definition> = (ctx) => {
564608
return ctx.t("title")
565609
},
566610
get entries() {
567-
const pairingEntry = { id: "pairing", title: ctx.t("title"), keywords: "pair device qr local" }
611+
const pairingEntry = { id: "pairing", title: ctx.t("title"), keywords: "pair device qr phone remote" }
568612

569-
// The display setting exists only while pairing's main side answers.
613+
// Remote access and the display setting exist only while pairing's main side answers.
570614
if (pairing().status !== "active") return [pairingEntry]
571615

572616
return [
573617
pairingEntry,
618+
{
619+
id: "settings-remote-access",
620+
title: ctx.t("remote.title"),
621+
description: ctx.t("remote.description"),
622+
keywords: "internet tunnel opentunnel",
623+
},
574624
{
575625
id: "settings-keep-screen-active",
576626
title: ctx.t("screenActive.title"),

‎packages/gui-extensions/src/pairing/contract.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@ import { Schema } from "effect"
22
import { Ipc } from "../sdk"
33

44
/**
5-
* Keeps this machine's display awake while another device uses it. The window asks the local server for addresses and
6-
* pairing codes through its own `ServerRef`; only the display sleep blocker needs the main process.
5+
* Turns on remote access and keeps this machine's display awake while another device uses it. The window asks the
6+
* local server for its remote address and pairing codes through its own `ServerRef`; remote access, which changes
7+
* the background service's settings through the CLI, and the display sleep blocker need the main process.
78
*/
89
export const Pairing = Ipc.define({
910
id: "pairing",
1011
methods: {
12+
/** Whether remote access is on: the background service serves an OpenTunnel address. */
13+
remote: { output: Schema.Boolean },
14+
/**
15+
* Turns remote access on or off, then starts the background service again. Turning it on the first time creates
16+
* the tunnel, which can take a minute.
17+
*/
18+
setRemote: { input: Schema.Boolean },
1119
/** Whether main holds the display sleep blocker. */
1220
screenActive: { output: Schema.Boolean },
1321
/** Holds or releases the display sleep blocker, and remembers the choice. */
Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,28 @@
11
export default {
22
"command.title": "Pair device",
33
title: "Pairing",
4-
connection: "Local Network",
5-
"local.description": "View connection details and a QR code to connect a device on the same network.",
6-
"local.open": "Show details",
4+
description: "Connect another device to this machine's OpenCode server.",
5+
"pair.title": "Pair a device",
6+
"pair.description":
7+
"Connect a phone, tablet, or another computer from anywhere through an end-to-end encrypted tunnel.",
8+
"pair.open": "Pair device",
9+
"pair.unavailable": "Pairing is unavailable right now.",
10+
"dialog.description": "Scan the code with the OpenCode app on another device, or copy the link.",
11+
"remote.title": "Remote access",
12+
"remote.description":
13+
"Paired devices reach this computer's OpenCode server over the internet. Turning it off disconnects them until you pair them again.",
14+
"remote.error": "Could not update remote access. Try again.",
15+
"setup.title": "Setting up remote access…",
16+
"setup.description": "This can take a minute.",
17+
"setup.error": "Could not set up remote access.",
18+
retry: "Try again",
719
"screenActive.title": "Keep screen active",
820
"screenActive.description": "Prevent this computer’s display from sleeping while OpenCode is running.",
921
"screenActive.error": "Could not update the screen activity setting. Try again.",
10-
description: "Connect another device to this machine's OpenCode server.",
1122
qr: "Pairing QR code",
1223
copyLink: "Copy link",
1324
"copy.error": "Could not copy pairing details. Try again.",
1425
error: "Could not update pairing details. Try again.",
15-
routes: "Connect with",
16-
"route.local": "Local network",
17-
"route.local.description": "Devices on the same network",
18-
"route.vpn": "VPN",
19-
"route.vpn.description": "Devices on the same VPN",
20-
"route.custom": "Custom address",
21-
"route.custom.description": "Devices that can open this address",
22-
"route.other": "Network address",
23-
"route.other.description": "Devices that can reach this address",
2426
expires: "Expires in {{time}}",
2527
refresh: "New code",
26-
"custom.title": "Custom address",
27-
"custom.description":
28-
"An address that reaches this computer from other devices, such as a VPN, tunnel, or reverse proxy URL.",
29-
"custom.placeholder": "https://opencode.example.com",
30-
"custom.invalid": "Enter an HTTP or HTTPS address without a path, such as https://opencode.example.com.",
31-
"unreachable.title": "Only this computer can reach the OpenCode server",
32-
"unreachable.description":
33-
"To pair a device on your network, run this command in a terminal, then reopen Pairing. You can also add a custom address below.",
34-
"unreachable.copy": "Copy command",
3528
}

‎packages/gui-extensions/src/pairing/index.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ export default Extension.define({
99
stores: {
1010
// Whether main keeps the display awake; stored before in the desktop's own settings namespace.
1111
keepScreenActive: Store.main(Schema.Boolean, false, { state: ["opencode.settings", "keepScreenActive"] }),
12-
// An address of this computer the server cannot see (a VPN, tunnel or proxy), and the address links use.
13-
links: Store.global(Schema.Struct({ custom: Schema.String, selected: Schema.String }), { custom: "", selected: "" }),
1412
},
1513
i18n: { en },
1614
})

‎packages/gui-extensions/src/pairing/main.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { execFile } from "node:child_process"
12
import { powerSaveBlocker } from "electron"
23
import type { MainSetup } from "../sdk/main"
34
import { Pairing } from "./contract"
@@ -6,9 +7,15 @@ import type definition from "./index"
67
/** The display sleep blocker this instance holds, if any. */
78
type Blocker = { id?: number }
89

10+
/** The last `opencode service` command; the next one starts after it settles. */
11+
type Queue = { tail: Promise<unknown> }
12+
913
const setup: MainSetup<typeof definition> = (ctx) => {
1014
const stored = ctx.stores.keepScreenActive
15+
const cli = ctx.cli
16+
const log = ctx.log
1117
const blocker: Blocker = {}
18+
const queue: Queue = { tail: Promise.resolve() }
1219

1320
const release = () => {
1421
if (blocker.id === undefined) return
@@ -23,10 +30,41 @@ const setup: MainSetup<typeof definition> = (ctx) => {
2330
stored.set(enabled)
2431
}
2532

33+
// Remote access is a setting of the background service, so it changes through the CLI the app runs, as with
34+
// `opencode pair --remote`. A change restarts the service, so commands run one at a time.
35+
const service = (args: readonly string[], signal: AbortSignal) => {
36+
const run = queue.tail.then(
37+
() =>
38+
new Promise<string>((resolve, reject) => {
39+
execFile(
40+
cli.command[0] ?? "opencode",
41+
[...cli.command.slice(1), "service", ...args],
42+
{ signal, windowsHide: true },
43+
(error, stdout, stderr) => {
44+
if (!error) return resolve(stdout.trim())
45+
log.write("error", "pairing service command failed", { args, error: error.message, stderr })
46+
reject(error)
47+
},
48+
)
49+
}),
50+
)
51+
52+
queue.tail = run.catch(() => undefined)
53+
54+
return run
55+
}
56+
2657
if (stored.value) keepScreenActive(true)
2758
ctx.scope.addFinalizer(release)
2859

2960
ctx.provide(Pairing, {
61+
remote: async (_, caller) => (await service(["get", "remote"], caller.signal)) === "true",
62+
// Changing the setting stops the service. Starting it here brings it back, with the tunnel when remote access is
63+
// on. A window that closes meanwhile does not cancel the change.
64+
setRemote: async (enabled) => {
65+
await service(["set", "remote", String(enabled)], ctx.scope.signal)
66+
await service(["start"], ctx.scope.signal)
67+
},
3068
screenActive: () => blocker.id !== undefined && powerSaveBlocker.isStarted(blocker.id),
3169
setScreenActive: (enabled) => keepScreenActive(enabled),
3270
})

0 commit comments

Comments
 (0)