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
This commit is contained in:
Ryan Vogel 2026-04-17 22:31:02 +00:00
parent 38d4d03ba8
commit 754951bbbd
7 changed files with 154 additions and 28 deletions

View file

@ -2,21 +2,60 @@ import { type Component, createResource, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk"
import { useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { SettingsList } from "./settings-list"
type PairResult = { enabled: false } | { enabled: true; hosts: string[]; link: string; qr: string }
type PairResult =
| { enabled: false }
| {
enabled: true
hosts: string[]
relayURL?: string
serverID?: string
relaySecretHash?: string
link: string
qr: string
}
export const SettingsPair: Component = () => {
const language = useLanguage()
const globalSDK = useGlobalSDK()
const server = useServer()
const platform = usePlatform()
const [data] = createResource(async () => {
const url = `${globalSDK.url}/experimental/push/pair`
console.debug("[settings-pair] fetching pair data", {
serverUrl: globalSDK.url,
serverName: server.name,
serverKey: server.key,
})
const f = platform.fetch ?? fetch
const res = await f(`${globalSDK.url}/experimental/push/pair`)
if (!res.ok) return { enabled: false as const }
return (await res.json()) as PairResult
const res = await f(url)
if (!res.ok) {
console.debug("[settings-pair] pair endpoint returned non-ok", {
status: res.status,
serverUrl: globalSDK.url,
})
return { enabled: false as const }
}
const result = (await res.json()) as PairResult
console.debug("[settings-pair] pair data received", {
enabled: result.enabled,
serverUrl: globalSDK.url,
serverName: server.name,
...(result.enabled
? {
relayURL: result.relayURL,
serverID: result.serverID,
relaySecretHash: result.relaySecretHash,
hostCount: result.hosts.length,
hosts: result.hosts,
}
: {}),
})
return result
})
return (
@ -69,21 +108,56 @@ export const SettingsPair: Component = () => {
</SettingsList>
}
>
{(pair) => (
<SettingsList>
<div class="flex flex-col items-center py-8 gap-4">
<img src={(pair() as PairResult & { enabled: true }).qr} alt="Pairing QR code" class="w-64 h-64" />
<div class="flex flex-col gap-1 text-center max-w-sm">
<span class="text-14-medium text-text-strong">
{language.t("settings.pair.instructions.title")}
</span>
<span class="text-13-regular text-text-weak">
{language.t("settings.pair.instructions.description")}
</span>
{(pair) => {
const p = pair() as PairResult & { enabled: true }
return (
<SettingsList>
<div class="flex flex-col items-center py-8 gap-4">
<Show when={server.list.length > 1 || p.relayURL}>
<div class="flex flex-col gap-1.5 w-full max-w-sm text-left">
<div class="flex items-center gap-2">
<span class="text-12-medium text-text-weak shrink-0">
{language.t("settings.pair.server.label")}
</span>
<code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
{server.name}
</code>
</div>
<Show when={p.relayURL}>
<div class="flex items-center gap-2">
<span class="text-12-medium text-text-weak shrink-0">
{language.t("settings.pair.relay.label")}
</span>
<code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
{p.relayURL}
</code>
</div>
</Show>
<Show when={p.relaySecretHash}>
<div class="flex items-center gap-2">
<span class="text-12-medium text-text-weak shrink-0">
{language.t("settings.pair.secret.label")}
</span>
<code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
{p.relaySecretHash}
</code>
</div>
</Show>
</div>
</Show>
<img src={p.qr} alt="Pairing QR code" class="w-64 h-64" />
<div class="flex flex-col gap-1 text-center max-w-sm">
<span class="text-14-medium text-text-strong">
{language.t("settings.pair.instructions.title")}
</span>
<span class="text-13-regular text-text-weak">
{language.t("settings.pair.instructions.description")}
</span>
</div>
</div>
</div>
</SettingsList>
)}
</SettingsList>
)
}}
</Show>
)}
</Show>

View file

@ -865,6 +865,9 @@ export const dict = {
"settings.pair.error.description": "Check that the server is reachable and try again.",
"settings.pair.disabled.title": "Push relay is not enabled",
"settings.pair.disabled.description": "Start the server with push relay options to enable mobile pairing.",
"settings.pair.server.label": "Server",
"settings.pair.relay.label": "Relay",
"settings.pair.secret.label": "Secret",
"settings.pair.instructions.title": "Scan with the OpenCode Control app",
"settings.pair.instructions.description":
"Open the OpenCode Control app and scan this QR code to pair your device for push notifications.",

View file

@ -10,6 +10,10 @@ declare module "virtual:opencode-server" {
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
}
export namespace PushRelay {
export const start: typeof import("../../../opencode/dist/types/src/node").PushRelay.start
export const stop: typeof import("../../../opencode/dist/types/src/node").PushRelay.stop
}
export namespace Config {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info

View file

@ -1,8 +1,20 @@
import { randomBytes } from "node:crypto"
import { app } from "electron"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { store } from "./store"
const DEFAULT_RELAY_URL = "https://apn.dev.opencode.ai"
const RELAY_SECRET_KEY = "relaySecret"
function getOrCreateRelaySecret(): string {
const existing = store.get(RELAY_SECRET_KEY)
if (typeof existing === "string" && existing.length > 0) return existing
const secret = randomBytes(18).toString("base64url")
store.set(RELAY_SECRET_KEY, secret)
return secret
}
export type WslConfig = { enabled: boolean }
export type HealthCheck = { wait: Promise<void> }
@ -32,7 +44,7 @@ export function setWslConfig(config: WslConfig) {
export async function spawnLocalServer(hostname: string, port: number, password: string) {
prepareServerEnv(password)
const { Log, Server } = await import("virtual:opencode-server")
const { Log, Server, PushRelay } = await import("virtual:opencode-server")
await Log.init({ level: "WARN" })
const listener = await Server.listen({
port,
@ -41,6 +53,18 @@ export async function spawnLocalServer(hostname: string, port: number, password:
password,
})
const relayURL = (process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ?? DEFAULT_RELAY_URL).trim()
const relaySecretInput = (process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
const relaySecret = relaySecretInput || getOrCreateRelaySecret()
if (relayURL && relaySecret) {
PushRelay.start({
relayURL,
relaySecret,
hostname,
port: listener.port,
})
}
const wait = (async () => {
const url = `http://${hostname}:${port}`

View file

@ -1,5 +1,7 @@
import { spawnSync } from "node:child_process"
import { createHash, randomBytes } from "node:crypto"
import { writeFileSync } from "node:fs"
import path from "node:path"
import os from "node:os"
import { Server } from "../../server/server"
import { cmd } from "./cmd"
@ -10,10 +12,24 @@ import { Project } from "../../project"
import { Installation } from "../../installation"
import { PushRelay } from "../../server/push-relay"
import { Log } from "../../util"
import { Global } from "../../global"
import * as QRCode from "qrcode"
const log = Log.create({ service: "serve" })
async function getOrCreatePersistedRelaySecret(): Promise<string> {
const filePath = path.join(Global.Path.state, "relay-secret")
try {
const existing = (await Bun.file(filePath).text()).trim()
if (existing.length > 0) return existing
} catch {
// file doesn't exist yet
}
const secret = randomBytes(18).toString("base64url")
writeFileSync(filePath, secret, { mode: 0o600 })
return secret
}
type PairPayload = {
serverID?: string
relayURL: string
@ -225,7 +241,7 @@ export const ServeCommand = cmd({
]
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
const relaySecret = input || randomBytes(18).toString("base64url")
const relaySecret = input || (await getOrCreatePersistedRelaySecret())
const connectQR = Boolean(args["connect-qr"])
if (connectQR) {
@ -236,10 +252,7 @@ export const ServeCommand = cmd({
}
if (!input) {
console.log("experimental push relay secret generated")
console.log(
"set --relay-secret or OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET to keep push registrations stable across server restarts",
)
log.info("using persisted relay secret", { hash: secretHash(relaySecret) })
}
console.log("printing connect qr without starting the server")
@ -259,10 +272,7 @@ export const ServeCommand = cmd({
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
if (!input) {
console.log("experimental push relay secret generated")
console.log(
"set --relay-secret or OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET to keep push registrations stable across server restarts",
)
log.info("using persisted relay secret", { hash: secretHash(relaySecret) })
}
if (relayURL && relaySecret) {
const host = server.hostname ?? opts.hostname

View file

@ -1,5 +1,6 @@
export { Config } from "./config"
export { Server } from "./server/server"
export { PushRelay } from "./server/push-relay"
export { bootstrap } from "./cli/bootstrap"
export { Log } from "./util"
export { Database } from "./storage"

View file

@ -1,3 +1,4 @@
import { createHash } from "node:crypto"
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
@ -36,6 +37,9 @@ const PushPairResult = z
z.object({
enabled: z.literal(true),
hosts: z.array(z.string()),
relayURL: z.string(),
serverID: z.string().optional(),
relaySecretHash: z.string(),
link: z.string(),
qr: z.string(),
}),
@ -488,10 +492,16 @@ export const ExperimentalRoutes = lazy(() =>
const link = pushPairLink(pair)
const qr = await pushPairQRCode(pair)
const relaySecretHash = pair.relaySecret
? `${createHash("sha256").update(pair.relaySecret).digest("hex").slice(0, 12)}...`
: "none"
return c.json({
enabled: true,
hosts: pair.hosts,
relayURL: pair.relayURL,
serverID: pair.serverID,
relaySecretHash,
link,
qr,
})