diff --git a/bun.lock b/bun.lock index 39df20acab4a..bf8ad789dd97 100644 --- a/bun.lock +++ b/bun.lock @@ -82,7 +82,9 @@ "effect": "catalog:", "fuzzysort": "catalog:", "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", + "ipaddr.js": "2.5.0", "luxon": "catalog:", + "qr-scanner": "1.4.2", "remeda": "catalog:", "solid-js": "catalog:", "solid-presence": "0.2.0", @@ -100,6 +102,7 @@ "diff": "catalog:", "happy-dom": "20.11.1", "tw-animate-css": "1.4.0", + "uqr": "0.1.3", "vite": "8.2.2", "vite-plugin-pwa": "1.3.0", "vite-plugin-solid": "2.11.14", @@ -3142,6 +3145,8 @@ "@types/npmlog": ["@types/npmlog@7.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-hJWbrKFvxKyWwSUXjZMYTINsSOY6IclhvGOZ97M8ac2tmR9hMwmTnYaMdpGhvju9ctWLTPhCS+eLfQNluiEjQQ=="], + "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], + "@types/pacote": ["@types/pacote@11.1.8", "", { "dependencies": { "@types/node": "*", "@types/npm-registry-fetch": "*", "@types/npmlog": "*", "@types/ssri": "*" } }, "sha512-/XLR0VoTh2JEO0jJg1q/e6Rh9bxjBq9vorJuQmtT7rRrXSiWz7e7NsvXVYJQ0i8JxMlBMPPYDTnrRe7MZRFA8Q=="], "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], @@ -3202,8 +3207,6 @@ "@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="], - "@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="], - "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], @@ -4274,7 +4277,7 @@ "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="], - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "ipaddr.js": ["ipaddr.js@2.5.0", "", {}, "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w=="], "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], @@ -5026,6 +5029,8 @@ "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + "qr-scanner": ["qr-scanner@1.4.2", "", { "dependencies": { "@types/offscreencanvas": "^2019.6.4" } }, "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -5436,8 +5441,6 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="], - "svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], @@ -5652,8 +5655,6 @@ "uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="], - "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], - "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -5802,9 +5803,7 @@ "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], - "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], - - "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], @@ -5836,8 +5835,6 @@ "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], - "zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="], - "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], "zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="], @@ -6580,8 +6577,6 @@ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], @@ -6596,6 +6591,8 @@ "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], diff --git a/packages/app/e2e/regression/server-pairing.spec.ts b/packages/app/e2e/regression/server-pairing.spec.ts new file mode 100644 index 000000000000..098490416617 --- /dev/null +++ b/packages/app/e2e/regression/server-pairing.spec.ts @@ -0,0 +1,125 @@ +import { expect, test } from "@playwright/test" +import { renderSVG } from "uqr" +import { mockOpenCodeServer } from "../utils/mock-server" + +test.use({ serviceWorkers: "block" }) + +test.beforeEach(async ({ page }) => { + await mockOpenCodeServer(page, { + directory: "/pairing-demo", + project: { id: "proj_pairing_demo", canonical: "/pairing-demo", time: { created: 1, updated: 1 } }, + provider: { all: [], connected: [], default: {} }, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await page.goto("/") + await page.getByRole("button", { name: "Settings", exact: true }).click() + const settings = page.getByTestId("settings-screen") + await settings.getByRole("tab", { name: "Servers", exact: true }).click() + await settings.getByRole("button", { name: "Add server", exact: true }).click() + await expect(page.getByRole("dialog", { name: "Add server" }).getByLabel("Server address")).toBeEditable() +}) + +test("imports a pairing image and authenticates only after confirmation", async ({ page }) => { + const info = { + urls: ["http://192.168.1.20:49374", "http://100.100.10.20:49374"], + username: "opencode", + password: "pairing-test-password", + } + const requests: string[] = [] + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()) + if (!info.urls.includes(url.origin)) return route.fallback() + if (route.request().method() === "OPTIONS") + return route.fulfill({ + status: 204, + headers: { + "access-control-allow-origin": "*", + "access-control-allow-headers": "authorization", + "access-control-allow-methods": "GET", + }, + }) + requests.push(url.origin) + const authorized = route.request().headers().authorization === `Basic ${btoa(`${info.username}:${info.password}`)}` + return route.fulfill({ + status: authorized ? 200 : 401, + headers: { "access-control-allow-origin": "*" }, + json: url.pathname === "/api/health" ? { healthy: true, version: "2.0.0" } : {}, + }) + }) + const dialog = page.getByRole("dialog", { name: "Add server" }) + await expect(dialog.getByText(/Run opencode2 pair on the server/)).toBeVisible() + await dialog.getByLabel("Choose QR image").setInputFiles({ + name: "pairing.svg", + mimeType: "image/svg+xml", + buffer: Buffer.from(renderSVG(JSON.stringify(info), { border: 2 })), + }) + await expect(dialog.getByLabel("Server address")).toHaveValue(info.urls[1]) + await expect(dialog.getByLabel("Username (optional)")).toHaveValue(info.username) + await expect(dialog.getByLabel("Password (optional)")).toHaveValue(info.password) + await expect(dialog.getByRole("status")).toHaveText( + "Connection details filled in. Review the server address, then select Add server.", + ) + const addresses = dialog.getByRole("group", { name: "Addresses from the pairing code" }) + await addresses.getByRole("button").click() + await page.getByRole("option").filter({ hasText: info.urls[0] }).click() + await expect(dialog.getByLabel("Server address")).toHaveValue(info.urls[0]) + await expect(page.getByRole("listbox")).toBeHidden() + await addresses.getByRole("button").click() + await page.getByRole("option").filter({ hasText: info.urls[1] }).click() + await expect(dialog.getByLabel("Server address")).toHaveValue(info.urls[1]) + expect(requests).toEqual([]) + + const health = page.waitForResponse(`${info.urls[1]}/api/health`) + await dialog.getByRole("button", { name: "Add server", exact: true }).click() + expect((await health).status()).toBe(200) + await expect(dialog).toBeHidden() + await expect(page.getByTestId("settings-screen").getByText("100.100.10.20:49374", { exact: true })).toBeVisible() +}) + +test("rejects an unrelated QR image without changing the form", async ({ page }) => { + const dialog = page.getByRole("dialog", { name: "Add server" }) + await dialog.getByLabel("Choose QR image").setInputFiles({ + name: "unrelated.svg", + mimeType: "image/svg+xml", + buffer: Buffer.from(renderSVG("https://example.test", { border: 2 })), + }) + await expect(dialog.getByRole("alert")).toHaveText( + "This is not an OpenCode pairing code. Run opencode2 pair on the server to get one.", + ) + await expect(dialog.getByLabel("Server address")).toBeEmpty() + await expect(dialog.getByLabel("Password (optional)")).toBeEmpty() +}) + +test("keeps camera controls accessible on a short screen and releases the camera", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 600 }) + await page.evaluate(() => { + // Supply a real browser MediaStream without requiring camera hardware or OS permission dialogs. + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement("canvas") + canvas.width = 640 + canvas.height = 480 + const context = canvas.getContext("2d") + if (!context) throw new Error("Missing canvas context") + context.fillStyle = "white" + context.fillRect(0, 0, canvas.width, canvas.height) + return canvas.captureStream(10) + } + }) + const dialog = page.getByRole("dialog", { name: "Add server" }) + await dialog.getByRole("button", { name: "Scan QR code" }).click() + const video = dialog.getByLabel("QR code camera preview") + await expect + .poll(() => video.evaluate((element) => element instanceof HTMLVideoElement && element.videoWidth > 0)) + .toBe(true) + const track = await video.evaluateHandle((element) => { + if (!(element instanceof HTMLVideoElement) || !(element.srcObject instanceof MediaStream)) + throw new Error("Expected a camera stream") + return element.srcObject.getVideoTracks()[0] + }) + await expect(dialog.getByRole("heading", { name: "Add server" })).toBeInViewport() + await expect(dialog.getByRole("button", { name: "Add server", exact: true })).toBeInViewport() + await dialog.getByRole("button", { name: "Stop camera" }).click() + await expect(video).toHaveCount(0) + await expect.poll(() => track.evaluate((value) => value.readyState)).toBe("ended") +}) diff --git a/packages/app/package.json b/packages/app/package.json index c471fe26d5c0..e902c613fbff 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -51,6 +51,7 @@ "diff": "catalog:", "happy-dom": "20.11.1", "tw-animate-css": "1.4.0", + "uqr": "0.1.3", "vite": "8.2.2", "vite-plugin-pwa": "1.3.0", "vite-plugin-solid": "2.11.14" @@ -84,7 +85,9 @@ "effect": "catalog:", "fuzzysort": "catalog:", "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", + "ipaddr.js": "2.5.0", "luxon": "catalog:", + "qr-scanner": "1.4.2", "remeda": "catalog:", "solid-js": "catalog:", "solid-presence": "0.2.0", diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 9c116e603e8c..8e8f116f8ee0 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -362,6 +362,24 @@ export const dict = { "dialog.server.add.password": "Password (optional)", "dialog.server.add.passwordPlaceholder": "password", "dialog.server.edit.title": "Edit server", + "dialog.server.pair.description": + "Run {{command}} on the server, then scan its QR code or choose an image to fill in the connection details.", + "dialog.server.pair.scan": "Scan QR code", + "dialog.server.pair.stop": "Stop camera", + "dialog.server.pair.image": "Choose QR image", + "dialog.server.pair.reading": "Reading image...", + "dialog.server.pair.camera": "QR code camera preview", + "dialog.server.pair.cameraError": + "Camera unavailable. Check camera permissions and use HTTPS or localhost, or choose a QR image.", + "dialog.server.pair.imageError": + "Could not read a QR code from this image. Try another image or enter the details manually.", + "dialog.server.pair.invalid": "This is not an OpenCode pairing code. Run opencode2 pair on the server to get one.", + "dialog.server.pair.addresses": "Addresses from the pairing code", + "dialog.server.pair.review": "Connection details filled in. Review the server address, then select Add server.", + "dialog.server.pair.loopback": + "This address only works on the server's device. To connect from another device, configure the server to listen on a LAN or tailnet address.", + "dialog.server.pair.http": + "Your browser may block this HTTP address from an HTTPS page. Use a trusted HTTPS server address if the connection fails.", "dialog.server.default.title": "Default server", "dialog.server.default.description": "Connect to this server on app launch instead of starting a local server. Requires restart.", diff --git a/packages/app/src/servers/connect/dialog.tsx b/packages/app/src/servers/connect/dialog.tsx index 0409345ad3eb..5a4a359ec401 100644 --- a/packages/app/src/servers/connect/dialog.tsx +++ b/packages/app/src/servers/connect/dialog.tsx @@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button" import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog" import { Divider } from "@opencode-ai/ui/divider" import { TextInput } from "@opencode-ai/ui/text-input" +import { Select } from "@opencode-ai/ui/select" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useMutation } from "@tanstack/solid-query" import { type Component, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" @@ -16,6 +17,9 @@ import { useLanguage } from "@/runtime/i18n/language" import { normalizeServerUrl, ServerConnection, useServers } from "@/runtime/server/registry" import { useTabs } from "@/shell/tabs/tabs" import { useCheckServerHealth } from "@/runtime/server/health" +import { usePlatform } from "@/runtime/platform/platform" +import { PairServer } from "./scan" +import { pairingAddressScope, type PairingInfo } from "./pairing" import "@/settings/settings.css" const DEFAULT_USERNAME = "opencode" @@ -28,6 +32,7 @@ export const DialogServer: Component<{ }> = (props) => { const dialog = useDialog() const language = useLanguage() + const platform = usePlatform() const form = createFormController({ onSelect: () => dialog.close(), }) @@ -64,18 +69,31 @@ export const DialogServer: Component<{ return language.t("common.save") } + const pairingWarning = () => { + if (!form.state.urls().length || !URL.canParse(form.state.value())) return + const url = new URL(form.state.value()) + if (pairingAddressScope(url) === "loopback") return language.t("dialog.server.pair.loopback") + if (platform.platform === "web" && location.protocol === "https:" && url.protocol === "http:") + return language.t("dialog.server.pair.http") + } + return ( - + {title()}
+ + +
form.change.value(event.currentTarget.value)} onKeyDown={keyDown} /> + 1}> + + + +

+ {language.t("dialog.server.pair.review")} +

+
+ + {(warning) => ( +

+ {warning()} +

+ )} +
{form.state.error()} @@ -112,6 +158,7 @@ export const DialogServer: Component<{ class="!w-full self-stretch" value={form.state.username()} placeholder={language.t("dialog.server.add.usernamePlaceholder")} + aria-label={language.t("dialog.server.add.username")} disabled={form.state.busy()} onInput={(event) => form.change.username(event.currentTarget.value)} onKeyDown={keyDown} @@ -125,6 +172,7 @@ export const DialogServer: Component<{ class="!w-full self-stretch" value={form.state.password()} placeholder={language.t("dialog.server.add.passwordPlaceholder")} + aria-label={language.t("dialog.server.add.password")} disabled={form.state.busy()} onInput={(event) => form.change.password(event.currentTarget.value)} onKeyDown={keyDown} @@ -156,6 +204,7 @@ function createFormController(options: { onSelect?: () => void } = {}) { mode: "list" as FormMode, originalUrl: undefined as string | undefined, values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" }, + urls: [] as string[], error: "", status: undefined as boolean | undefined, }) @@ -168,6 +217,7 @@ function createFormController(options: { onSelect?: () => void } = {}) { mode: "list", originalUrl: undefined, values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" }, + urls: [], error: "", status: undefined, }) @@ -237,7 +287,11 @@ function createFormController(options: { onSelect?: () => void } = {}) { }, })) - const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status)) + const preview = () => { + // Scanned credentials are sent only after the user confirms the server address. + if (store.urls.length) return + void healthPreview.preview(store.values, (status) => setStore("status", status)) + } const change = (field: keyof ServerFormValues, value: string) => { if (request.isPending) return setStore("values", field, value) @@ -282,6 +336,7 @@ function createFormController(options: { onSelect?: () => void } = {}) { adding: () => store.mode === "add", busy: () => request.isPending, value: () => store.values.url, + urls: () => store.urls, name: () => store.values.name, username: () => store.values.username, password: () => store.values.password, @@ -295,6 +350,15 @@ function createFormController(options: { onSelect?: () => void } = {}) { password: (value: string) => change("password", value), }, start: { add: startAdd, edit: startEdit }, + pair(info: PairingInfo) { + healthPreview.cancel() + setStore({ + values: { ...store.values, url: info.urls[0], username: info.username, password: info.password }, + urls: [...info.urls], + error: "", + status: undefined, + }) + }, reset, submit, } diff --git a/packages/app/src/servers/connect/pairing.test.ts b/packages/app/src/servers/connect/pairing.test.ts new file mode 100644 index 000000000000..94847a42fbff --- /dev/null +++ b/packages/app/src/servers/connect/pairing.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test" +import { readPairing } from "./pairing" + +test("prefers public URLs and tailnet addresses without changing credentials", () => { + const info = { + urls: [ + "https://localhost:49374", + "http://192.168.1.20:49374", + "http://100.100.10.20:49374", + "http://server.example.test", + "https://server.example.test", + "https://laptop.tailnet.ts.net", + ], + username: "opencode", + password: "password:with spaces", + } + expect(readPairing(JSON.stringify(info))).toEqual({ + ...info, + urls: [ + "https://server.example.test", + "http://server.example.test", + "https://laptop.tailnet.ts.net", + "http://100.100.10.20:49374", + "http://192.168.1.20:49374", + "https://localhost:49374", + ], + }) +}) + +test("ranks IPv6 scopes and puts bind-only addresses last", () => { + const urls = [ + "http://[::]:49374", + "http://[::1]:49374", + "http://[fe80::1]:49374", + "http://[fd00::1]:49374", + "http://[fd7a:115c:a1e0::1]:49374", + "http://[2606:4700:4700::1111]:49374", + ] + expect(readPairing(JSON.stringify({ urls, username: "opencode", password: "secret" }))?.urls).toEqual( + urls.toReversed(), + ) +}) + +test("keeps equivalent addresses in their original order", () => { + const urls = ["http://172.16.0.2:49374", "http://10.0.0.2:49374", "http://192.168.1.2:49374"] + expect(readPairing(JSON.stringify({ urls, username: "opencode", password: "secret" }))?.urls).toEqual(urls) +}) + +test("classifies IPv4-mapped IPv6 addresses with their IPv4 scope", () => { + const urls = ["http://[::ffff:127.0.0.1]:49374", "http://[::ffff:192.168.1.20]:49374", "http://100.64.0.1:49374"] + expect(readPairing(JSON.stringify({ urls, username: "opencode", password: "secret" }))?.urls).toEqual( + urls.toReversed(), + ) +}) + +test("rejects unrelated QR contents and invalid pairing details", () => { + expect(readPairing("not json")).toBeUndefined() + const info = { urls: ["http://127.0.0.1:49374"], username: "opencode", password: "secret" } + const invalid = [ + null, + {}, + { ...info, urls: [] }, + { ...info, urls: ["not a URL"] }, + { ...info, urls: ["file:///etc/passwd"] }, + { ...info, urls: ["https://user:password@example.test"] }, + { ...info, username: 42 }, + { ...info, password: "" }, + ] + invalid.forEach((value) => expect(readPairing(JSON.stringify(value))).toBeUndefined()) +}) diff --git a/packages/app/src/servers/connect/pairing.ts b/packages/app/src/servers/connect/pairing.ts new file mode 100644 index 000000000000..5e9c1dce87d9 --- /dev/null +++ b/packages/app/src/servers/connect/pairing.ts @@ -0,0 +1,66 @@ +import { Option, Schema } from "effect" +import ipaddr from "ipaddr.js" + +const PairingInfo = Schema.Struct({ + urls: Schema.Array(Schema.String).check(Schema.isMinLength(1)), + username: Schema.String, + password: Schema.String.check(Schema.isMinLength(1)), +}) +export type PairingInfo = typeof PairingInfo.Type + +const decode = Schema.decodeUnknownOption(Schema.fromJsonString(PairingInfo)) +const tailnet = ipaddr.IPv6.parseCIDR("fd7a:115c:a1e0::/48") +const order = { public: 0, tailnet: 1, private: 2, linkLocal: 3, loopback: 4, unusable: 5 } + +export function readPairing(value: string) { + const result = decode(value) + if (Option.isNone(result)) return + if ( + !result.value.urls.every((value) => { + if (!URL.canParse(value)) return false + const url = new URL(value) + return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password + }) + ) + return + return { + ...result.value, + urls: result.value.urls.toSorted((a, b) => { + const left = new URL(a) + const right = new URL(b) + return ( + order[pairingAddressScope(left)] - order[pairingAddressScope(right)] || + Number(left.protocol !== "https:") - Number(right.protocol !== "https:") + ) + }), + } +} + +export function pairingAddressScope(url: URL) { + const hostname = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname + if (!ipaddr.isValid(hostname)) { + const labels = hostname.split(".").filter(Boolean) + if (labels.at(-1) === "localhost") return "loopback" + if (labels.slice(-2).join(".") === "ts.net") return "tailnet" + if (labels.length === 1 || ["local", "lan", "internal"].includes(labels.at(-1) ?? "")) return "private" + return "public" + } + + const address = ipaddr.process(hostname) + if (address instanceof ipaddr.IPv6 && address.match(tailnet)) return "tailnet" + switch (address.range()) { + case "unicast": + return "public" + case "carrierGradeNat": + return "tailnet" + case "private": + case "uniqueLocal": + return "private" + case "linkLocal": + return "linkLocal" + case "loopback": + return "loopback" + default: + return "unusable" + } +} diff --git a/packages/app/src/servers/connect/scan.tsx b/packages/app/src/servers/connect/scan.tsx new file mode 100644 index 000000000000..5a9eafb179f3 --- /dev/null +++ b/packages/app/src/servers/connect/scan.tsx @@ -0,0 +1,120 @@ +import { Button } from "@opencode-ai/ui/button" +import { Show, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import type QrScanner from "qr-scanner" +import { useLanguage } from "@/runtime/i18n/language" +import { usePlatform } from "@/runtime/platform/platform" +import { readPairing, type PairingInfo } from "./pairing" + +export function PairServer(props: { disabled: boolean; onPair: (info: PairingInfo) => void }) { + const language = useLanguage() + const platform = usePlatform() + const [state, setState] = createStore({ camera: false, reading: false, error: "" }) + let input: HTMLInputElement | undefined + let active = true + onCleanup(() => { + active = false + }) + + const read = (value: string) => { + if (!active || props.disabled) return + const info = readPairing(value) + if (!info) { + setState("error", language.t("dialog.server.pair.invalid")) + return + } + setState({ camera: false, error: "" }) + props.onPair(info) + } + const image = async (file: File) => { + setState({ camera: false, reading: true, error: "" }) + const { default: QrScanner } = await import("qr-scanner") + if (!active) return + const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true }) + read(result.data) + } + + return ( +
+

+ {language.t("dialog.server.pair.description", { command: "opencode2 pair" })} +

+
+ + + + + { + const file = event.currentTarget.files?.[0] + event.currentTarget.value = "" + if (!file) return + void image(file) + .catch(() => { + if (active) setState("error", language.t("dialog.server.pair.imageError")) + }) + .finally(() => { + if (active) setState("reading", false) + }) + }} + /> +
+ + setState({ camera: false, error: language.t("dialog.server.pair.cameraError") })} + /> + + + + +
+ ) +} + +function Camera(props: { onRead: (value: string) => void; onError: () => void }) { + const language = useLanguage() + let video: HTMLVideoElement | undefined + let scanner: QrScanner | undefined + let active = true + const start = async () => { + const { default: QrScanner } = await import("qr-scanner") + if (!active || !video) return + scanner = new QrScanner( + video, + (result) => { + if (active) props.onRead(result.data) + }, + { returnDetailedScanResult: true, preferredCamera: "environment", maxScansPerSecond: 8 }, + ) + scanner.setInversionMode("both") + await scanner.start() + } + onMount(() => { + void start().catch(() => { + if (active) props.onError() + }) + }) + onCleanup(() => { + active = false + void scanner?.pause(true) + scanner?.destroy() + }) + return