Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}, []);
Expand Down Expand Up @@ -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)) {
Expand All @@ -131,7 +139,7 @@ export function ConnectionsNewRouteScreen({
} else {
setIsSubmitting(false);
}
}, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]);
}, [normalizePairingFields, onChangeConnectionPairingUrl, onConnectPress, navigation]);

return (
<View collapsable={false} className="flex-1 bg-sheet">
Expand Down Expand Up @@ -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"
/>
</View>
Expand Down
46 changes: 44 additions & 2 deletions apps/mobile/src/features/connection/pairing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildPairingUrl,
extractPairingUrlFromQrPayload,
PairingQrPayloadEmptyError,
parsePairingFields,
parsePairingUrl,
} from "./pairing";

Expand All @@ -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");
});

Expand All @@ -43,14 +67,32 @@ 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.",
);
});
});

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(
Expand Down
22 changes: 19 additions & 3 deletions apps/mobile/src/features/connection/pairing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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) {
Expand All @@ -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;
}
Expand Down
Loading