mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-20 01:12:15 +00:00
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:
parent
38d4d03ba8
commit
754951bbbd
7 changed files with 154 additions and 28 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
packages/desktop-electron/src/main/env.d.ts
vendored
4
packages/desktop-electron/src/main/env.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue