authorgravatar for lucenz@proton.meSimone <lucenz@proton.me> 2026-07-31 00:17:38+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 22:17:38+00:00
log1d694dcbd8d27c994b3bdc66c4bdb199faf2efc3
treeb5610d5af95cb724327d4f563ca697fcbcc2dcb6
parent32407df9c5ffd3c5dda2e745568997ac33c15ed1
signature Signed by PGP key B5690EEEBB952194

fix(mobile): default bare IP pairing to HTTP (#4990)


4 files changed, 40 insertions(+), 2 deletions(-)

.agents/skills/test-t3-mobile/SKILL.md+1-1
...@@ -66,7 +66,7 @@ Use these client origins:...@@ -66,7 +66,7 @@ Use these client origins:
66- Android Emulator: `http://10.0.2.2:<server-port>`66- Android Emulator: `http://10.0.2.2:<server-port>`
67- Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin67- Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin
6868
69Always enter the complete `http://` origin; the mobile host field otherwise assumes HTTPS. When testing web and mobile together, run `vp run dev --home-dir <base-dir> --host 127.0.0.1` instead and do not launch a second backend over the same base directory.69Enter the complete `http://` origin to make the test transport explicit. Bare IP addresses default to HTTP, while bare hostnames default to HTTPS. When testing web and mobile together, run `vp run dev --home-dir <base-dir> --host 127.0.0.1` instead and do not launch a second backend over the same base directory.
7070
71## Start or reuse Metro safely71## Start or reuse Metro safely
7272
apps/mobile/src/features/connection/pairing.test.ts+21
...@@ -1,11 +1,32 @@...@@ -1,11 +1,32 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 buildPairingUrl,
4 extractPairingUrlFromQrPayload,5 extractPairingUrlFromQrPayload,
5 PairingQrPayloadEmptyError,6 PairingQrPayloadEmptyError,
6 parsePairingUrl,7 parsePairingUrl,
7} from "./pairing";8} from "./pairing";
89
10describe("buildPairingUrl", () => {
11 it("uses HTTP for a schemeless IP address", () => {
12 expect(buildPairingUrl("192.168.1.100:3773", "pairing-token")).toBe(
13 "http://192.168.1.100:3773/#token=pairing-token",
14 );
15 });
16
17 it("keeps HTTPS as the default for a schemeless hostname", () => {
18 expect(buildPairingUrl("remote.example.com", "pairing-token")).toBe(
19 "https://remote.example.com/#token=pairing-token",
20 );
21 });
22
23 it("preserves an explicit scheme for an IP address", () => {
24 expect(buildPairingUrl("https://192.168.1.100:3773", "pairing-token")).toBe(
25 "https://192.168.1.100:3773/#token=pairing-token",
26 );
27 });
28});
29
9describe("extractPairingUrlFromQrPayload", () => {30describe("extractPairingUrlFromQrPayload", () => {
10 it("trims raw pairing urls from qr payloads", () => {31 it("trims raw pairing urls from qr payloads", () => {
11 expect(32 expect(
apps/mobile/src/features/connection/pairing.ts+16-1
...@@ -3,6 +3,21 @@ import * as Schema from "effect/Schema";...@@ -3,6 +3,21 @@ import * as Schema from "effect/Schema";
33
4const MOBILE_PAIRING_URL_PARAM = "pairingUrl";4const MOBILE_PAIRING_URL_PARAM = "pairingUrl";
55
6function isIpLiteral(host: string): boolean {
7 try {
8 const hostname = new URL(`http://${host}`).hostname.replace(/^\[|\]$/g, "");
9 if (hostname.includes(":")) return true;
10
11 const octets = hostname.split(".");
12 return (
13 octets.length === 4 &&
14 octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255)
15 );
16 } catch {
17 return false;
18 }
19}
20
6export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass<PairingQrPayloadEmptyError>()(21export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass<PairingQrPayloadEmptyError>()(
7 "PairingQrPayloadEmptyError",22 "PairingQrPayloadEmptyError",
8 {},23 {},
...@@ -19,7 +34,7 @@ export function buildPairingUrl(host: string, code: string): string {...@@ -19,7 +34,7 @@ export function buildPairingUrl(host: string, code: string): string {
19 if (!c) return h;34 if (!c) return h;
2035
21 try {36 try {
22 const url = new URL(h.includes("://") ? h : `https://${h}`);37 const url = new URL(h.includes("://") ? h : `${isIpLiteral(h) ? "http" : "https"}://${h}`);
23 url.hash = new URLSearchParams([["token", c]]).toString();38 url.hash = new URLSearchParams([["token", c]]).toString();
24 return url.toString();39 return url.toString();
25 } catch {40 } catch {
docs/user/remote-access.md+2
...@@ -38,6 +38,8 @@ available. You can set another endpoint as the default from the expanded endpoin...@@ -38,6 +38,8 @@ available. You can set another endpoint as the default from the expanded endpoin
3838
39If the copied link points directly at `http://192.168.x.y:3773`, open it from a client that can reach that LAN address. If it points at `https://app.t3.codes/pair?...`, the hosted web app will save the environment and connect directly to the backend URL in the link.39If the copied link points directly at `http://192.168.x.y:3773`, open it from a client that can reach that LAN address. If it points at `https://app.t3.codes/pair?...`, the hosted web app will save the environment and connect directly to the backend URL in the link.
4040
41In the mobile app's **Add Environment** form, a numeric IP address without a scheme uses HTTP. Include `https://` explicitly when the backend is served over HTTPS.
42
41### Tailscale Endpoints43### Tailscale Endpoints
4244
43When the desktop app can detect Tailscale, it adds Tailnet endpoints to the reachable endpoint list.45When the desktop app can detect Tailscale, it adds Tailnet endpoints to the reachable endpoint list.