mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(cli): include every pairing address in QR codes (#53588)
This commit is contained in:
@@ -4,7 +4,18 @@ import { pairingLink } from "./pairing"
|
||||
describe("pairing link", () => {
|
||||
test("reads the server address and code from opencode pair links", () => {
|
||||
expect(pairingLink(" http://192.168.1.2:49374/auth/connect/abc_DEF-123 ")).toEqual({
|
||||
url: "http://192.168.1.2:49374",
|
||||
urls: ["http://192.168.1.2:49374"],
|
||||
code: "abc_DEF-123",
|
||||
})
|
||||
})
|
||||
|
||||
test("reads every server address from opencode pair QR codes", () => {
|
||||
expect(
|
||||
pairingLink(
|
||||
JSON.stringify({ code: "abc_DEF-123", urls: ["http://192.168.1.2:49374", "http://100.64.0.3:49374"] }),
|
||||
),
|
||||
).toEqual({
|
||||
urls: ["http://192.168.1.2:49374", "http://100.64.0.3:49374"],
|
||||
code: "abc_DEF-123",
|
||||
})
|
||||
})
|
||||
@@ -16,4 +27,12 @@ describe("pairing link", () => {
|
||||
expect(pairingLink("opencode-ios://auth/connect/abc")).toBeUndefined()
|
||||
expect(pairingLink("192.168.1.2:49374")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("rejects malformed QR payloads", () => {
|
||||
expect(pairingLink(JSON.stringify({ code: "abc", urls: [] }))).toBeUndefined()
|
||||
expect(pairingLink(JSON.stringify({ code: "a/b", urls: ["http://192.168.1.2:49374"] }))).toBeUndefined()
|
||||
expect(pairingLink(JSON.stringify({ code: "abc", urls: ["opencode-ios://host"] }))).toBeUndefined()
|
||||
expect(pairingLink(JSON.stringify({ urls: ["http://192.168.1.2:49374"] }))).toBeUndefined()
|
||||
expect(pairingLink("{not json")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { Option, Schema } from "effect"
|
||||
import { normalizeServerUrl } from "@/runtime/server/registry"
|
||||
|
||||
export function serverAddress(value: string) {
|
||||
@@ -15,26 +16,48 @@ export function serverAddress(value: string) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Links printed by `opencode pair` carry a single-use code that the server exchanges for a session token.
|
||||
const CODE = /^[A-Za-z0-9_-]+$/
|
||||
|
||||
const decodePayload = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(Schema.Struct({ code: Schema.String, urls: Schema.Array(Schema.String) })),
|
||||
)
|
||||
|
||||
// `opencode pair` prints links carrying a single-use code that the server exchanges for a session token.
|
||||
// Its QR code carries the same code with every reachable server address as {"code","urls"} JSON.
|
||||
export function pairingLink(value: string) {
|
||||
const url = URL.parse(value.trim())
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (trimmed.startsWith("{")) {
|
||||
const payload = Option.getOrUndefined(decodePayload(trimmed))
|
||||
|
||||
if (!payload || !CODE.test(payload.code)) return
|
||||
const urls = [...new Set(payload.urls)].map(serverAddress)
|
||||
|
||||
if (urls.length === 0 || urls.some((url) => url === undefined)) return
|
||||
|
||||
return { urls: urls.filter((url) => url !== undefined), code: payload.code }
|
||||
}
|
||||
|
||||
const url = URL.parse(trimmed)
|
||||
|
||||
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return
|
||||
const code = /^\/auth\/connect\/([A-Za-z0-9_-]+)$/.exec(url.pathname)?.[1]
|
||||
const code = /^\/auth\/connect\/([^/]+)$/.exec(url.pathname)?.[1]
|
||||
const address = serverAddress(url.origin)
|
||||
|
||||
if (!code || !address) return
|
||||
if (!code || !CODE.test(code) || !address) return
|
||||
|
||||
return { url: address, code }
|
||||
return { urls: [address], code }
|
||||
}
|
||||
|
||||
export type Pairing = { readonly url: string; readonly password: string }
|
||||
|
||||
export function redeemPairingLink(link: { url: string; code: string }) {
|
||||
return OpenCode.make({ baseUrl: link.url })
|
||||
.server.connect({ code: link.code })
|
||||
.then(
|
||||
(session): Pairing => ({ url: link.url, password: session.token }),
|
||||
() => undefined,
|
||||
)
|
||||
// Every address may reach the same server, but the code is single-use, so at most one attempt succeeds.
|
||||
export function redeemPairingLink(link: { urls: ReadonlyArray<string>; code: string }) {
|
||||
return Promise.any(
|
||||
link.urls.map((url) =>
|
||||
OpenCode.make({ baseUrl: url })
|
||||
.server.connect({ code: link.code }, { signal: AbortSignal.timeout(5_000) })
|
||||
.then((session): Pairing => ({ url, password: session.token })),
|
||||
),
|
||||
).catch(() => undefined)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
|
||||
}
|
||||
|
||||
// Keep the token in the form so a failed connection check can retry without the spent code.
|
||||
setState({ url: link.url, password: redeemed.password })
|
||||
setState({ url: redeemed.url, password: redeemed.password })
|
||||
}
|
||||
|
||||
const url = serverAddress(state.url)
|
||||
|
||||
@@ -21,17 +21,21 @@ export default Runtime.handler(
|
||||
: (yield* Effect.tryPromise(() => client.server.info())).urls
|
||||
const pairing = yield* Effect.tryPromise(() => client.server.pair())
|
||||
const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href)
|
||||
// Loopback URLs are useless to the scanning device, so the QR code only carries reachable addresses.
|
||||
const remote = urls.filter((url) => !isLoopback(new URL(url).hostname))
|
||||
process.stdout.write(
|
||||
[
|
||||
"",
|
||||
` Open a link to connect. Links work once and expire in ${Math.round(pairing.expires_in / 60)} minutes.`,
|
||||
"",
|
||||
...(links.length ? links.map((link) => ` ${link}`) : [" (no server URLs)"]),
|
||||
...(links[0]
|
||||
...(remote.length
|
||||
? [
|
||||
"",
|
||||
// uqr separates rows with "\n" on every platform, so splitting on EOL ("\r\n" on Windows) indents only the first row.
|
||||
renderUnicodeCompact(links[0], { border: 2 })
|
||||
renderUnicodeCompact(JSON.stringify({ code: pairing.code, urls: remote }), {
|
||||
border: 2,
|
||||
})
|
||||
.split("\n")
|
||||
.map((line) => " " + line)
|
||||
.join(EOL),
|
||||
@@ -56,3 +60,9 @@ export default Runtime.handler(
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
function isLoopback(hostname: string) {
|
||||
return (
|
||||
hostname === "localhost" || hostname.endsWith(".localhost") || hostname.startsWith("127.") || hostname === "[::1]"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@ function SettingsPairing(props: { client: Client }) {
|
||||
// Reading pending query data would suspend the entire settings surface.
|
||||
const localInfo = () => (local.isSuccess ? local.data : undefined)
|
||||
|
||||
const localHost = createMemo(() =>
|
||||
localInfo()?.urls.find((value) => {
|
||||
// Loopback URLs are useless to the scanning device, so the QR code only carries reachable addresses.
|
||||
const localHosts = createMemo(() =>
|
||||
(localInfo()?.urls ?? []).filter((value) => {
|
||||
const host = new URL(value).hostname
|
||||
|
||||
return (
|
||||
@@ -78,10 +79,15 @@ function SettingsPairing(props: { client: Client }) {
|
||||
<Row title={ctx.t("connection")} description={ctx.t("local.description")}>
|
||||
<Button
|
||||
variant="neutral"
|
||||
disabled={!localHost()}
|
||||
disabled={localHosts().length === 0}
|
||||
onClick={() =>
|
||||
dialogs.open(() => (
|
||||
<DialogPairing title={ctx.t("connection")} host={localHost()!} code={() => props.client.code()} />
|
||||
<DialogPairing
|
||||
title={ctx.t("connection")}
|
||||
host={localHosts()[0]!}
|
||||
hosts={localHosts()}
|
||||
code={() => props.client.code()}
|
||||
/>
|
||||
))
|
||||
}
|
||||
>
|
||||
@@ -120,7 +126,12 @@ function SettingsPairing(props: { client: Client }) {
|
||||
/** The pending return from "Copied" to the copy label. */
|
||||
type CopiedTimer = { timeout?: ReturnType<typeof setTimeout> }
|
||||
|
||||
function DialogPairing(props: { title: string; host: string; code: () => Promise<string> }) {
|
||||
function DialogPairing(props: {
|
||||
title: string
|
||||
host: string
|
||||
hosts: ReadonlyArray<string>
|
||||
code: () => Promise<string>
|
||||
}) {
|
||||
const ctx = useExtension()
|
||||
const system = ctx.system
|
||||
|
||||
@@ -156,11 +167,13 @@ function DialogPairing(props: { title: string; host: string; code: () => Promise
|
||||
}))
|
||||
|
||||
const qr = createMemo(() => {
|
||||
const value = url()
|
||||
if (!code.isSuccess) return
|
||||
|
||||
if (!value) return
|
||||
|
||||
return renderSVG(value, { border: 4, blackColor: "currentColor", whiteColor: "transparent" })
|
||||
return renderSVG(JSON.stringify({ code: code.data, urls: props.hosts }), {
|
||||
border: 4,
|
||||
blackColor: "currentColor",
|
||||
whiteColor: "transparent",
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,9 +21,12 @@ export function connectionURLs(value: string, requestedHostname?: string) {
|
||||
const family = hostname === "0.0.0.0" ? "IPv4" : hostname === "::" || hostname === "[::]" ? "IPv6" : undefined
|
||||
if (family === undefined) return [value]
|
||||
|
||||
const loopback = new URL(value)
|
||||
loopback.hostname = family === "IPv6" ? "[::1]" : "127.0.0.1"
|
||||
return [
|
||||
...new Set(
|
||||
Object.values(networkInterfaces())
|
||||
...new Set([
|
||||
loopback.toString().replace(/\/$/, ""),
|
||||
...Object.values(networkInterfaces())
|
||||
.flatMap((entries) => entries ?? [])
|
||||
.filter((entry) => !entry.internal && entry.family === family)
|
||||
.map((entry) => {
|
||||
@@ -31,7 +34,7 @@ export function connectionURLs(value: string, requestedHostname?: string) {
|
||||
result.hostname = family === "IPv6" ? `[${entry.address}]` : entry.address
|
||||
return result.toString().replace(/\/$/, "")
|
||||
}),
|
||||
),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user