diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index bfcaf4013ab1..95984790dbaa 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -16,7 +16,12 @@ import { AppText as Text } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { ConnectionFormField } from "./ConnectionFormField"; import { ConnectionSheetButton } from "./ConnectionSheetButton"; -import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; +import { + buildPairingUrl, + extractPairingUrlFromQrPayload, + parsePairingFields, + parsePairingUrl, +} from "./pairing"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; type ConnectionsNewRouteParams = { @@ -83,6 +88,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); }, []); @@ -174,8 +186,9 @@ export function ConnectionsNewRouteScreen({ ); const handleSubmit = useCallback(async () => { - await connectAndClose(buildPairingUrl(hostInput, codeInput), false); - }, [codeInput, connectAndClose, hostInput]); + const fields = normalizePairingFields(); + await connectAndClose(buildPairingUrl(fields.host, fields.code), false); + }, [connectAndClose, normalizePairingFields]); useEffect(() => { if (!shouldAutoConnect || attemptedAutoConnectRef.current === routePairingUrl) { @@ -251,6 +264,7 @@ export function ConnectionsNewRouteScreen({ placeholder="192.168.1.100:8080" value={hostInput} onChangeText={handleHostChange} + onBlur={normalizePairingFields} /> { "https://192.168.1.100:3773/#token=pairing-token", ); }); + + it("treats a protocol-relative hostname as HTTPS", () => { + expect(buildPairingUrl("//remote.example.com", "pairing-token")).toBe( + "https://remote.example.com/#token=pairing-token", + ); + }); +}); + +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", () => { @@ -51,6 +76,27 @@ 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 a protocol-relative pairing link into backend host fields", () => { + expect(parsePairingUrl("//remote.example.com/pair#token=pairing-token")).toEqual({ + host: "https://remote.example.com", + code: "pairing-token", + }); + }); + it("reads hosted pairing links into backend host fields", () => { expect( parsePairingUrl( @@ -61,4 +107,15 @@ describe("parsePairingUrl", () => { code: "pairing-token", }); }); + + it("preserves protocol-relative hosts from hosted pairing links", () => { + expect( + parsePairingUrl( + "https://app.t3.codes/pair?host=%2F%2Fdesktop.tailnet.ts.net%2F#token=pairing-token", + ), + ).toEqual({ + host: "//desktop.tailnet.ts.net", + code: "pairing-token", + }); + }); }); diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index ee9e4b9ec8f3..38349c404a9b 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -18,6 +18,14 @@ function isIpLiteral(host: string): boolean { } } +function normalizePairingUrlInput(value: string): string { + if (value.startsWith("//")) return `https:${value}`; + if (value.includes("://")) return value; + + const authority = value.split(/[/?#]/, 1)[0] ?? value; + return `${isIpLiteral(authority) ? "http" : "https"}://${value}`; +} + export class PairingQrPayloadEmptyError extends Schema.TaggedError()( "PairingQrPayloadEmptyError", {}, @@ -34,7 +42,7 @@ 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(normalizePairingUrlInput(h)); url.hash = new URLSearchParams([["token", c]]).toString(); return url.toString(); } catch { @@ -47,7 +55,7 @@ export function parsePairingUrl(url: string): { host: string; code: string } { if (!trimmed) return { host: "", code: "" }; try { - const parsed = new URL(trimmed); + const parsed = new URL(normalizePairingUrlInput(trimmed)); const hostedPairingRequest = readHostedPairingRequest(parsed); if (hostedPairingRequest) { return { @@ -70,6 +78,11 @@ 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) {