From d96ef7afcda08a09c60f7bedf72540aed7199315 Mon Sep 17 00:00:00 2001 From: Dominic Vonk Date: Fri, 7 Aug 2026 19:16:55 +0200 Subject: [PATCH] fix(mobile): parse pasted pairing links --- .../connection/ConnectionsNewRouteScreen.tsx | 15 ++++-- .../src/features/connection/pairing.test.ts | 46 ++++++++++++++++++- .../mobile/src/features/connection/pairing.ts | 22 +++++++-- 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index de3799ac8a8e..ef061d9806bf 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -13,7 +13,7 @@ import { ErrorBanner } from "../../components/ErrorBanner"; import { ConnectionSheetButton } from "./ConnectionSheetButton"; import { extractPairingUrlFromQrPayload } from "./pairing"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; -import { buildPairingUrl, parsePairingUrl } from "./pairing"; +import { buildPairingUrl, parsePairingFields, parsePairingUrl } from "./pairing"; type ConnectionsNewRouteParams = { readonly mode?: string; @@ -58,6 +58,13 @@ export function ConnectionsNewRouteScreen({ setHostInput(value); }, []); + const normalizePairingFields = useCallback(() => { + const parsed = parsePairingFields(hostInput, codeInput); + setHostInput(parsed.host); + setCodeInput(parsed.code); + return parsed; + }, [codeInput, hostInput]); + const handleCodeChange = useCallback((value: string) => { setCodeInput(value); }, []); @@ -119,7 +126,8 @@ export function ConnectionsNewRouteScreen({ const handleSubmit = useCallback(async () => { setIsSubmitting(true); - const pairingUrl = buildPairingUrl(hostInput, codeInput); + const fields = normalizePairingFields(); + const pairingUrl = buildPairingUrl(fields.host, fields.code); onChangeConnectionPairingUrl(pairingUrl); const result = await onConnectPress(pairingUrl); if (AsyncResult.isSuccess(result)) { @@ -131,7 +139,7 @@ export function ConnectionsNewRouteScreen({ } else { setIsSubmitting(false); } - }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]); + }, [normalizePairingFields, onChangeConnectionPairingUrl, onConnectPress, navigation]); return ( @@ -226,6 +234,7 @@ export function ConnectionsNewRouteScreen({ placeholder="192.168.1.100:8080" value={hostInput} onChangeText={handleHostChange} + onBlur={normalizePairingFields} className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground" /> diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 193927684794..e74627934f6d 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -4,6 +4,7 @@ import { buildPairingUrl, extractPairingUrlFromQrPayload, PairingQrPayloadEmptyError, + parsePairingFields, parsePairingUrl, } from "./pairing"; @@ -27,10 +28,33 @@ describe("buildPairingUrl", () => { }); }); +describe("parsePairingFields", () => { + it("extracts an embedded pairing token when the host field is committed", () => { + expect( + parsePairingFields( + "http://remote.example.com/pair#token=embedded-token", + "old-code", + ), + ).toEqual({ + host: "http://remote.example.com", + code: "embedded-token", + }); + }); + + it("preserves separately entered host and code values", () => { + expect(parsePairingFields("remote.example.com", "manual-code")).toEqual({ + host: "remote.example.com", + code: "manual-code", + }); + }); +}); + describe("extractPairingUrlFromQrPayload", () => { it("trims raw pairing urls from qr payloads", () => { expect( - extractPairingUrlFromQrPayload(" https://remote.example.com/pair#token=pairing-token "), + extractPairingUrlFromQrPayload( + " https://remote.example.com/pair#token=pairing-token ", + ), ).toBe("https://remote.example.com/pair#token=pairing-token"); }); @@ -43,7 +67,9 @@ describe("extractPairingUrlFromQrPayload", () => { }); it("rejects empty qr payloads", () => { - expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError(PairingQrPayloadEmptyError); + expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError( + PairingQrPayloadEmptyError, + ); expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError( "Scanned QR code did not contain a pairing URL.", ); @@ -51,6 +77,22 @@ describe("extractPairingUrlFromQrPayload", () => { }); describe("parsePairingUrl", () => { + it("reads a direct pairing link into backend host fields", () => { + expect( + parsePairingUrl("http://remote.example.com/pair#token=pairing-token"), + ).toEqual({ + host: "http://remote.example.com", + code: "pairing-token", + }); + }); + + it("reads a schemeless local pairing link into backend host fields", () => { + expect(parsePairingUrl("192.168.1.100:3773/#token=pairing-token")).toEqual({ + host: "http://192.168.1.100:3773", + code: "pairing-token", + }); + }); + it("reads hosted pairing links into backend host fields", () => { expect( parsePairingUrl( diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index 569d00cbdd36..35ec4fbfe2aa 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -34,7 +34,9 @@ export function buildPairingUrl(host: string, code: string): string { if (!c) return h; try { - const url = new URL(h.includes("://") ? h : `${isIpLiteral(h) ? "http" : "https"}://${h}`); + const url = new URL( + h.includes("://") ? h : `${isIpLiteral(h) ? "http" : "https"}://${h}`, + ); url.hash = new URLSearchParams([["token", c]]).toString(); return url.toString(); } catch { @@ -47,7 +49,12 @@ export function parsePairingUrl(url: string): { host: string; code: string } { if (!trimmed) return { host: "", code: "" }; try { - const parsed = new URL(trimmed); + const authority = trimmed.split(/[/?#]/, 1)[0] ?? trimmed; + const parsed = new URL( + trimmed.includes("://") + ? trimmed + : `${isIpLiteral(authority) ? "http" : "https"}://${trimmed}`, + ); const hostedPairingRequest = readHostedPairingRequest(parsed); if (hostedPairingRequest) { return { @@ -70,6 +77,14 @@ export function parsePairingUrl(url: string): { host: string; code: string } { } } +export function parsePairingFields( + host: string, + code: string, +): { host: string; code: string } { + const parsed = parsePairingUrl(host); + return parsed.code.length > 0 ? parsed : { host, code }; +} + export function extractPairingUrlFromQrPayload(payload: string): string { const trimmed = payload.trim(); if (!trimmed) { @@ -79,7 +94,8 @@ export function extractPairingUrlFromQrPayload(payload: string): string { try { const url = new URL(trimmed); if (url.protocol === "t3code:") { - const pairingUrl = url.searchParams.get(MOBILE_PAIRING_URL_PARAM)?.trim() ?? ""; + const pairingUrl = + url.searchParams.get(MOBILE_PAIRING_URL_PARAM)?.trim() ?? ""; if (pairingUrl.length > 0) { return pairingUrl; }