diff --git a/packages/app/src/components/settings-pair.tsx b/packages/app/src/components/settings-pair.tsx index 7a7b69ce8b..ea9ab6f35e 100644 --- a/packages/app/src/components/settings-pair.tsx +++ b/packages/app/src/components/settings-pair.tsx @@ -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 = () => { } > - {(pair) => ( - -
- Pairing QR code -
- - {language.t("settings.pair.instructions.title")} - - - {language.t("settings.pair.instructions.description")} - + {(pair) => { + const p = pair() as PairResult & { enabled: true } + return ( + +
+ 1 || p.relayURL}> +
+
+ + {language.t("settings.pair.server.label")} + + + {server.name} + +
+ +
+ + {language.t("settings.pair.relay.label")} + + + {p.relayURL} + +
+
+ +
+ + {language.t("settings.pair.secret.label")} + + + {p.relaySecretHash} + +
+
+
+
+ Pairing QR code +
+ + {language.t("settings.pair.instructions.title")} + + + {language.t("settings.pair.instructions.description")} + +
-
- - )} + + ) + }} )} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 4144b1fc3a..b421417ee1 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -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.", diff --git a/packages/desktop-electron/src/main/env.d.ts b/packages/desktop-electron/src/main/env.d.ts index 1de56e1c90..42cb7821f6 100644 --- a/packages/desktop-electron/src/main/env.d.ts +++ b/packages/desktop-electron/src/main/env.d.ts @@ -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 diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 5a6050013a..00b3e1a573 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -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 } @@ -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}` diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index c184a56ed6..4323a4a96e 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -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 { + 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 diff --git a/packages/opencode/src/node.ts b/packages/opencode/src/node.ts index 1cb30d8082..30196d34e6 100644 --- a/packages/opencode/src/node.ts +++ b/packages/opencode/src/node.ts @@ -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" diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index 88e9d60b9c..2bb355e87b 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -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, })