Ver Fonte

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
Ryan Vogel há 4 meses atrás
pai
commit
754951bbbd

+ 92 - 18
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 = () => {
               </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>

+ 3 - 0
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.",

+ 4 - 0
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

+ 25 - 1
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<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}`
 

+ 19 - 9
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<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

+ 1 - 0
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"

+ 10 - 0
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,
         })