diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index f3e3dcfd8ce5..98c1c3b20224 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -66,7 +66,7 @@ Use these client origins: - Android Emulator: `http://10.0.2.2:` - Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin -Always enter the complete `http://` origin; the mobile host field otherwise assumes HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. +Enter 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 --host 127.0.0.1` instead and do not launch a second backend over the same base directory. ## Start or reuse Metro safely diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index d29f922bb339..2e8568f170d2 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -33,6 +33,7 @@ const clientSettings: ClientSettings = { sidebarThreadPreviewCount: 6, sidebarV2Enabled: false, sidebarV2ConfiguredByUser: false, + sidebarV2GroupByProject: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index de3799ac8a8e..ee5f8303db84 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -3,7 +3,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useState } from "react"; -import { Alert, Platform, ScrollView, View } from "react-native"; +import { Alert, Linking, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -76,9 +76,21 @@ export function ConnectionsNewRouteScreen({ return; } + if (permission.canAskAgain) { + Alert.alert( + "Camera access needed", + "Allow camera access to scan an environment pairing QR code.", + ); + return; + } + Alert.alert( "Camera access needed", - "Allow camera access to scan an environment pairing QR code.", + "Camera access was denied for this app. Open Settings to enable it.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], ); }, [cameraPermission?.granted, requestCameraPermission]); diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts index 75f78738ade5..6d07f40a52dd 100644 --- a/apps/mobile/src/features/connection/environmentSections.test.ts +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -2,7 +2,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { describe, expect, it } from "vite-plus/test"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; -import { splitEnvironmentSections } from "./environmentSections"; +import { relayManagedEnvironmentIds, splitEnvironmentSections } from "./environmentSections"; function connectedEnvironment( input: Omit, "environmentId"> & { @@ -34,6 +34,17 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { }; } +describe("relayManagedEnvironmentIds", () => { + it("leaves out a backend that was saved directly", () => { + const ids = relayManagedEnvironmentIds([ + connectedEnvironment({ environmentId: "environment-local", isRelayManaged: false }), + connectedEnvironment({ environmentId: "environment-cloud", isRelayManaged: true }), + ]); + + expect([...ids]).toEqual([EnvironmentId.make("environment-cloud")]); + }); +}); + describe("mobile environment settings sections", () => { it("keeps saved relay-managed connections under T3 Connect", () => { const local = connectedEnvironment({ @@ -111,6 +122,24 @@ describe("mobile environment settings sections", () => { expect(sections.availableCloudEnvironments).toEqual([]); }); + it("still offers a cloud environment saved directly as a local backend", () => { + const local = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: false, + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [local], + cloudEnvironments: [cloudEnvironment("environment-cloud")], + }); + + expect(sections.localEnvironments).toEqual([local]); + expect(sections.connectedCloudEnvironments).toEqual([]); + expect( + sections.availableCloudEnvironments.map((environment) => environment.environmentId), + ).toEqual([EnvironmentId.make("environment-cloud")]); + }); + it("keeps failed relay environments in the local connection row", () => { const cloud = connectedEnvironment({ environmentId: "environment-cloud", diff --git a/apps/mobile/src/features/connection/environmentSections.ts b/apps/mobile/src/features/connection/environmentSections.ts index fc6db479c2ff..10ba636dc576 100644 --- a/apps/mobile/src/features/connection/environmentSections.ts +++ b/apps/mobile/src/features/connection/environmentSections.ts @@ -1,3 +1,4 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; @@ -12,10 +13,25 @@ export interface EnvironmentSections { readonly availableCloudEnvironments: ReadonlyArray; } -export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { - const savedEnvironmentIds = new Set( - input.connectedEnvironments.map((environment) => environment.environmentId), +/** + * Ids of the environments that already occupy a T3 Connect slot. A backend saved directly is + * not one of them, so it must not suppress the cloud environment that happens to share its id. + */ +export function relayManagedEnvironmentIds( + environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly isRelayManaged: boolean; + }>, +): ReadonlySet { + return new Set( + environments + .filter((environment) => environment.isRelayManaged) + .map((environment) => environment.environmentId), ); +} + +export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { + const savedEnvironmentIds = relayManagedEnvironmentIds(input.connectedEnvironments); return { localEnvironments: input.connectedEnvironments.filter( diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 18b6c71a293a..193927684794 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -1,11 +1,32 @@ import { describe, expect, it } from "vite-plus/test"; import { + buildPairingUrl, extractPairingUrlFromQrPayload, PairingQrPayloadEmptyError, parsePairingUrl, } from "./pairing"; +describe("buildPairingUrl", () => { + it("uses HTTP for a schemeless IP address", () => { + expect(buildPairingUrl("192.168.1.100:3773", "pairing-token")).toBe( + "http://192.168.1.100:3773/#token=pairing-token", + ); + }); + + it("keeps HTTPS as the default for a schemeless hostname", () => { + expect(buildPairingUrl("remote.example.com", "pairing-token")).toBe( + "https://remote.example.com/#token=pairing-token", + ); + }); + + it("preserves an explicit scheme for an IP address", () => { + expect(buildPairingUrl("https://192.168.1.100:3773", "pairing-token")).toBe( + "https://192.168.1.100:3773/#token=pairing-token", + ); + }); +}); + describe("extractPairingUrlFromQrPayload", () => { it("trims raw pairing urls from qr payloads", () => { expect( diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index 910efa7f2565..569d00cbdd36 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -3,6 +3,21 @@ import * as Schema from "effect/Schema"; const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; +function isIpLiteral(host: string): boolean { + try { + const hostname = new URL(`http://${host}`).hostname.replace(/^\[|\]$/g, ""); + if (hostname.includes(":")) return true; + + const octets = hostname.split("."); + return ( + octets.length === 4 && + octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) + ); + } catch { + return false; + } +} + export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass()( "PairingQrPayloadEmptyError", {}, @@ -19,7 +34,7 @@ export function buildPairingUrl(host: string, code: string): string { if (!c) return h; try { - const url = new URL(h.includes("://") ? h : `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 { diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts index bad6b6f17209..faa34477569d 100644 --- a/apps/mobile/src/features/connection/useConnectionController.ts +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -20,6 +20,7 @@ import { useEnvironments } from "../../state/environments"; import { relayEnvironmentDiscovery } from "../../state/relay"; import { useAtomCommand } from "../../state/use-atom-command"; import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel"; +import { relayManagedEnvironmentIds } from "./environmentSections"; export interface RelayEnvironmentView { readonly environment: RelayClientEnvironmentRecord; @@ -49,7 +50,7 @@ export function useConnectionController() { [environments], ); const registeredIds = useMemo( - () => new Set(connectedEnvironments.map((environment) => environment.environmentId)), + () => relayManagedEnvironmentIds(connectedEnvironments), [connectedEnvironments], ); const relayEnvironments = useMemo>( diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index d6ff00bee1f9..5ad33bc674ea 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -37,6 +37,8 @@ available. You can set another endpoint as the default from the expanded endpoin If 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. +In 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. + ### Tailscale Endpoints When the desktop app can detect Tailscale, it adds Tailnet endpoints to the reachable endpoint list. diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 24c22454d9de..09d9def21066 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -212,6 +212,45 @@ describe("tailscale", () => { }); }); + it.effect("turns spawn defects into typed spawn failures", () => { + // A non-directory entry on PATH makes node's spawn throw ENOTDIR + // synchronously. The platform spawner calls `NodeChildProcess.spawn` from + // inside an `Effect.callback` registration, so that throw arrives as a + // defect rather than a typed error - the shape reproduced here. + const defect = Object.assign(new Error("spawn tailscale ENOTDIR"), { code: "ENOTDIR" }); + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.callback(() => { + throw defect; + }), + ), + ); + + return Effect.gen(function* () { + const statusError = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); + assert.instanceOf(statusError, TailscaleCommandSpawnError); + assert.equal(statusError.subcommand, "status"); + assert.strictEqual(statusError.cause, defect); + + const serveError = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( + Effect.flip, + Effect.provide(layer), + ); + assert.instanceOf(serveError, TailscaleCommandSpawnError); + assert.equal(serveError.subcommand, "serve"); + assert.strictEqual(serveError.cause, defect); + + // What callers actually rely on: the desktop endpoint providers recover + // with `Effect.orElseSucceed`, which only sees the typed error channel. + const degraded = yield* readTailscaleStatus.pipe( + Effect.orElseSucceed(() => null), + Effect.provide(layer), + ); + assert.equal(degraded, null); + }); + }); + it.effect("keeps nonzero exit diagnostics structured", () => { const layer = mockSpawnerLayer(() => ({ code: 7, diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 7260a9de11bb..fedde02ee76f 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -228,11 +228,15 @@ export const readTailscaleStatus = Effect.gen(function* () { argumentCount: args.length, }; return yield* Effect.gen(function* () { - const child = yield* spawner - .spawn(ChildProcess.make(executable, args)) - .pipe( - Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), - ); + const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + // Spawning can also fail as a defect rather than a typed error - a + // non-directory entry on PATH makes node throw ENOTDIR synchronously. + // `mapError` never sees that, so it would escape as an uncaught error. + Effect.catchDefect((cause) => + Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), + ), + ); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectStdout(child.stdout), @@ -299,11 +303,12 @@ const runTailscaleCommand = ( }; const timeout = Duration.fromInputUnsafe(timeoutInput); return yield* Effect.gen(function* () { - const child = yield* spawner - .spawn(ChildProcess.make(executable, args)) - .pipe( - Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), - ); + const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + Effect.catchDefect((cause) => + Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), + ), + ); const [stderr, exitCode] = yield* Effect.all( [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], { concurrency: "unbounded" },