diff --git a/packages/app/src/servers/connect/pairing.test.ts b/packages/app/src/servers/connect/pairing.test.ts index 647e227a4df..abee36b9c1d 100644 --- a/packages/app/src/servers/connect/pairing.test.ts +++ b/packages/app/src/servers/connect/pairing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { decodePairingCode, decodePairingUrl, pairingUrl } from "./pairing" +import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing" describe("pairing URL", () => { test("pairs with the current origin using credentials without server URLs", () => { @@ -48,3 +48,48 @@ describe("pairing URL", () => { expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined() }) }) + +describe("pairing scan", () => { + const info = { + urls: ["http://192.168.1.2:49374", "http://127.0.0.1:49374"], + username: "opencode" as const, + password: "a+b & café", + } + + test("decodes the raw JSON code", () => { + expect(decodePairingScan(JSON.stringify(info))).toEqual({ urls: info.urls, password: info.password }) + }) + + test("decodes a direct /connect URL", () => { + expect( + decodePairingScan(pairingUrl({ username: info.username, password: info.password }, "http://192.168.1.2:49374")), + ).toEqual({ + urls: ["http://192.168.1.2:49374"], + password: info.password, + }) + }) + + test("keeps accepting /connect URLs with query data", () => { + expect( + decodePairingScan(`http://192.168.1.2:49374/connect?data=${encodeURIComponent(JSON.stringify(info))}`), + ).toEqual({ + urls: info.urls, + password: info.password, + }) + }) + + test("falls back to the URL origin when the payload omits server URLs", () => { + const origin = "https://opencode.example.com:49709" + expect(decodePairingScan(pairingUrl({ username: "opencode", password: "secret" }, origin))).toEqual({ + urls: [origin], + password: "secret", + }) + }) + + test("rejects URLs without pairing data and non-http schemes", () => { + expect(decodePairingScan("http://192.168.1.2:49374/connect")).toBeUndefined() + expect(decodePairingScan("https://example.com/?data=invalid")).toBeUndefined() + expect(decodePairingScan("opencode-ios://connect?password=secret")).toBeUndefined() + expect(decodePairingScan("not a code")).toBeUndefined() + }) +}) diff --git a/packages/app/src/servers/connect/pairing.ts b/packages/app/src/servers/connect/pairing.ts index e659240b690..9ac2f975dd8 100644 --- a/packages/app/src/servers/connect/pairing.ts +++ b/packages/app/src/servers/connect/pairing.ts @@ -34,6 +34,13 @@ export function pairingUrl(value: { username: "opencode"; password: string }, ho return `${new URL("/connect", host)}#${base64Encode(JSON.stringify(value))}` } +// Scanned codes are either the raw pairing JSON or a full /connect URL from `opencode pair` or desktop. +export function decodePairingScan(value: string) { + const url = URL.parse(value.trim()) + if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value) + return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash, url.origin) +} + export function decodePairingUrl(value: string, origin?: string) { if (value.startsWith("?")) { const data = new URLSearchParams(value).get("data") diff --git a/packages/app/src/servers/connect/scanner.tsx b/packages/app/src/servers/connect/scanner.tsx index 0e41ee7f7ca..13e42daebdb 100644 --- a/packages/app/src/servers/connect/scanner.tsx +++ b/packages/app/src/servers/connect/scanner.tsx @@ -3,11 +3,11 @@ import { onCleanup, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode/ui/button" import { useLanguage } from "@/runtime/i18n/language" -import { decodePairingCode } from "./pairing" +import { decodePairingScan } from "./pairing" import "./scanner.css" export function PairingScanner(props: { - onScan: (value: NonNullable>) => void + onScan: (value: NonNullable>) => void onCancel: () => void }) { const language = useLanguage() @@ -22,7 +22,7 @@ export function PairingScanner(props: { const scanner = new QrScanner( video, (result) => { - const pairing = decodePairingCode(result.data) + const pairing = decodePairingScan(result.data) if (!pairing) { setState("error", language.t("server.connect.scan.invalid")) return diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index 46b0dea87f9..66b7dffc9c1 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -2,6 +2,7 @@ import { EOL } from "os" import { Effect, Option } from "effect" import { Service } from "@opencode/client/effect/service" import { OpenCode } from "@opencode/client/promise" +import { base64Encode } from "@opencode/util/encode" import { renderUnicodeCompact } from "uqr" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" @@ -18,6 +19,9 @@ export default Runtime.handler( OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(), )).urls const info = { urls, username: "opencode", password } + const link = info.urls[0] + ? `${new URL("/connect", info.urls[0])}#${base64Encode(JSON.stringify({ username: info.username, password }))}` + : undefined process.stdout.write( [ "", @@ -25,13 +29,19 @@ export default Runtime.handler( ...info.urls.slice(1).map((url) => ` ${url}`), ` Username ${info.username}`, ` Password ${info.password}`, - "", - " Scan to pair", - "", - renderUnicodeCompact(JSON.stringify(info), { border: 2 }) - .split(EOL) - .map((line) => " " + line) - .join(EOL), + ...(link + ? [ + "", + " Scan to pair", + "", + renderUnicodeCompact(link, { border: 2 }) + .split(EOL) + .map((line) => " " + line) + .join(EOL), + "", + ` Link ${link}`, + ] + : []), "", ].join(EOL) + EOL, ) diff --git a/services/www/src/docs/content/cli/commands.mdx b/services/www/src/docs/content/cli/commands.mdx index 69d118c6529..82bb5fbbf54 100644 --- a/services/www/src/docs/content/cli/commands.mdx +++ b/services/www/src/docs/content/cli/commands.mdx @@ -362,13 +362,15 @@ $ opencode serve --help ## pair -Shows server pairing information, including URLs, credentials, and a QR code. +Shows server pairing information, including URLs, credentials, and a QR code. The +QR code contains a direct link to the advertised server, with credentials in the +URL fragment. ```bash $ opencode pair ``` -Advertise an external URL in the QR code. +Advertise an external URL in the QR code and link. ```bash $ opencode pair --url https://dev.example.com diff --git a/services/www/src/docs/content/cli/web.mdx b/services/www/src/docs/content/cli/web.mdx index 1f1eac3c9b5..b3c89293930 100644 --- a/services/www/src/docs/content/cli/web.mdx +++ b/services/www/src/docs/content/cli/web.mdx @@ -14,8 +14,18 @@ $ opencode pair URLs http://127.0.0.1:49374 Username opencode Password ******** + + Scan to pair + + █▀▀▀▀▀█ ... + + Link http://127.0.0.1:49374/connect#... ``` +The QR code and link open the advertised server directly. Connection credentials +stay in the URL fragment and are saved by the `/connect` page before it starts +authenticated requests. + By default the server runs on port 49374 and listens only on localhost. You can change this config with the `opencode service` command. @@ -66,7 +76,6 @@ Use it when you want to: a foreground process. - Keep a dedicated server instead of the shared background service. - Connect a client to it with `--server`: ```bash