Skip to content

Commit 754951b

Browse files
author
Ryan Vogel
committed
feat: persist APN relay secret across restarts, add dev logging and server identification to pair UI
- Electron desktop: auto-start PushRelay with secret persisted in electron-store - CLI serve (Tauri): persist relay secret to Global.Path.state/relay-secret (mode 0600) - Pair endpoint now returns relayURL, serverID, relaySecretHash for debugging - Desktop settings-pair component shows server name, relay URL, and secret hash above QR - Add console.debug logging for pairing fetch lifecycle - Export PushRelay from node.ts entry point for Electron consumption
1 parent 38d4d03 commit 754951b

7 files changed

Lines changed: 154 additions & 28 deletions

File tree

packages/app/src/components/settings-pair.tsx

Lines changed: 92 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,60 @@ import { type Component, createResource, Show } from "solid-js"
22
import { Icon } from "@opencode-ai/ui/icon"
33
import { useLanguage } from "@/context/language"
44
import { useGlobalSDK } from "@/context/global-sdk"
5+
import { useServer } from "@/context/server"
56
import { usePlatform } from "@/context/platform"
67
import { SettingsList } from "./settings-list"
78

8-
type PairResult = { enabled: false } | { enabled: true; hosts: string[]; link: string; qr: string }
9+
type PairResult =
10+
| { enabled: false }
11+
| {
12+
enabled: true
13+
hosts: string[]
14+
relayURL?: string
15+
serverID?: string
16+
relaySecretHash?: string
17+
link: string
18+
qr: string
19+
}
920

1021
export const SettingsPair: Component = () => {
1122
const language = useLanguage()
1223
const globalSDK = useGlobalSDK()
24+
const server = useServer()
1325
const platform = usePlatform()
1426

1527
const [data] = createResource(async () => {
28+
const url = `${globalSDK.url}/experimental/push/pair`
29+
console.debug("[settings-pair] fetching pair data", {
30+
serverUrl: globalSDK.url,
31+
serverName: server.name,
32+
serverKey: server.key,
33+
})
1634
const f = platform.fetch ?? fetch
17-
const res = await f(`${globalSDK.url}/experimental/push/pair`)
18-
if (!res.ok) return { enabled: false as const }
19-
return (await res.json()) as PairResult
35+
const res = await f(url)
36+
if (!res.ok) {
37+
console.debug("[settings-pair] pair endpoint returned non-ok", {
38+
status: res.status,
39+
serverUrl: globalSDK.url,
40+
})
41+
return { enabled: false as const }
42+
}
43+
const result = (await res.json()) as PairResult
44+
console.debug("[settings-pair] pair data received", {
45+
enabled: result.enabled,
46+
serverUrl: globalSDK.url,
47+
serverName: server.name,
48+
...(result.enabled
49+
? {
50+
relayURL: result.relayURL,
51+
serverID: result.serverID,
52+
relaySecretHash: result.relaySecretHash,
53+
hostCount: result.hosts.length,
54+
hosts: result.hosts,
55+
}
56+
: {}),
57+
})
58+
return result
2059
})
2160

2261
return (
@@ -69,21 +108,56 @@ export const SettingsPair: Component = () => {
69108
</SettingsList>
70109
}
71110
>
72-
{(pair) => (
73-
<SettingsList>
74-
<div class="flex flex-col items-center py-8 gap-4">
75-
<img src={(pair() as PairResult & { enabled: true }).qr} alt="Pairing QR code" class="w-64 h-64" />
76-
<div class="flex flex-col gap-1 text-center max-w-sm">
77-
<span class="text-14-medium text-text-strong">
78-
{language.t("settings.pair.instructions.title")}
79-
</span>
80-
<span class="text-13-regular text-text-weak">
81-
{language.t("settings.pair.instructions.description")}
82-
</span>
111+
{(pair) => {
112+
const p = pair() as PairResult & { enabled: true }
113+
return (
114+
<SettingsList>
115+
<div class="flex flex-col items-center py-8 gap-4">
116+
<Show when={server.list.length > 1 || p.relayURL}>
117+
<div class="flex flex-col gap-1.5 w-full max-w-sm text-left">
118+
<div class="flex items-center gap-2">
119+
<span class="text-12-medium text-text-weak shrink-0">
120+
{language.t("settings.pair.server.label")}
121+
</span>
122+
<code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
123+
{server.name}
124+
</code>
125+
</div>
126+
<Show when={p.relayURL}>
127+
<div class="flex items-center gap-2">
128+
<span class="text-12-medium text-text-weak shrink-0">
129+
{language.t("settings.pair.relay.label")}
130+
</span>
131+
<code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
132+
{p.relayURL}
133+
</code>
134+
</div>
135+
</Show>
136+
<Show when={p.relaySecretHash}>
137+
<div class="flex items-center gap-2">
138+
<span class="text-12-medium text-text-weak shrink-0">
139+
{language.t("settings.pair.secret.label")}
140+
</span>
141+
<code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
142+
{p.relaySecretHash}
143+
</code>
144+
</div>
145+
</Show>
146+
</div>
147+
</Show>
148+
<img src={p.qr} alt="Pairing QR code" class="w-64 h-64" />
149+
<div class="flex flex-col gap-1 text-center max-w-sm">
150+
<span class="text-14-medium text-text-strong">
151+
{language.t("settings.pair.instructions.title")}
152+
</span>
153+
<span class="text-13-regular text-text-weak">
154+
{language.t("settings.pair.instructions.description")}
155+
</span>
156+
</div>
83157
</div>
84-
</div>
85-
</SettingsList>
86-
)}
158+
</SettingsList>
159+
)
160+
}}
87161
</Show>
88162
)}
89163
</Show>

packages/app/src/i18n/en.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,9 @@ export const dict = {
865865
"settings.pair.error.description": "Check that the server is reachable and try again.",
866866
"settings.pair.disabled.title": "Push relay is not enabled",
867867
"settings.pair.disabled.description": "Start the server with push relay options to enable mobile pairing.",
868+
"settings.pair.server.label": "Server",
869+
"settings.pair.relay.label": "Relay",
870+
"settings.pair.secret.label": "Secret",
868871
"settings.pair.instructions.title": "Scan with the OpenCode Control app",
869872
"settings.pair.instructions.description":
870873
"Open the OpenCode Control app and scan this QR code to pair your device for push notifications.",

packages/desktop-electron/src/main/env.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ declare module "virtual:opencode-server" {
1010
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
1111
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
1212
}
13+
export namespace PushRelay {
14+
export const start: typeof import("../../../opencode/dist/types/src/node").PushRelay.start
15+
export const stop: typeof import("../../../opencode/dist/types/src/node").PushRelay.stop
16+
}
1317
export namespace Config {
1418
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
1519
export type Info = import("../../../opencode/dist/types/src/node").Config.Info

packages/desktop-electron/src/main/server.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
1+
import { randomBytes } from "node:crypto"
12
import { app } from "electron"
23
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
34
import { getUserShell, loadShellEnv } from "./shell-env"
45
import { store } from "./store"
56

7+
const DEFAULT_RELAY_URL = "https://apn.dev.opencode.ai"
8+
const RELAY_SECRET_KEY = "relaySecret"
9+
10+
function getOrCreateRelaySecret(): string {
11+
const existing = store.get(RELAY_SECRET_KEY)
12+
if (typeof existing === "string" && existing.length > 0) return existing
13+
const secret = randomBytes(18).toString("base64url")
14+
store.set(RELAY_SECRET_KEY, secret)
15+
return secret
16+
}
17+
618
export type WslConfig = { enabled: boolean }
719

820
export type HealthCheck = { wait: Promise<void> }
@@ -32,7 +44,7 @@ export function setWslConfig(config: WslConfig) {
3244

3345
export async function spawnLocalServer(hostname: string, port: number, password: string) {
3446
prepareServerEnv(password)
35-
const { Log, Server } = await import("virtual:opencode-server")
47+
const { Log, Server, PushRelay } = await import("virtual:opencode-server")
3648
await Log.init({ level: "WARN" })
3749
const listener = await Server.listen({
3850
port,
@@ -41,6 +53,18 @@ export async function spawnLocalServer(hostname: string, port: number, password:
4153
password,
4254
})
4355

56+
const relayURL = (process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ?? DEFAULT_RELAY_URL).trim()
57+
const relaySecretInput = (process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
58+
const relaySecret = relaySecretInput || getOrCreateRelaySecret()
59+
if (relayURL && relaySecret) {
60+
PushRelay.start({
61+
relayURL,
62+
relaySecret,
63+
hostname,
64+
port: listener.port,
65+
})
66+
}
67+
4468
const wait = (async () => {
4569
const url = `http://${hostname}:${port}`
4670

packages/opencode/src/cli/cmd/serve.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { spawnSync } from "node:child_process"
22
import { createHash, randomBytes } from "node:crypto"
3+
import { writeFileSync } from "node:fs"
4+
import path from "node:path"
35
import os from "node:os"
46
import { Server } from "../../server/server"
57
import { cmd } from "./cmd"
@@ -10,10 +12,24 @@ import { Project } from "../../project"
1012
import { Installation } from "../../installation"
1113
import { PushRelay } from "../../server/push-relay"
1214
import { Log } from "../../util"
15+
import { Global } from "../../global"
1316
import * as QRCode from "qrcode"
1417

1518
const log = Log.create({ service: "serve" })
1619

20+
async function getOrCreatePersistedRelaySecret(): Promise<string> {
21+
const filePath = path.join(Global.Path.state, "relay-secret")
22+
try {
23+
const existing = (await Bun.file(filePath).text()).trim()
24+
if (existing.length > 0) return existing
25+
} catch {
26+
// file doesn't exist yet
27+
}
28+
const secret = randomBytes(18).toString("base64url")
29+
writeFileSync(filePath, secret, { mode: 0o600 })
30+
return secret
31+
}
32+
1733
type PairPayload = {
1834
serverID?: string
1935
relayURL: string
@@ -225,7 +241,7 @@ export const ServeCommand = cmd({
225241
]
226242

227243
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
228-
const relaySecret = input || randomBytes(18).toString("base64url")
244+
const relaySecret = input || (await getOrCreatePersistedRelaySecret())
229245
const connectQR = Boolean(args["connect-qr"])
230246

231247
if (connectQR) {
@@ -236,10 +252,7 @@ export const ServeCommand = cmd({
236252
}
237253

238254
if (!input) {
239-
console.log("experimental push relay secret generated")
240-
console.log(
241-
"set --relay-secret or OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET to keep push registrations stable across server restarts",
242-
)
255+
log.info("using persisted relay secret", { hash: secretHash(relaySecret) })
243256
}
244257

245258
console.log("printing connect qr without starting the server")
@@ -259,10 +272,7 @@ export const ServeCommand = cmd({
259272
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
260273

261274
if (!input) {
262-
console.log("experimental push relay secret generated")
263-
console.log(
264-
"set --relay-secret or OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET to keep push registrations stable across server restarts",
265-
)
275+
log.info("using persisted relay secret", { hash: secretHash(relaySecret) })
266276
}
267277
if (relayURL && relaySecret) {
268278
const host = server.hostname ?? opts.hostname

packages/opencode/src/node.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { Config } from "./config"
22
export { Server } from "./server/server"
3+
export { PushRelay } from "./server/push-relay"
34
export { bootstrap } from "./cli/bootstrap"
45
export { Log } from "./util"
56
export { Database } from "./storage"

packages/opencode/src/server/instance/experimental.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from "node:crypto"
12
import { Hono } from "hono"
23
import { describeRoute, validator, resolver } from "hono-openapi"
34
import z from "zod"
@@ -36,6 +37,9 @@ const PushPairResult = z
3637
z.object({
3738
enabled: z.literal(true),
3839
hosts: z.array(z.string()),
40+
relayURL: z.string(),
41+
serverID: z.string().optional(),
42+
relaySecretHash: z.string(),
3943
link: z.string(),
4044
qr: z.string(),
4145
}),
@@ -488,10 +492,16 @@ export const ExperimentalRoutes = lazy(() =>
488492

489493
const link = pushPairLink(pair)
490494
const qr = await pushPairQRCode(pair)
495+
const relaySecretHash = pair.relaySecret
496+
? `${createHash("sha256").update(pair.relaySecret).digest("hex").slice(0, 12)}...`
497+
: "none"
491498

492499
return c.json({
493500
enabled: true,
494501
hosts: pair.hosts,
502+
relayURL: pair.relayURL,
503+
serverID: pair.serverID,
504+
relaySecretHash,
495505
link,
496506
qr,
497507
})

0 commit comments

Comments
 (0)