From cb535eef9d0240a4806b51c76c23e8931de2732d Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Sun, 29 Mar 2026 18:32:21 -0400 Subject: [PATCH] feat: support advertised QR hosts for mobile pairing Allow serve to publish preferred host/domain entries in QR payloads and make mobile choose the first reachable host by QR order so preferred addresses like .ts.net are selected consistently. --- packages/mobile-voice/src/app/index.tsx | 109 ++++++++++++++------- packages/opencode/src/cli/cmd/serve.ts | 64 ++++++++++-- packages/opencode/src/server/push-relay.ts | 42 ++++++-- 3 files changed, 168 insertions(+), 47 deletions(-) diff --git a/packages/mobile-voice/src/app/index.tsx b/packages/mobile-voice/src/app/index.tsx index 6572cde885..a5cca18704 100644 --- a/packages/mobile-voice/src/app/index.tsx +++ b/packages/mobile-voice/src/app/index.tsx @@ -342,6 +342,7 @@ type DropdownMode = "none" | "server" | "session" type Pair = { v: 1 + name?: string serverID?: string relayURL: string relaySecret: string @@ -426,10 +427,13 @@ function parsePair(input: string): Pair | undefined { if (!Array.isArray((data as { hosts?: unknown }).hosts)) return const hosts = (data as { hosts: unknown[] }).hosts.filter((item): item is string => typeof item === "string") if (!hosts.length) return + const nameRaw = (data as { name?: unknown }).name + const name = typeof nameRaw === "string" && nameRaw.trim().length > 0 ? nameRaw.trim() : undefined const serverIDRaw = (data as { serverID?: unknown }).serverID const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : undefined return { v: 1, + name, serverID, relayURL: (data as { relayURL: string }).relayURL, relaySecret: (data as { relaySecret: string }).relaySecret, @@ -444,10 +448,17 @@ function isLoopback(hostname: string): boolean { return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "0.0.0.0" || hostname === "::1" } +function isCarrierGradeNat(hostname: string): boolean { + const match = /^100\.(\d{1,3})\./.exec(hostname) + if (!match) return false + const octet = Number(match[1]) + return octet >= 64 && octet <= 127 +} + /** - * Race all non-loopback hosts in parallel by hitting /health. - * Returns the first one that responds with 200, or falls back to the - * first non-loopback entry (preserving server-side ordering) if none respond. + * Probe all non-loopback hosts in parallel by hitting /health, then + * choose the first reachable host based on the original ordering. + * This preserves server-side preference (e.g. tailnet before LAN). */ async function pickHost(list: string[]): Promise { const candidates = list.filter((item) => { @@ -460,27 +471,34 @@ async function pickHost(list: string[]): Promise { if (!candidates.length) return list[0] - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 3000) + const probes = candidates.map(async (host) => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 3000) + try { + const res = await fetch(`${host.replace(/\/+$/, "")}/health`, { + method: "GET", + signal: controller.signal, + }) + return res.ok + } catch { + return false + } finally { + clearTimeout(timeout) + } + }) + for (let index = 0; index < candidates.length; index += 1) { + const reachable = await probes[index] + if (reachable) { + return candidates[index] + } + } + + // none reachable — keep first candidate as deterministic fallback try { - const winner = await Promise.any( - candidates.map(async (host) => { - const res = await fetch(`${host.replace(/\/+$/, "")}/health`, { - method: "GET", - signal: controller.signal, - }) - if (!res.ok) throw new Error(`${res.status}`) - return host - }), - ) - return winner - } catch { - // all failed or timed out — fall back to first candidate (server already orders by reachability) return candidates[0] - } finally { - clearTimeout(timeout) - controller.abort() + } catch { + return list[0] } } @@ -489,11 +507,7 @@ function serverBases(input: string) { const list = [base] try { const url = new URL(base) - const local = - url.hostname === "127.0.0.1" || - url.hostname === "localhost" || - url.hostname === "::1" || - url.hostname.startsWith("10.") + const local = looksLikeLocalHost(url.hostname) const tailnet = url.hostname.endsWith(".ts.net") const secure = `https://${url.host}` const insecure = `http://${url.host}` @@ -514,11 +528,14 @@ function serverBases(input: string) { function looksLikeLocalHost(hostname: string): boolean { return ( + hostname === "127.0.0.1" || + hostname === "::1" || hostname === "localhost" || hostname.endsWith(".local") || hostname.startsWith("10.") || hostname.startsWith("192.168.") || - /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) + /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) || + isCarrierGradeNat(hostname) ) } @@ -1553,7 +1570,12 @@ export default function DictationScreen() { setLocalNetworkPermissionState("pending") - const localProbes = new Set(["http://192.168.1.1", "http://192.168.0.1", "http://10.0.0.1"]) + const localProbes = new Set([ + "http://192.168.1.1", + "http://192.168.0.1", + "http://10.0.0.1", + "http://100.100.100.100", + ]) for (const server of serversRef.current) { try { @@ -2127,8 +2149,13 @@ export default function DictationScreen() { const base = candidates[0] ?? server.url.replace(/\/+$/, "") const healthURL = `${base}/health` const sessionsURL = `${base}/experimental/session?limit=100` - const insecureRemote = - base.startsWith("http://") && !base.includes("127.0.0.1") && !base.includes("localhost") && !base.includes("10.") + let insecureRemote = false + try { + const parsedBase = new URL(base) + insecureRemote = parsedBase.protocol === "http:" && !looksLikeLocalHost(parsedBase.hostname) + } catch { + insecureRemote = base.startsWith("http://") + } console.log("[Server] refresh:start", { id: server.id, name: server.name, @@ -2517,7 +2544,7 @@ export default function DictationScreen() { ) const addServer = useCallback( - (serverURL: string, relayURL: string, relaySecretRaw: string, serverIDRaw?: string) => { + (serverURL: string, relayURL: string, relaySecretRaw: string, serverIDRaw?: string, nameRaw?: string) => { const raw = serverURL.trim() if (!raw) return false @@ -2542,9 +2569,11 @@ export default function DictationScreen() { const id = `srv-${Date.now()}` const relaySecret = relaySecretRaw.trim() const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null + const explicitName = typeof nameRaw === "string" && nameRaw.trim().length > 0 ? nameRaw.trim() : null const url = `${parsed.protocol}//${parsed.host}` const inferredName = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname + const name = explicitName ?? inferredName const relay = `${relayParsed.protocol}//${relayParsed.host}` const existing = serversRef.current.find( (item) => @@ -2554,8 +2583,18 @@ export default function DictationScreen() { (!serverID || item.serverID === serverID || item.serverID === null), ) if (existing) { - if (serverID && existing.serverID !== serverID) { - setServers((prev) => prev.map((item) => (item.id === existing.id ? { ...item, serverID } : item))) + if ((serverID && existing.serverID !== serverID) || (explicitName && existing.name !== explicitName)) { + setServers((prev) => + prev.map((item) => + item.id === existing.id + ? { + ...item, + name: explicitName ?? item.name, + serverID: serverID ?? item.serverID, + } + : item, + ), + ) } setActiveServerId(existing.id) setActiveSessionId(null) @@ -2568,7 +2607,7 @@ export default function DictationScreen() { ...prev, { id, - name: inferredName, + name, url, serverID, relayURL: relay, @@ -2675,7 +2714,7 @@ export default function DictationScreen() { return } - const ok = addServer(host, pair.relayURL, pair.relaySecret, pair.serverID) + const ok = addServer(host, pair.relayURL, pair.relaySecret, pair.serverID, pair.name) if (!ok) { scanLockRef.current = false return diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index b6dfba439c..eaf10ce238 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -24,17 +24,51 @@ function ipTier(address: string): number { return 0 } -function hosts(hostname: string, port: number) { +function norm(input: string) { + return input.replace(/\/+$/, "") +} + +function advertiseURL(input: string, port: number): string | undefined { + const raw = input.trim() + if (!raw) return + + try { + const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`) + if (!parsed.hostname) return + if (!parsed.port) { + parsed.port = String(port) + } + return norm(`${parsed.protocol}//${parsed.host}`) + } catch { + return + } +} + +function hosts(hostname: string, port: number, advertised: string[] = []) { const seen = new Set() + const preferred: string[] = [] const entries: Array<{ url: string; tier: number }> = [] + + const addPreferred = (value: string) => { + const url = advertiseURL(value, port) + if (!url) return + if (seen.has(url)) return + seen.add(url) + preferred.push(url) + } + const add = (item: string) => { if (!item) return if (item === "0.0.0.0") return if (item === "::") return - if (seen.has(item)) return - seen.add(item) - entries.push({ url: `http://${item}:${port}`, tier: ipTier(item) }) + const url = `http://${item}:${port}` + if (seen.has(url)) return + seen.add(url) + entries.push({ url, tier: ipTier(item) }) } + + advertised.forEach(addPreferred) + add(hostname) add("127.0.0.1") Object.values(os.networkInterfaces()) @@ -43,7 +77,7 @@ function hosts(hostname: string, port: number) { .map((item) => item.address) .forEach(add) entries.sort((a, b) => a.tier - b.tier) - return entries.map((item) => item.url) + return [...preferred, ...entries.map((item) => item.url)] } export const ServeCommand = cmd({ @@ -57,6 +91,11 @@ export const ServeCommand = cmd({ .option("relay-secret", { type: "string", describe: "experimental APN relay secret", + }) + .option("advertise-host", { + type: "string", + array: true, + describe: "preferred host/domain for mobile QR (repeatable, supports host[:port] or URL)", }), describe: "starts a headless opencode server", handler: async (args) => { @@ -72,6 +111,18 @@ export const ServeCommand = cmd({ process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ?? "https://apn.dev.opencode.ai" ).trim() + const advertiseHostArg = args["advertise-host"] + const advertiseHostsFromArg = Array.isArray(advertiseHostArg) + ? advertiseHostArg + : typeof advertiseHostArg === "string" + ? [advertiseHostArg] + : [] + const advertiseHostsFromEnv = (process.env.OPENCODE_EXPERIMENTAL_PUSH_ADVERTISE_HOSTS ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + const advertiseHosts = [...new Set([...advertiseHostsFromArg, ...advertiseHostsFromEnv])] + const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim() const relaySecret = input || randomBytes(18).toString("base64url") if (!input) { @@ -88,13 +139,14 @@ export const ServeCommand = cmd({ relaySecret, hostname: host, port, + advertiseHosts, }) const pair = started ?? PushRelay.pair() ?? { v: 1 as const, relayURL, relaySecret, - hosts: hosts(host, port), + hosts: hosts(host, port, advertiseHosts), } if (!started) { console.log("experimental push relay failed to initialize; showing setup qr anyway") diff --git a/packages/opencode/src/server/push-relay.ts b/packages/opencode/src/server/push-relay.ts index e72bf7f5d2..2d493be745 100644 --- a/packages/opencode/src/server/push-relay.ts +++ b/packages/opencode/src/server/push-relay.ts @@ -19,6 +19,7 @@ type Input = { relaySecret: string hostname: string port: number + advertiseHosts?: string[] } type State = { @@ -99,18 +100,47 @@ function ipTier(address: string): number { return 0 } -function list(hostname: string, port: number) { +function advertiseURL(input: string, port: number): string | undefined { + const raw = input.trim() + if (!raw) return + + try { + const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`) + if (!parsed.hostname) return + if (!parsed.port) { + parsed.port = String(port) + } + return norm(`${parsed.protocol}//${parsed.host}`) + } catch { + return + } +} + +function list(hostname: string, port: number, advertised: string[] = []) { const seen = new Set() + const preferred: string[] = [] const hosts: Array<{ url: string; tier: number }> = [] + + const addPreferred = (input: string) => { + const url = advertiseURL(input, port) + if (!url) return + if (seen.has(url)) return + seen.add(url) + preferred.push(url) + } + const add = (host: string) => { if (!host) return if (host === "0.0.0.0") return if (host === "::") return - if (seen.has(host)) return - seen.add(host) - hosts.push({ url: `http://${host}:${port}`, tier: ipTier(host) }) + const url = `http://${host}:${port}` + if (seen.has(url)) return + seen.add(url) + hosts.push({ url, tier: ipTier(host) }) } + advertised.forEach(addPreferred) + add(hostname) add("127.0.0.1") @@ -124,7 +154,7 @@ function list(hostname: string, port: number) { // sort: most externally reachable first, loopback last hosts.sort((a, b) => a.tier - b.tier) - return hosts.map((item) => item.url) + return [...preferred, ...hosts.map((item) => item.url)] } function map(event: Event): { type: Type; sessionID: string } | undefined { @@ -353,7 +383,7 @@ export namespace PushRelay { serverID: serverID({ relayURL, relaySecret }), relayURL, relaySecret, - hosts: list(input.hostname, input.port), + hosts: list(input.hostname, input.port, input.advertiseHosts ?? []), } const callback = (event: { payload: Event }) => {