From 201b3d187354a840f3c3eef6abe6e2cef9136aae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 19:06:52 -0700 Subject: [PATCH 01/74] feat(web): support hosted pairing links Co-authored-by: codex --- .plans/19-remote-endpoints-hosted-static.md | 350 ++++++++++++++++++ .../components/auth/PairingRouteSurface.tsx | 99 +++++ .../settings/ConnectionsSettings.tsx | 17 +- apps/web/src/environments/runtime/service.ts | 6 +- apps/web/src/hostedPairing.test.ts | 52 +++ apps/web/src/hostedPairing.ts | 49 +++ apps/web/src/localApi.ts | 47 ++- apps/web/src/routes/__root.tsx | 79 +++- apps/web/src/routes/_chat.tsx | 5 +- apps/web/src/routes/pair.tsx | 18 +- apps/web/src/routes/settings.tsx | 5 +- apps/web/vite.config.ts | 2 + 12 files changed, 704 insertions(+), 25 deletions(-) create mode 100644 .plans/19-remote-endpoints-hosted-static.md create mode 100644 apps/web/src/hostedPairing.test.ts create mode 100644 apps/web/src/hostedPairing.ts diff --git a/.plans/19-remote-endpoints-hosted-static.md b/.plans/19-remote-endpoints-hosted-static.md new file mode 100644 index 00000000000..2fa0bc70211 --- /dev/null +++ b/.plans/19-remote-endpoints-hosted-static.md @@ -0,0 +1,350 @@ +# Remote Endpoints and Hosted Static App Plan + +## Purpose + +Make remote access feel first-class while keeping the free DIY path open. + +The immediate product goal is: + +- users can expose a backend through LAN, their own Tailscale, MagicDNS, a manual HTTPS endpoint, or later T3 Tunnel +- users can generate a hosted pairing link for `app.t3.codes` +- the hosted app can pair, persist, reconnect, and operate against saved environments without requiring a backend at the hosted app origin +- all transports reuse the same backend auth, WebSocket runtime, saved environment registry, and pairing UX + +This plan intentionally leaves the paid T3 cloud tunnel fabric out of scope. It defines the OSS foundation that T3 Tunnel should later plug into. + +## Current State + +Already present or in progress: + +- Server auth distinguishes bootstrap credentials from session credentials. +- One-time pairing credentials can be exchanged for browser sessions or bearer sessions. +- Saved remote environments store `httpBaseUrl`, `wsBaseUrl`, and a bearer token. +- Remote environment WebSocket connections use a short-lived WebSocket token. +- Pairing URLs can carry tokens in the URL fragment. +- Hosted `/pair?host=...#token=...` can add a saved environment. +- Hosted static startup can avoid assuming the page origin is the backend. + +Main gaps: + +- Reachability is represented ad hoc as `endpointUrl`, manual host input, or saved environment URLs. +- Desktop exposure, hosted pairing, manual remote environments, and future tunnels do not share one endpoint model. +- Tailscale/MagicDNS endpoints are not detected or surfaced. +- Hosted-static empty/offline states are still thin. +- Browser compatibility is not explicitly modeled, especially HTTPS hosted app to HTTP backend mixed-content failure. + +## Core Decision: Add `AdvertisedEndpoint` + +Add a new first-class contract instead of extending the environment descriptor. + +### Why not extend `ExecutionEnvironmentDescriptor` + +`ExecutionEnvironmentDescriptor` answers: "What environment is this?" + +Examples: + +- environment id +- label +- platform +- server version +- capabilities + +`AdvertisedEndpoint` answers: "How can a client reach this environment right now?" + +Examples: + +- loopback URL +- LAN URL +- Tailscale IP URL +- MagicDNS/Serve URL +- manual URL +- future T3 Tunnel URL +- browser compatibility and exposure level + +Those are different lifecycles. One environment can have many endpoints, endpoints can appear/disappear as network interfaces change, and the same descriptor is returned regardless of which endpoint the client used. Extending the descriptor would blur environment identity with transport reachability and make saved environments harder to reason about. + +### Target Contract + +Add a schema in `packages/contracts`, likely `remoteAccess.ts`: + +```ts +type AdvertisedEndpointProvider = + | "loopback" + | "lan" + | "tailscale-ip" + | "tailscale-magicdns" + | "manual" + | "t3-tunnel"; + +type AdvertisedEndpointVisibility = "local" | "private-network" | "tailnet" | "public"; + +type AdvertisedEndpointCompatibility = { + hostedHttpsApp: "compatible" | "mixed-content-blocked" | "untrusted-certificate" | "unknown"; + desktopApp: "compatible" | "unknown"; +}; + +type AdvertisedEndpoint = { + id: string; + provider: AdvertisedEndpointProvider; + label: string; + httpBaseUrl: string; + wsBaseUrl: string; + visibility: AdvertisedEndpointVisibility; + compatibility: AdvertisedEndpointCompatibility; + source: "server" | "desktop" | "user"; + status: "available" | "unavailable" | "unknown"; + isDefault?: boolean; +}; +``` + +Keep the contract schema-only. All classification logic belongs in `packages/shared`, `apps/server`, `apps/desktop`, or `apps/web`. + +## HTTP/WS and HTTPS/WSS Readiness + +The codebase is partially ready, but the UX and compatibility model are not explicit enough. + +What is ready: + +- Remote target parsing already derives `ws://` from `http://` and `wss://` from `https://`. +- Saved environments store both HTTP and WebSocket base URLs. +- Remote auth uses bearer tokens instead of cookies, so cross-origin hosted clients are viable. +- WebSocket connections can use a dynamically issued `wsToken`. +- Server CORS support exists for browser remote auth endpoints. + +What is not solved by code alone: + +- `https://app.t3.codes` cannot reliably call `http://...` or `ws://...` endpoints because browsers block mixed content. +- `wss://100.x.y.z:3773` needs a certificate the browser trusts. A raw Tailscale IP does not solve certificate trust. +- LAN `http://192.168.x.y:3773` is usable from another desktop/native context but not from the hosted HTTPS app. +- The UI needs to explain why an endpoint is copyable for desktop pairing but not hosted-app compatible. + +Policy: + +- Support both HTTP/WS and HTTPS/WSS at the runtime layer. +- Mark endpoint compatibility at the product layer. +- Generate `app.t3.codes` links only from endpoints that are likely hosted-browser compatible, or show a warning with an explicit fallback. + +## Architecture + +### Endpoint Sources + +Endpoint records can come from several providers: + +1. **Server runtime** + - headless bind host and port + - server-known explicit advertised host config + +2. **Desktop shell** + - loopback backend URL + - LAN exposure state + - network interface discovery + - Tailscale CLI/status discovery + +3. **User configuration** + - manually added hostnames + - preferred endpoint labels + - hidden/disabled endpoints + +4. **Future cloud provider** + - T3 Tunnel endpoint + - billing/account status + - tunnel lifecycle state + +### Endpoint Registry + +Create a central runtime registry: + +- `packages/contracts/src/remoteAccess.ts` +- `packages/shared/src/remoteAccess.ts` for URL normalization and compatibility classification +- `apps/server/src/remoteAccess/*` for server/headless endpoints +- `apps/desktop/src/remoteAccess/*` for desktop-discovered endpoints +- `apps/web/src/environments/endpoints/*` for client-side display and pairing selection + +The web app should consume endpoint records and not care whether they came from LAN, Tailscale, or a future tunnel. + +### Pairing Link Generation + +Move hosted pairing link generation to endpoint-driven input: + +```ts +buildHostedPairingUrl({ + endpoint: AdvertisedEndpoint, + token, +}); +``` + +Generated URL: + +```text +https://app.t3.codes/pair?host=#token= +``` + +Use fragment tokens by default. Continue accepting `?token=` for compatibility. + +## Phase 1: Endpoint Abstraction + +### Goals + +- Centralize URL normalization, protocol derivation, and compatibility checks. +- Replace ad hoc desktop `endpointUrl` pairing logic with endpoint selection. +- Preserve all current remote behavior. + +### Tasks + +1. Add `AdvertisedEndpoint` schemas to `packages/contracts`. +2. Add shared helpers: + - normalize HTTP base URL + - derive WebSocket base URL + - classify loopback/private/LAN/Tailscale/public host + - classify hosted HTTPS compatibility +3. Add server endpoint discovery: + - loopback endpoint + - configured non-loopback endpoint + - explicit advertised host override +4. Add desktop endpoint discovery: + - local loopback + - LAN exposure endpoint + - endpoint status labels +5. Add WebSocket/API method or existing config field for endpoint snapshots. +6. Refactor settings connections UI: + - render endpoint rows + - endpoint picker for pairing link copy + - show compatibility warnings +7. Refactor hosted link builder to accept endpoint records. +8. Add tests for URL normalization and compatibility classification. + +### Acceptance Criteria + +- Existing LAN/network access UI still works. +- Pairing links are generated from endpoint records. +- Loopback endpoints never produce hosted pairing links silently. +- HTTP private-network endpoints are marked incompatible with `app.t3.codes`. +- No remote environment runtime changes are required for existing saved environments. + +## Phase 2: BYO Tailscale/MagicDNS + +### Goals + +- Detect free DIY Tailscale reachability. +- Surface Tailscale endpoints as normal advertised endpoints. +- Keep users in control of their own tailnet. + +### Tasks + +1. Detect Tailscale IPs from network interfaces: + - IPv4 `100.64.0.0/10` + - mark as `provider: "tailscale-ip"` +2. Add optional desktop-side `tailscale status --json` discovery: + - MagicDNS hostname + - Tailscale Serve/Funnel HTTPS endpoint if discoverable + - graceful failure if CLI is missing +3. Add manual Tailscale endpoint override: + - hostname + - label + - preferred/default flag +4. Show Tailscale endpoint rows in settings: + - raw IP HTTP endpoint: desktop-compatible, hosted-app likely blocked + - HTTPS MagicDNS/Serve endpoint: hosted-compatible if URL is HTTPS +5. Generate pairing links using selected Tailscale endpoint. +6. Document DIY setup: + - local desktop-to-desktop over Tailscale + - hosted app requirements + - why HTTPS matters + +### Acceptance Criteria + +- A machine on Tailscale shows a Tailscale endpoint without paid features. +- Users can copy a Tailscale-hosted pairing link when the endpoint is HTTPS-compatible. +- Users can still copy token-only/manual values when endpoint compatibility is unknown. +- Tailscale is optional and never required for regular LAN/loopback use. + +## Phase 3: Hosted Static App Completion + +### Goals + +- `app.t3.codes` works as a real client shell. +- It can pair, persist, reconnect, and clearly explain offline/incompatible states. + +### Tasks + +1. Finish hosted-static root behavior: + - no primary backend required + - saved environment hydration before initial routing decisions + - first saved environment selected as active +2. Add hosted empty state: + - no saved environments + - paste pairing URL + - add host + token +3. Add offline saved environment UI: + - last connected + - reconnect + - remove + - copy/add alternate endpoint +4. Audit primary-backend assumptions: + - command palette + - settings pages + - server config atom defaults + - keybindings + - provider/model lists + - update/desktop-only affordances +5. Add route tests for: + - hosted `/pair?host=...#token=...` + - hosted root with no saved environments + - hosted root with saved environment + - primary backend unavailable but saved environment present +6. Add deployment hardening: + - SPA fallback + - strict CSP + - no third-party scripts + - no query token logging + - disable or hide source maps in production if needed +7. Add browser error messages: + - mixed content + - unreachable backend + - CORS failure + - certificate failure + +### Acceptance Criteria + +- `app.t3.codes` can pair a reachable HTTPS backend and reconnect after reload. +- A saved environment can be used without any backend at `app.t3.codes`. +- Offline machines show a useful state instead of a generic boot error. +- HTTP endpoints are still supported in desktop/native/local contexts. +- Hosted HTTPS app only promises compatibility for HTTPS/WSS endpoints. + +## Phase 4: Future T3 Tunnel Provider + +Not part of the current implementation, but the endpoint abstraction should make it straightforward. + +Future tunnel provider responsibilities: + +- create endpoint with `provider: "t3-tunnel"` +- surface tunnel status +- provide stable HTTPS URL +- use existing backend pairing/session auth +- never bypass server auth + +The tunnel fabric can later be Pipenet-derived, Tailscale-derived, or another reverse tunnel implementation. The rest of T3 Code should only see an `AdvertisedEndpoint`. + +## Security Checklist + +- Pairing tokens are short-lived and one-time. +- Generated hosted pairing links put tokens in the fragment. +- The backend remains the authorization boundary. +- Endpoint discovery never disables backend auth. +- Hosted app does not silently downgrade to HTTP. +- Tunnel/public endpoints require explicit user action. +- Client sessions remain revocable. +- Endpoint URLs and request logs must avoid recording pairing tokens. +- Future cloud tunnel must authenticate tunnel creation and tunnel data connections separately from backend pairing. + +## Verification + +Each implementation PR should run: + +- `bun fmt` +- `bun lint` +- `bun typecheck` +- focused tests for changed backend/web behavior +- backend tests for any server-side endpoint discovery or auth changes using `bun run test`, never `bun test` + diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index f583af72ec4..46349ca1faf 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -2,11 +2,13 @@ import type { AuthSessionState } from "@t3tools/contracts"; import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; import { APP_DISPLAY_NAME } from "../../branding"; +import { addSavedEnvironment } from "../../environments/runtime"; import { peekPairingTokenFromUrl, stripPairingTokenFromUrl, submitServerAuthCredential, } from "../../environments/primary"; +import { readHostedPairingRequest } from "../../hostedPairing"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -159,6 +161,103 @@ export function PairingRouteSurface({ ); } +export function HostedPairingRouteSurface() { + const hostedPairingRequestRef = useRef(readHostedPairingRequest()); + const [status, setStatus] = useState<"pairing" | "paired" | "error">("pairing"); + const [message, setMessage] = useState("Connecting to this backend."); + const submitAttemptedRef = useRef(false); + + const submitHostedPairingRequest = useCallback(async () => { + const request = hostedPairingRequestRef.current; + + if (!request) { + setStatus("error"); + setMessage("This pairing link is missing its backend host or token."); + return; + } + + setStatus("pairing"); + setMessage("Connecting to this backend."); + + try { + const record = await addSavedEnvironment({ + label: request.label, + host: request.host, + pairingCode: request.token, + }); + setStatus("paired"); + setMessage(`${record.label} is saved in this browser.`); + } catch (error) { + setStatus("error"); + setMessage(errorMessageFromUnknown(error)); + } + }, []); + + useEffect(() => { + if (submitAttemptedRef.current) { + return; + } + submitAttemptedRef.current = true; + + stripPairingTokenFromUrl(); + void submitHostedPairingRequest(); + }, [submitHostedPairingRequest]); + + const request = hostedPairingRequestRef.current; + + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+

+ {status === "paired" + ? "Backend paired" + : status === "error" + ? "Pairing failed" + : "Pairing backend"} +

+

{message}

+ + {request ? ( +
+ Host: {request.host} +
+ ) : null} + + {status === "error" ? ( +
+ Verify the backend is reachable from this browser, supports CORS for hosted clients, and + is served over HTTPS when opening this page from HTTPS. +
+ ) : null} + +
+ + {status === "paired" ? ( + + ) : null} +
+
+
+ ); +} + function errorMessageFromUnknown(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 880c4376e2c..50af9f88596 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -9,6 +9,7 @@ import { import { DateTime } from "effect"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { buildHostedPairingUrl } from "../../hostedPairing"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { @@ -249,6 +250,13 @@ function resolveDesktopPairingUrl(endpointUrl: string, credential: string): stri return setPairingTokenOnUrl(url, credential).toString(); } +function resolveHostedPairingUrl(endpointUrl: string, credential: string): string { + return buildHostedPairingUrl({ + host: endpointUrl, + token: credential, + }); +} + function resolveCurrentOriginPairingUrl(credential: string): string { const url = new URL("/pair", window.location.href); return setPairingTokenOnUrl(url, credential).toString(); @@ -278,9 +286,16 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ () => resolveCurrentOriginPairingUrl(pairingLink.credential), [pairingLink.credential], ); + const hostedPairingUrl = useMemo( + () => + endpointUrl != null && endpointUrl !== "" + ? resolveHostedPairingUrl(endpointUrl, pairingLink.credential) + : null, + [endpointUrl, pairingLink.credential], + ); const shareablePairingUrl = endpointUrl != null && endpointUrl !== "" - ? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential) + ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)) : isLoopbackHostname(window.location.hostname) ? null : currentOriginPairingUrl; diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index d724eeff333..ec920e9a7be 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -905,6 +905,10 @@ function createPrimaryEnvironmentConnection(): EnvironmentConnection { ); } +function maybeCreatePrimaryEnvironmentConnection(): EnvironmentConnection | null { + return getPrimaryKnownEnvironment() ? createPrimaryEnvironmentConnection() : null; +} + async function ensureSavedEnvironmentConnection( record: SavedEnvironmentRecord, options?: { @@ -1170,7 +1174,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () }, ); - createPrimaryEnvironmentConnection(); + maybeCreatePrimaryEnvironmentConnection(); const unsubscribeSavedEnvironments = useSavedEnvironmentRegistryStore.subscribe(() => { if (!hasSavedEnvironmentRegistryHydrated()) { diff --git a/apps/web/src/hostedPairing.test.ts b/apps/web/src/hostedPairing.test.ts new file mode 100644 index 00000000000..f347ca46ab5 --- /dev/null +++ b/apps/web/src/hostedPairing.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + buildHostedPairingUrl, + hasHostedPairingRequest, + readHostedPairingRequest, +} from "./hostedPairing"; + +describe("hostedPairing", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("reads hosted pairing host and query token parameters", () => { + const url = new URL("https://app.t3.codes/pair?host=100.64.1.2:3773&token=ABCD1234"); + + expect(readHostedPairingRequest(url)).toEqual({ + host: "100.64.1.2:3773", + token: "ABCD1234", + label: "", + }); + expect(hasHostedPairingRequest(url)).toBe(true); + }); + + it("prefers hash tokens so generated hosted links do not put credentials in search params", () => { + vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes"); + + const url = new URL( + buildHostedPairingUrl({ + host: "https://backend.example.com:3773", + token: "pairing-token", + label: "Workstation", + }), + ); + + expect(url.origin).toBe("https://preview.t3.codes"); + expect(url.pathname).toBe("/pair"); + expect(url.searchParams.get("host")).toBe("https://backend.example.com:3773"); + expect(url.searchParams.get("label")).toBe("Workstation"); + expect(url.searchParams.has("token")).toBe(false); + expect(url.hash).toBe("#token=pairing-token"); + }); + + it("ignores incomplete hosted pairing requests", () => { + expect( + hasHostedPairingRequest(new URL("https://app.t3.codes/pair?host=backend.example.com")), + ).toBe(false); + expect(hasHostedPairingRequest(new URL("https://app.t3.codes/pair?token=ABCD1234"))).toBe( + false, + ); + }); +}); diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts new file mode 100644 index 00000000000..9855c01be2b --- /dev/null +++ b/apps/web/src/hostedPairing.ts @@ -0,0 +1,49 @@ +import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl"; + +const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; + +export interface HostedPairingRequest { + readonly host: string; + readonly token: string; + readonly label: string; +} + +function configuredHostedAppUrl(): string { + return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; +} + +export function readHostedPairingRequest(url: URL = new URL(window.location.href)) { + const host = url.searchParams.get("host")?.trim() ?? ""; + const token = getPairingTokenFromUrl(url)?.trim() ?? ""; + const label = url.searchParams.get("label")?.trim() ?? ""; + + if (!host || !token) { + return null; + } + + return { + host, + token, + label, + } satisfies HostedPairingRequest; +} + +export function hasHostedPairingRequest(url: URL = new URL(window.location.href)): boolean { + return readHostedPairingRequest(url) !== null; +} + +export function buildHostedPairingUrl(input: { + readonly host: string; + readonly token: string; + readonly label?: string | null; +}): string { + const url = new URL("/pair", configuredHostedAppUrl()); + url.searchParams.set("host", input.host); + + const label = input.label?.trim(); + if (label) { + url.searchParams.set("label", label); + } + + return setPairingTokenOnUrl(url, input.token).toString(); +} diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index c872a5f1030..17fa75af675 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -13,6 +13,7 @@ import { getPrimaryEnvironmentConnection, resetEnvironmentServiceForTests, } from "./environments/runtime"; +import { getPrimaryKnownEnvironment } from "./environments/primary"; import { type WsRpcClient } from "./rpc/wsRpcClient"; import { showContextMenuFallback } from "./contextMenuFallback"; import { @@ -27,7 +28,11 @@ import { let cachedApi: LocalApi | undefined; -export function createLocalApi(rpcClient: WsRpcClient): LocalApi { +function unavailableLocalBackendError(): Error { + return new Error("Local backend API is unavailable before a backend is paired."); +} + +function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { return { dialogs: { pickFolder: async (options) => { @@ -42,7 +47,10 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { }, }, shell: { - openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), + openInEditor: (cwd, editor) => + rpcClient + ? rpcClient.shell.openInEditor({ cwd, editor }) + : Promise.reject(unavailableLocalBackendError()), openExternal: async (url) => { if (window.desktopBridge) { const opened = await window.desktopBridge.openExternal(url); @@ -111,16 +119,34 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { }, }, server: { - getConfig: rpcClient.server.getConfig, - refreshProviders: rpcClient.server.refreshProviders, - upsertKeybinding: rpcClient.server.upsertKeybinding, - getSettings: rpcClient.server.getSettings, - updateSettings: rpcClient.server.updateSettings, - discoverSourceControl: rpcClient.server.discoverSourceControl, + getConfig: () => + rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()), + refreshProviders: () => + rpcClient + ? rpcClient.server.refreshProviders() + : Promise.reject(unavailableLocalBackendError()), + upsertKeybinding: (input) => + rpcClient + ? rpcClient.server.upsertKeybinding(input) + : Promise.reject(unavailableLocalBackendError()), + getSettings: () => + rpcClient ? rpcClient.server.getSettings() : Promise.reject(unavailableLocalBackendError()), + updateSettings: (patch) => + rpcClient + ? rpcClient.server.updateSettings(patch) + : Promise.reject(unavailableLocalBackendError()), + discoverSourceControl: (input) => + rpcClient + ? rpcClient.server.discoverSourceControl(input) + : Promise.reject(unavailableLocalBackendError()), }, }; } +export function createLocalApi(rpcClient: WsRpcClient): LocalApi { + return createBrowserLocalApi(rpcClient); +} + export function readLocalApi(): LocalApi | undefined { if (typeof window === "undefined") return undefined; if (cachedApi) return cachedApi; @@ -130,7 +156,10 @@ export function readLocalApi(): LocalApi | undefined { return cachedApi; } - cachedApi = createLocalApi(getPrimaryEnvironmentConnection().client); + const primaryEnvironment = getPrimaryKnownEnvironment(); + cachedApi = primaryEnvironment + ? createLocalApi(getPrimaryEnvironmentConnection().client) + : createBrowserLocalApi(); return cachedApi; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 87e8667901d..fb33c6ae238 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -46,26 +46,52 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme"; import { ensureEnvironmentConnectionBootstrapped, getPrimaryEnvironmentConnection, + listSavedEnvironmentRecords, + waitForSavedEnvironmentRegistryHydration, startEnvironmentConnectionService, + useSavedEnvironmentRegistryStore, } from "../environments/runtime"; import { configureClientTracing } from "../observability/clientTracing"; import { ensurePrimaryEnvironmentReady, + getPrimaryKnownEnvironment, resolveInitialServerAuthGateState, updatePrimaryEnvironmentDescriptor, } from "../environments/primary"; +import { hasHostedPairingRequest } from "../hostedPairing"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; }>()({ - beforeLoad: async () => { - const [, authGateState] = await Promise.all([ - ensurePrimaryEnvironmentReady(), - resolveInitialServerAuthGateState(), - ]); - return { - authGateState, - }; + beforeLoad: async ({ location }) => { + if (location.pathname === "/pair" && hasHostedPairingRequest(new URL(window.location.href))) { + return { + authGateState: { + status: "hosted-pairing", + } as const, + }; + } + + try { + const [, authGateState] = await Promise.all([ + ensurePrimaryEnvironmentReady(), + resolveInitialServerAuthGateState(), + ]); + return { + authGateState, + }; + } catch (error) { + if (location.pathname === "/pair") { + throw error; + } + + await waitForSavedEnvironmentRegistryHydration(); + return { + authGateState: { + status: "hosted-static", + } as const, + }; + } }, component: RootRouteView, errorComponent: RootRouteErrorView, @@ -91,7 +117,7 @@ function RootRouteView() { return ; } - if (authGateState.status !== "authenticated") { + if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { return ; } return ( @@ -100,6 +126,7 @@ function RootRouteView() { + @@ -115,6 +142,32 @@ function RootRouteView() { ); } +function HostedStaticEnvironmentBootstrap() { + const savedEnvironmentCount = useSavedEnvironmentRegistryStore( + (state) => Object.keys(state.byId).length, + ); + + useEffect(() => { + if (getPrimaryKnownEnvironment()) { + return; + } + + const currentActiveEnvironmentId = useStore.getState().activeEnvironmentId; + if (currentActiveEnvironmentId) { + return; + } + + const firstSavedEnvironment = listSavedEnvironmentRecords()[0]; + if (!firstSavedEnvironment) { + return; + } + + useStore.getState().setActiveEnvironmentId(firstSavedEnvironment.environmentId); + }, [savedEnvironmentCount]); + + return null; +} + function RootRouteErrorView({ error, reset }: ErrorComponentProps) { const message = errorMessage(error); const details = errorDetails(error); @@ -187,7 +240,13 @@ function errorDetails(error: unknown): string { } function ServerStateBootstrap() { - useEffect(() => startServerStateSync(getPrimaryEnvironmentConnection().client.server), []); + useEffect(() => { + if (!getPrimaryKnownEnvironment()) { + return; + } + + return startServerStateSync(getPrimaryEnvironmentConnection().client.server); + }, []); return null; } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index fb8191f4480..da22d7e6028 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -108,7 +108,10 @@ function ChatRouteLayout() { export const Route = createFileRoute("/_chat")({ beforeLoad: async ({ context }) => { - if (context.authGateState.status !== "authenticated") { + if ( + context.authGateState.status !== "authenticated" && + context.authGateState.status !== "hosted-static" + ) { throw redirect({ to: "/pair", replace: true }); } }, diff --git a/apps/web/src/routes/pair.tsx b/apps/web/src/routes/pair.tsx index 6925dac69cc..6575cd1bafa 100644 --- a/apps/web/src/routes/pair.tsx +++ b/apps/web/src/routes/pair.tsx @@ -1,11 +1,21 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; -import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface"; +import { + HostedPairingRouteSurface, + PairingPendingSurface, + PairingRouteSurface, +} from "../components/auth/PairingRouteSurface"; export const Route = createFileRoute("/pair")({ beforeLoad: async ({ context }) => { const { authGateState } = context; - if (authGateState.status === "authenticated") { + if (authGateState.status === "hosted-pairing") { + return { + authGateState, + }; + } + + if (authGateState.status === "authenticated" || authGateState.status === "hosted-static") { throw redirect({ to: "/", replace: true }); } return { @@ -24,6 +34,10 @@ function PairRouteView() { return null; } + if (authGateState.status === "hosted-pairing") { + return ; + } + return ( { - if (context.authGateState.status !== "authenticated") { + if ( + context.authGateState.status !== "authenticated" && + context.authGateState.status !== "hosted-static" + ) { throw redirect({ to: "/pair", replace: true }); } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index d4042c2e88e..90d8689c81c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,6 +9,7 @@ const port = Number(process.env.PORT ?? 5733); const host = process.env.HOST?.trim() || "localhost"; const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim(); const configuredWsUrl = process.env.VITE_WS_URL?.trim(); +const configuredHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim(); const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); const buildSourcemap = @@ -68,6 +69,7 @@ export default defineConfig({ "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), // In dev mode, tell the web app where the WebSocket server lives "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), + "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), }, resolve: { From 9033cb38ada7011e108065cfe25f94ef44dbda31 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 19:43:08 -0700 Subject: [PATCH 02/74] fix(web): keep hosted pairing to secure endpoints Co-authored-by: codex --- .../settings/ConnectionsSettings.tsx | 15 +----- .../components/settings/pairingUrls.test.ts | 24 ++++++++++ .../src/components/settings/pairingUrls.ts | 20 ++++++++ .../service.threadSubscriptions.test.ts | 47 +++++++++++++++---- apps/web/src/environments/runtime/service.ts | 2 +- 5 files changed, 83 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/components/settings/pairingUrls.test.ts create mode 100644 apps/web/src/components/settings/pairingUrls.ts diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 50af9f88596..f6ab384d9fb 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -9,9 +9,9 @@ import { import { DateTime } from "effect"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; -import { buildHostedPairingUrl } from "../../hostedPairing"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; +import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; import { SettingsPageContainer, SettingsRow, @@ -244,19 +244,6 @@ function removeDesktopClientSession( return current.filter((clientSession) => clientSession.sessionId !== sessionId); } -function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { - const url = new URL(endpointUrl); - url.pathname = "/pair"; - return setPairingTokenOnUrl(url, credential).toString(); -} - -function resolveHostedPairingUrl(endpointUrl: string, credential: string): string { - return buildHostedPairingUrl({ - host: endpointUrl, - token: credential, - }); -} - function resolveCurrentOriginPairingUrl(credential: string): string { const url = new URL("/pair", window.location.href); return setPairingTokenOnUrl(url, credential).toString(); diff --git a/apps/web/src/components/settings/pairingUrls.test.ts b/apps/web/src/components/settings/pairingUrls.test.ts new file mode 100644 index 00000000000..b32f5c51b32 --- /dev/null +++ b/apps/web/src/components/settings/pairingUrls.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; + +describe("settings pairing URL helpers", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("uses direct backend pairing URLs for HTTP endpoints", () => { + expect(resolveHostedPairingUrl("http://192.168.1.44:3773", "PAIRCODE")).toBeNull(); + expect(resolveDesktopPairingUrl("http://192.168.1.44:3773", "PAIRCODE")).toBe( + "http://192.168.1.44:3773/pair#token=PAIRCODE", + ); + }); + + it("uses hosted pairing URLs for HTTPS endpoints", () => { + vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes"); + + expect(resolveHostedPairingUrl("https://host.tailnet.example.ts.net:3773", "PAIRCODE")).toBe( + "https://preview.t3.codes/pair?host=https%3A%2F%2Fhost.tailnet.example.ts.net%3A3773#token=PAIRCODE", + ); + }); +}); diff --git a/apps/web/src/components/settings/pairingUrls.ts b/apps/web/src/components/settings/pairingUrls.ts new file mode 100644 index 00000000000..891fe04ad6b --- /dev/null +++ b/apps/web/src/components/settings/pairingUrls.ts @@ -0,0 +1,20 @@ +import { buildHostedPairingUrl } from "../../hostedPairing"; +import { setPairingTokenOnUrl } from "../../pairingUrl"; + +export function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { + const url = new URL(endpointUrl); + url.pathname = "/pair"; + return setPairingTokenOnUrl(url, credential).toString(); +} + +export function resolveHostedPairingUrl(endpointUrl: string, credential: string): string | null { + const url = new URL(endpointUrl); + if (url.protocol !== "https:") { + return null; + } + + return buildHostedPairingUrl({ + host: endpointUrl, + token: credential, + }); +} diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index efecfdca8e9..eb5f9d7b20e 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -16,22 +16,14 @@ const mockCreateWsRpcClient = vi.fn(); const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); const mockListSavedEnvironmentRecords = vi.fn(); const mockSavedEnvironmentRegistrySubscribe = vi.fn(); +const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); function MockWsTransport() { return undefined; } vi.mock("../primary", () => ({ - getPrimaryKnownEnvironment: vi.fn(() => ({ - id: "env-1", - label: "Primary environment", - source: "window-origin", - target: { - httpBaseUrl: "http://127.0.0.1:3000/", - wsBaseUrl: "ws://127.0.0.1:3000/", - }, - environmentId: EnvironmentId.make("env-1"), - })), + getPrimaryKnownEnvironment: mockGetPrimaryKnownEnvironment, })); vi.mock("./catalog", () => ({ @@ -145,6 +137,16 @@ describe("retainThreadDetailSubscription", () => { vi.useFakeTimers(); vi.resetModules(); vi.clearAllMocks(); + mockGetPrimaryKnownEnvironment.mockReturnValue({ + id: "env-1", + label: "Primary environment", + source: "window-origin", + target: { + httpBaseUrl: "http://127.0.0.1:3000/", + wsBaseUrl: "ws://127.0.0.1:3000/", + }, + environmentId: EnvironmentId.make("env-1"), + }); mockThreadUnsubscribe.mockImplementation(() => undefined); mockSubscribeThread.mockImplementation(() => mockThreadUnsubscribe); @@ -204,6 +206,31 @@ describe("retainThreadDetailSubscription", () => { await resetEnvironmentServiceForTests(); }); + it("does not start the primary connection until the known environment has an id", async () => { + mockGetPrimaryKnownEnvironment.mockReturnValue({ + id: "env-1", + label: "Primary environment", + source: "window-origin", + target: { + httpBaseUrl: "http://127.0.0.1:3000/", + wsBaseUrl: "ws://127.0.0.1:3000/", + }, + }); + const { + listEnvironmentConnections, + resetEnvironmentServiceForTests, + startEnvironmentConnectionService, + } = await import("./service"); + + const stop = startEnvironmentConnectionService(new QueryClient()); + + expect(mockCreateEnvironmentConnection).not.toHaveBeenCalled(); + expect(listEnvironmentConnections()).toEqual([]); + + stop(); + await resetEnvironmentServiceForTests(); + }); + it("keeps non-idle thread detail subscriptions attached until the thread becomes idle", async () => { const { retainThreadDetailSubscription, diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index ec920e9a7be..6c58f9e8a53 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -906,7 +906,7 @@ function createPrimaryEnvironmentConnection(): EnvironmentConnection { } function maybeCreatePrimaryEnvironmentConnection(): EnvironmentConnection | null { - return getPrimaryKnownEnvironment() ? createPrimaryEnvironmentConnection() : null; + return getPrimaryKnownEnvironment()?.environmentId ? createPrimaryEnvironmentConnection() : null; } async function ensureSavedEnvironmentConnection( From 94acd222c5c812ccd4b19e10d4664eafc5e45977 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 19:55:24 -0700 Subject: [PATCH 03/74] docs(remote): document hosted pairing constraints Co-authored-by: codex --- .docs/remote-architecture.md | 26 ++++++++++++++++++++++++++ REMOTE.md | 16 ++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md index 32e35d7cafa..8e5ed37928e 100644 --- a/.docs/remote-architecture.md +++ b/.docs/remote-architecture.md @@ -93,6 +93,8 @@ Examples: A known environment may or may not know the target `environmentId` before first successful connect. +In the hosted web app, known environments are browser-local. A hosted pairing URL can create the saved entry, but it does not give the hosted app a server-side control plane or a copy of the session state. + ### AccessEndpoint An `AccessEndpoint` is one concrete way to reach a known environment. @@ -108,6 +110,26 @@ A single environment may have many endpoints: The environment stays the same. Only the access path changes. +### Hosted pairing request + +A hosted pairing request is a bootstrap URL for the static web app, not a transport. + +Example: + +```text +https://app.t3.codes/pair?host=https://backend.example.com:3773#token=PAIRCODE +``` + +The hosted app reads the `host` parameter and pairing token, exchanges the token directly with that backend, then saves the resulting environment record in browser local storage. + +Important constraints: + +- the hosted app does not proxy HTTP or WebSocket traffic +- the backend must still be reachable directly from the browser +- HTTPS pages can only connect to HTTPS/WSS backends +- HTTP LAN endpoints should keep using direct desktop or CLI pairing URLs +- the token belongs in the URL hash so it is not sent to the hosted app origin + ### RepositoryIdentity `RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments. @@ -151,6 +173,8 @@ Benefits: - no client-specific process management required - best fit for hosted or self-managed remote T3 deployments +Browser security rules are part of this access method. A hosted HTTPS web client can connect to `wss://` backends, but it cannot connect to plain `ws://` or `http://` LAN backends because that would be mixed content. + ### 2. Tunneled WebSocket access Examples: @@ -267,6 +291,8 @@ T3 already supports a WebSocket auth token on the server. That should become a f For publicly reachable environments, authenticated access should be treated as required. +Hosted pairing should be treated as a client-side convenience only. The hosted app must not receive pairing tokens through query parameters, must not store pairing state server-side, and must not imply that an HTTP backend is safe or reachable from an HTTPS browser context. + ## Relationship to Zed Zed is a useful reference implementation for managed remote launch and reconnect behavior. diff --git a/REMOTE.md b/REMOTE.md index 30dc562792f..cec7b0ecccb 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -47,6 +47,7 @@ From there, connect from another device in either of these ways: - scan the QR code on your phone - in the desktop app, enter the full pairing URL - in the desktop app, enter the host and token separately +- in the hosted web app, open a hosted pairing URL when the backend is reachable over HTTPS Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument. @@ -67,6 +68,20 @@ Instead: After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device. +## Hosted Web App Pairing + +The hosted web app at `https://app.t3.codes` can save a remote backend in browser local storage from a URL like: + +```text +https://app.t3.codes/pair?host=https://backend.example.com:3773#token=PAIRCODE +``` + +Use hosted pairing when the backend is reachable from the browser over HTTPS/WSS. This includes a backend behind a trusted HTTPS tunnel or another HTTPS endpoint you operate. + +Do not use hosted pairing for plain HTTP LAN URLs such as `http://192.168.x.y:3773`. Browsers block an HTTPS page from connecting to an insecure HTTP or WS backend. For those endpoints, use the direct pairing URL shown by the desktop app or CLI from a client that can open that HTTP URL directly. + +Hosted pairing does not proxy traffic through T3 Code. The browser still connects directly to the backend URL in the pairing link. + ## Managing Access Later Use `t3 auth` to manage access after the initial pairing flow. @@ -84,4 +99,5 @@ Use `t3 auth --help` and the nested subcommand help pages for the full reference - Treat pairing URLs and pairing tokens like passwords. - Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly. - Anyone with a valid pairing credential can create a session until that credential expires or is revoked. +- Hosted pairing links keep the credential in the URL hash so it is not sent to the hosted app server, but it can still be exposed through browser history, screenshots, logs, or copy/paste. - Use `t3 auth` to revoke credentials or sessions you no longer trust. From 9813692378bf777e0fa7059fe9e9e954693b8b41 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 23:34:48 -0700 Subject: [PATCH 04/74] fix(web): treat hosted app as static Co-authored-by: codex --- .gitignore | 1 + apps/web/src/hostedPairing.test.ts | 14 ++++++++ apps/web/src/hostedPairing.ts | 21 +++++++++++ apps/web/src/routes/__root.tsx | 43 +++++++++++++++------- apps/web/src/routes/_chat.index.tsx | 55 +++++++++++++++++++++++++++++ apps/web/src/vite-env.d.ts | 3 ++ apps/web/vercel.json | 8 +++++ apps/web/vite.config.ts | 10 +++++- 8 files changed, 141 insertions(+), 14 deletions(-) create mode 100644 apps/web/vercel.json diff --git a/.gitignore b/.gitignore index 6c48782f9ac..9e14e917910 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ apps/web/src/components/__screenshots__ __screenshots__/ .tanstack squashfs-root/ +.vercel diff --git a/apps/web/src/hostedPairing.test.ts b/apps/web/src/hostedPairing.test.ts index f347ca46ab5..ef2b2eb5279 100644 --- a/apps/web/src/hostedPairing.test.ts +++ b/apps/web/src/hostedPairing.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { buildHostedPairingUrl, hasHostedPairingRequest, + isHostedStaticApp, readHostedPairingRequest, } from "./hostedPairing"; @@ -49,4 +50,17 @@ describe("hostedPairing", () => { false, ); }); + + it("detects the hosted static app only when no backend URL is configured", () => { + vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes"); + vi.stubEnv("VITE_HTTP_URL", ""); + vi.stubEnv("VITE_WS_URL", ""); + + expect(isHostedStaticApp(new URL("https://preview.t3.codes/"))).toBe(true); + expect(isHostedStaticApp(new URL("https://preview.t3.codes/pair"))).toBe(true); + expect(isHostedStaticApp(new URL("https://backend.example.com/"))).toBe(false); + + vi.stubEnv("VITE_HTTP_URL", "https://backend.example.com"); + expect(isHostedStaticApp(new URL("https://preview.t3.codes/"))).toBe(false); + }); }); diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index 9855c01be2b..a44cfa70834 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -12,6 +12,27 @@ function configuredHostedAppUrl(): string { return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; } +function configuredBackendUrl(): string { + return import.meta.env.VITE_HTTP_URL?.trim() || import.meta.env.VITE_WS_URL?.trim() || ""; +} + +function originFromUrl(value: string): string | null { + try { + return new URL(value).origin; + } catch { + return null; + } +} + +export function isHostedStaticApp(url: URL = new URL(window.location.href)): boolean { + if (configuredBackendUrl()) { + return false; + } + + const hostedOrigin = originFromUrl(configuredHostedAppUrl()); + return hostedOrigin !== null && url.origin === hostedOrigin; +} + export function readHostedPairingRequest(url: URL = new URL(window.location.href)) { const host = url.searchParams.get("host")?.trim() ?? ""; const token = getPairingTokenFromUrl(url)?.trim() ?? ""; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index fb33c6ae238..df62be29dfa 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -58,7 +58,7 @@ import { resolveInitialServerAuthGateState, updatePrimaryEnvironmentDescriptor, } from "../environments/primary"; -import { hasHostedPairingRequest } from "../hostedPairing"; +import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -72,6 +72,15 @@ export const Route = createRootRouteWithContext<{ }; } + if (isHostedStaticApp(new URL(window.location.href))) { + await waitForSavedEnvironmentRegistryHydration(); + return { + authGateState: { + status: "hosted-static", + } as const, + }; + } + try { const [, authGateState] = await Promise.all([ ensurePrimaryEnvironmentReady(), @@ -103,6 +112,7 @@ export const Route = createRootRouteWithContext<{ function RootRouteView() { const pathname = useLocation({ select: (location) => location.pathname }); const { authGateState } = Route.useRouteContext(); + const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; useEffect(() => { const frame = window.requestAnimationFrame(() => { @@ -120,23 +130,30 @@ function RootRouteView() { if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { return ; } + + const appShell = ( + + + + + + ); + return ( - - + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} - - - - - - - - - - + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? ( + {appShell} + ) : ( + appShell + )} ); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 43f06177a74..98a125bdfe4 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,11 +1,66 @@ import { createFileRoute } from "@tanstack/react-router"; +import { LinkIcon, PlusIcon } from "lucide-react"; import { NoActiveThreadState } from "../components/NoActiveThreadState"; +import { Button } from "../components/ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; +import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { useSavedEnvironmentRegistryStore } from "../environments/runtime"; +import { APP_DISPLAY_NAME } from "~/branding"; function ChatIndexRouteView() { + const { authGateState } = Route.useRouteContext(); + const savedEnvironmentCount = useSavedEnvironmentRegistryStore( + (state) => Object.keys(state.byId).length, + ); + + if (authGateState.status === "hosted-static" && savedEnvironmentCount === 0) { + return ; + } + return ; } export const Route = createFileRoute("/_chat/")({ component: ChatIndexRouteView, }); + +function HostedStaticOnboardingState() { + return ( + +
+
+
+ + + {APP_DISPLAY_NAME} + +
+
+ + +
+ +
+ +
+ + Connect an environment to get started + + + Open a pairing link from your T3 Code desktop app or add a reachable backend + manually. Your saved environments stay in this browser. + +
+ +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 1d7d41db1bc..99c5a65d85e 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -3,6 +3,9 @@ import type { DesktopBridge, LocalApi } from "@t3tools/contracts"; interface ImportMetaEnv { + readonly VITE_HTTP_URL: string; + readonly VITE_WS_URL: string; + readonly VITE_HOSTED_APP_URL: string; readonly APP_VERSION: string; } diff --git a/apps/web/vercel.json b/apps/web/vercel.json new file mode 100644 index 00000000000..1323cdac34c --- /dev/null +++ b/apps/web/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 90d8689c81c..cea7ce71ec9 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,7 +9,15 @@ const port = Number(process.env.PORT ?? 5733); const host = process.env.HOST?.trim() || "localhost"; const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim(); const configuredWsUrl = process.env.VITE_WS_URL?.trim(); -const configuredHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim(); +const configuredHostedAppUrl = (() => { + if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) { + return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`; + } + if (process.env.VERCEL_URL) { + return `https://${process.env.VERCEL_URL}`; + } + return process.env.VITE_HOSTED_APP_URL?.trim(); +})(); const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); const buildSourcemap = From e227b7874a20f81a62f170cc5950eee5d06d9958 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 19:13:05 -0700 Subject: [PATCH 05/74] feat(remote): add advertised endpoint registry Co-authored-by: codex --- apps/desktop/package.json | 1 + apps/desktop/src/main.ts | 31 +- apps/desktop/src/preload.ts | 2 + apps/desktop/src/serverExposure.test.ts | 97 +++++- apps/desktop/src/serverExposure.ts | 95 +++++- .../settings/ConnectionsSettings.tsx | 278 ++++++++++++------ .../settings/SettingsPanels.browser.tsx | 2 + apps/web/src/localApi.test.ts | 1 + bun.lock | 1 + .../src/advertisedEndpoint.test.ts | 61 ++++ .../client-runtime/src/advertisedEndpoint.ts | 78 +++++ packages/client-runtime/src/index.ts | 1 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 2 + packages/contracts/src/remoteAccess.ts | 68 +++++ 15 files changed, 634 insertions(+), 85 deletions(-) create mode 100644 packages/client-runtime/src/advertisedEndpoint.test.ts create mode 100644 packages/client-runtime/src/advertisedEndpoint.ts create mode 100644 packages/contracts/src/remoteAccess.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 34a061ffc70..db4f478798d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,6 +20,7 @@ "electron-updater": "^6.6.2" }, "devDependencies": { + "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@types/node": "catalog:", diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c5507c6fb03..c5713d2035b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -55,7 +55,10 @@ import { } from "./clientPersistence.ts"; import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts"; import { showDesktopConfirmDialog } from "./confirmDialog.ts"; -import { resolveDesktopServerExposure } from "./serverExposure.ts"; +import { + resolveDesktopCoreAdvertisedEndpoints, + resolveDesktopServerExposure, +} from "./serverExposure.ts"; import { syncShellEnvironment } from "./syncShellEnvironment.ts"; import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; @@ -102,6 +105,7 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); @@ -297,6 +301,7 @@ function backendChildEnv(): NodeJS.ProcessEnv { delete env.T3CODE_DESKTOP_WS_URL; delete env.T3CODE_DESKTOP_LAN_ACCESS; delete env.T3CODE_DESKTOP_LAN_HOST; + delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS; return env; } @@ -308,6 +313,20 @@ function getDesktopServerExposureState(): DesktopServerExposureState { }; } +function getDesktopAdvertisedEndpoints() { + const exposure = resolveDesktopServerExposure({ + mode: desktopServerExposureMode, + port: backendPort, + networkInterfaces: OS.networkInterfaces(), + ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}), + }); + return resolveDesktopCoreAdvertisedEndpoints({ + port: backendPort, + exposure, + customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), + }); +} + function getDesktopSecretStorage() { return { isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), @@ -321,6 +340,13 @@ function resolveAdvertisedHostOverride(): string | undefined { return override && override.length > 0 ? override : undefined; } +function resolveCustomHttpsEndpointUrls(): readonly string[] { + return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + async function applyDesktopServerExposureMode( mode: DesktopServerExposureMode, options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean }, @@ -1669,6 +1695,9 @@ function registerIpcHandlers(): void { return nextState; }); + ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL); + ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => getDesktopAdvertisedEndpoints()); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a6756048725..e918e782ab9 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -24,6 +24,7 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { @@ -53,6 +54,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), + getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL), pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts index c83bbc210e0..86e9d3a6558 100644 --- a/apps/desktop/src/serverExposure.test.ts +++ b/apps/desktop/src/serverExposure.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure.ts"; +import { + resolveDesktopCoreAdvertisedEndpoints, + resolveDesktopServerExposure, + resolveLanAdvertisedHost, +} from "./serverExposure.ts"; describe("resolveLanAdvertisedHost", () => { it("prefers an explicit host override", () => { @@ -74,6 +78,97 @@ describe("resolveLanAdvertisedHost", () => { }); }); +describe("resolveDesktopCoreAdvertisedEndpoints", () => { + it("advertises loopback and LAN endpoints without provider-specific assumptions", () => { + const exposure = resolveDesktopServerExposure({ + mode: "network-accessible", + port: 3773, + networkInterfaces: { + en0: [ + { + address: "192.168.1.44", + family: "IPv4", + internal: false, + netmask: "255.255.255.0", + cidr: "192.168.1.44/24", + mac: "00:00:00:00:00:00", + }, + ], + }, + }); + + expect( + resolveDesktopCoreAdvertisedEndpoints({ + port: 3773, + exposure, + customHttpsEndpointUrls: ["https://desktop.example.ts.net"], + }), + ).toEqual([ + { + id: "desktop-loopback:3773", + label: "This machine", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + reachability: "loopback", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + description: "Loopback endpoint for this desktop app.", + }, + { + id: "desktop-lan:http://192.168.1.44:3773", + label: "Local network", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://192.168.1.44:3773/", + wsBaseUrl: "ws://192.168.1.44:3773/", + reachability: "lan", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }, + { + id: "manual:https://desktop.example.ts.net", + label: "Custom HTTPS", + provider: { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, + }, + httpBaseUrl: "https://desktop.example.ts.net/", + wsBaseUrl: "wss://desktop.example.ts.net/", + reachability: "public", + compatibility: { + hostedHttpsApp: "compatible", + desktopApp: "compatible", + }, + source: "user", + status: "unknown", + description: "User-configured HTTPS endpoint for this desktop backend.", + }, + ]); + }); +}); + describe("resolveDesktopServerExposure", () => { it("keeps the desktop server loopback-only when local-only mode is selected", () => { expect( diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts index 65c99b60e13..fb6b284b9ef 100644 --- a/apps/desktop/src/serverExposure.ts +++ b/apps/desktop/src/serverExposure.ts @@ -1,5 +1,13 @@ import type { NetworkInterfaceInfo } from "node:os"; -import type { DesktopServerExposureMode } from "@t3tools/contracts"; +import { + createAdvertisedEndpoint, + type CreateAdvertisedEndpointInput, +} from "@t3tools/client-runtime"; +import type { + AdvertisedEndpoint, + AdvertisedEndpointProvider, + DesktopServerExposureMode, +} from "@t3tools/contracts"; const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; @@ -13,6 +21,26 @@ export interface DesktopServerExposure { readonly advertisedHost: string | null; } +export interface DesktopAdvertisedEndpointInput { + readonly port: number; + readonly exposure: DesktopServerExposure; + readonly customHttpsEndpointUrls?: readonly string[]; +} + +const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, +}; + +const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, +}; + const normalizeOptionalHost = (value: string | undefined): string | undefined => { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : undefined; @@ -78,3 +106,68 @@ export function resolveDesktopServerExposure(input: { advertisedHost, }; } + +function createDesktopEndpoint( + input: Omit, +): AdvertisedEndpoint { + return createAdvertisedEndpoint({ + ...input, + provider: DESKTOP_CORE_ENDPOINT_PROVIDER, + source: "desktop-core", + }); +} + +function createManualEndpoint( + input: Omit, +): AdvertisedEndpoint { + return createAdvertisedEndpoint({ + ...input, + provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER, + source: "user", + }); +} + +export function resolveDesktopCoreAdvertisedEndpoints( + input: DesktopAdvertisedEndpointInput, +): readonly AdvertisedEndpoint[] { + const endpoints: AdvertisedEndpoint[] = [ + createDesktopEndpoint({ + id: `desktop-loopback:${input.port}`, + label: "This machine", + httpBaseUrl: input.exposure.localHttpUrl, + reachability: "loopback", + status: "available", + description: "Loopback endpoint for this desktop app.", + }), + ]; + + if (input.exposure.endpointUrl) { + endpoints.push( + createDesktopEndpoint({ + id: `desktop-lan:${input.exposure.endpointUrl}`, + label: "Local network", + httpBaseUrl: input.exposure.endpointUrl, + reachability: "lan", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }), + ); + } + + for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { + endpoints.push( + createManualEndpoint({ + id: `manual:${customEndpointUrl}`, + label: "Custom HTTPS", + httpBaseUrl: customEndpointUrl, + reachability: "public", + hostedHttpsCompatibility: "compatible", + status: "unknown", + description: "User-configured HTTPS endpoint for this desktop backend.", + }), + ); + } + + return endpoints; +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index f6ab384d9fb..3328952c7b6 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { type AuthClientSession, type AuthPairingLink, + type AdvertisedEndpoint, type DesktopServerExposureState, type EnvironmentId, } from "@t3tools/contracts"; @@ -244,6 +245,31 @@ function removeDesktopClientSession( return current.filter((clientSession) => clientSession.sessionId !== sessionId); } +function selectPairingEndpoint( + endpoints: ReadonlyArray, +): AdvertisedEndpoint | null { + const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable"); + return ( + availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ?? + availableEndpoints.find((endpoint) => endpoint.isDefault) ?? + availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ?? + null + ); +} + +function resolveAdvertisedEndpointPairingUrl( + endpoint: AdvertisedEndpoint, + credential: string, +): string { + if (endpoint.compatibility.hostedHttpsApp === "compatible") { + return ( + resolveHostedPairingUrl(endpoint.httpBaseUrl, credential) ?? + resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential) + ); + } + return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential); +} + function resolveCurrentOriginPairingUrl(credential: string): string { const url = new URL("/pair", window.location.href); return setPairingTokenOnUrl(url, credential).toString(); @@ -252,6 +278,7 @@ function resolveCurrentOriginPairingUrl(credential: string): string { type PairingLinkListRowProps = { pairingLink: ServerPairingLinkRecord; endpointUrl: string | null | undefined; + endpoints: ReadonlyArray; revokingPairingLinkId: string | null; onRevoke: (id: string) => void; }; @@ -259,6 +286,7 @@ type PairingLinkListRowProps = { const PairingLinkListRow = memo(function PairingLinkListRow({ pairingLink, endpointUrl, + endpoints, revokingPairingLinkId, onRevoke, }: PairingLinkListRowProps) { @@ -280,12 +308,17 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ : null, [endpointUrl, pairingLink.credential], ); + const endpointPairingUrl = useMemo(() => { + const endpoint = selectPairingEndpoint(endpoints); + return endpoint ? resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential) : null; + }, [endpoints, pairingLink.credential]); const shareablePairingUrl = - endpointUrl != null && endpointUrl !== "" + endpointPairingUrl ?? + (endpointUrl != null && endpointUrl !== "" ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)) : isLoopbackHostname(window.location.hostname) ? null - : currentOriginPairingUrl; + : currentOriginPairingUrl); const copyValue = shareablePairingUrl ?? pairingLink.credential; const canCopyToClipboard = typeof window !== "undefined" && @@ -622,6 +655,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio type PairingClientsListProps = { endpointUrl: string | null | undefined; + endpoints: ReadonlyArray; isLoading: boolean; pairingLinks: ReadonlyArray; clientSessions: ReadonlyArray; @@ -633,6 +667,7 @@ type PairingClientsListProps = { const PairingClientsList = memo(function PairingClientsList({ endpointUrl, + endpoints, isLoading, pairingLinks, clientSessions, @@ -648,6 +683,7 @@ const PairingClientsList = memo(function PairingClientsList({ key={pairingLink.id} pairingLink={pairingLink} endpointUrl={endpointUrl} + endpoints={endpoints} revokingPairingLinkId={revokingPairingLinkId} onRevoke={onRevokePairingLink} /> @@ -671,6 +707,58 @@ const PairingClientsList = memo(function PairingClientsList({ ); }); +type AdvertisedEndpointListRowProps = { + endpoint: AdvertisedEndpoint; +}; + +const endpointCompatibilityLabel = (endpoint: AdvertisedEndpoint): string => { + if (endpoint.compatibility.hostedHttpsApp === "compatible") { + return "Works with hosted app"; + } + if (endpoint.compatibility.hostedHttpsApp === "mixed-content-blocked") { + return "Desktop or direct browser only"; + } + if (endpoint.compatibility.hostedHttpsApp === "requires-configuration") { + return "Needs HTTPS setup"; + } + return "Compatibility unknown"; +}; + +const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ + endpoint, +}: AdvertisedEndpointListRowProps) { + return ( +
+
+
+
+ +

{endpoint.label}

+ {endpoint.provider.isAddon ? ( + + Add-on + + ) : null} +
+

+ {endpoint.httpBaseUrl} +

+
+

+ {endpointCompatibilityLabel(endpoint)} +

+
+
+ ); +}); + type SavedBackendListRowProps = { environmentId: EnvironmentId; reconnectingEnvironmentId: EnvironmentId | null; @@ -776,6 +864,9 @@ export function ConnectionsSettings() { const [desktopServerExposureState, setDesktopServerExposureState] = useState(null); + const [desktopAdvertisedEndpoints, setDesktopAdvertisedEndpoints] = useState< + ReadonlyArray + >([]); const [desktopServerExposureError, setDesktopServerExposureError] = useState(null); const [desktopPairingLinks, setDesktopPairingLinks] = useState< ReadonlyArray @@ -1107,8 +1198,21 @@ export function ConnectionsSettings() { error instanceof Error ? error.message : "Failed to load network exposure state."; setDesktopServerExposureError(message); }); + void desktopBridge + .getAdvertisedEndpoints() + .then((endpoints) => { + if (cancelled) return; + setDesktopAdvertisedEndpoints(endpoints); + }) + .catch((error: unknown) => { + if (cancelled) return; + const message = + error instanceof Error ? error.message : "Failed to load reachable endpoints."; + setDesktopServerExposureError(message); + }); } else { setDesktopServerExposureState(null); + setDesktopAdvertisedEndpoints([]); setDesktopServerExposureError(null); } @@ -1125,6 +1229,7 @@ export function ConnectionsSettings() { setDesktopClientSessions([]); setDesktopAccessManagementError(null); setDesktopServerExposureState(null); + setDesktopAdvertisedEndpoints([]); setDesktopServerExposureError(null); }, [canManageLocalBackend]); const visibleDesktopPairingLinks = useMemo( @@ -1137,87 +1242,95 @@ export function ConnectionsSettings() { <> {desktopBridge ? ( - {desktopServerExposureError} - ) : null - } - control={ - { - if (isUpdatingDesktopServerExposure) return; - if (!open) setPendingDesktopServerExposureMode(null); - }} - > - { - setPendingDesktopServerExposureMode( - checked ? "network-accessible" : "local-only", - ); + <> + {desktopServerExposureError} + ) : null + } + control={ + { + if (isUpdatingDesktopServerExposure) return; + if (!open) setPendingDesktopServerExposureMode(null); }} - aria-label="Enable network access" - /> - - - - {pendingDesktopServerExposureMode === "network-accessible" - ? "Enable network access?" - : "Disable network access?"} - - - {pendingDesktopServerExposureMode === "network-accessible" - ? "T3 Code will restart to expose this environment over the network." - : "T3 Code will restart and limit this environment back to this machine."} - - - - - } - > - Cancel - - - - - - } - /> + > + { + setPendingDesktopServerExposureMode( + checked ? "network-accessible" : "local-only", + ); + }} + aria-label="Enable network access" + /> + + + + {pendingDesktopServerExposureMode === "network-accessible" + ? "Enable network access?" + : "Disable network access?"} + + + {pendingDesktopServerExposureMode === "network-accessible" + ? "T3 Code will restart to expose this environment over the network." + : "T3 Code will restart and limit this environment back to this machine."} + + + + + } + > + Cancel + + + + + + } + /> + {desktopAdvertisedEndpoints.map((endpoint) => ( + + ))} + ) : ( >; + readonly advertisedEndpoints?: Awaited>; readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"]; readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"]; }): DesktopBridge => { @@ -309,6 +310,7 @@ const createDesktopBridgeStub = (overrides?: { endpointUrl: mode === "network-accessible" ? "http://192.168.1.44:3773" : null, advertisedHost: mode === "network-accessible" ? "192.168.1.44" : null, })), + getAdvertisedEndpoints: vi.fn().mockResolvedValue(overrides?.advertisedEndpoints ?? []), pickFolder: vi.fn().mockResolvedValue(null), confirm: vi.fn().mockResolvedValue(false), setTheme: vi.fn().mockResolvedValue(undefined), diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index b627286199c..9b46acdb99c 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -183,6 +183,7 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg endpointUrl: null, advertisedHost: null, }), + getAdvertisedEndpoints: async () => [], pickFolder: async () => null, confirm: async () => true, setTheme: async () => undefined, diff --git a/bun.lock b/bun.lock index c7aeb59b76f..e1b9d456398 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "electron-updater": "^6.6.2", }, "devDependencies": { + "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@types/node": "catalog:", diff --git a/packages/client-runtime/src/advertisedEndpoint.test.ts b/packages/client-runtime/src/advertisedEndpoint.test.ts new file mode 100644 index 00000000000..1cbfde87bd3 --- /dev/null +++ b/packages/client-runtime/src/advertisedEndpoint.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { + classifyHostedHttpsCompatibility, + createAdvertisedEndpoint, + deriveWsBaseUrl, + normalizeHttpBaseUrl, +} from "./advertisedEndpoint.ts"; + +const coreProvider = { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, +} as const; + +describe("advertised endpoint helpers", () => { + it("normalizes HTTP and WebSocket base URLs", () => { + expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash")).toBe("https://example.com/"); + expect(normalizeHttpBaseUrl("wss://example.com/socket")).toBe("https://example.com/"); + expect(deriveWsBaseUrl("https://example.com/api")).toBe("wss://example.com/"); + expect(deriveWsBaseUrl("http://127.0.0.1:3773")).toBe("ws://127.0.0.1:3773/"); + }); + + it("marks HTTP endpoints as blocked from hosted HTTPS apps", () => { + expect(classifyHostedHttpsCompatibility("http://192.168.1.44:3773")).toBe( + "mixed-content-blocked", + ); + expect(classifyHostedHttpsCompatibility("https://desktop.example.com", "compatible")).toBe( + "compatible", + ); + }); + + it("creates provider-neutral endpoint records", () => { + expect( + createAdvertisedEndpoint({ + id: "lan:http://192.168.1.44:3773", + label: "LAN", + provider: coreProvider, + httpBaseUrl: "http://192.168.1.44:3773", + reachability: "lan", + source: "desktop-core", + isDefault: true, + }), + ).toEqual({ + id: "lan:http://192.168.1.44:3773", + label: "LAN", + provider: coreProvider, + httpBaseUrl: "http://192.168.1.44:3773/", + wsBaseUrl: "ws://192.168.1.44:3773/", + reachability: "lan", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + isDefault: true, + }); + }); +}); diff --git a/packages/client-runtime/src/advertisedEndpoint.ts b/packages/client-runtime/src/advertisedEndpoint.ts new file mode 100644 index 00000000000..314d8272c81 --- /dev/null +++ b/packages/client-runtime/src/advertisedEndpoint.ts @@ -0,0 +1,78 @@ +import type { + AdvertisedEndpoint, + AdvertisedEndpointHostedHttpsCompatibility, + AdvertisedEndpointProvider, + AdvertisedEndpointReachability, + AdvertisedEndpointSource, + AdvertisedEndpointStatus, +} from "@t3tools/contracts"; + +export interface CreateAdvertisedEndpointInput { + readonly id: string; + readonly label: string; + readonly provider: AdvertisedEndpointProvider; + readonly httpBaseUrl: string; + readonly reachability: AdvertisedEndpointReachability; + readonly hostedHttpsCompatibility?: AdvertisedEndpointHostedHttpsCompatibility; + readonly desktopCompatibility?: "compatible" | "unknown"; + readonly source: AdvertisedEndpointSource; + readonly status?: AdvertisedEndpointStatus; + readonly isDefault?: boolean; + readonly description?: string; +} + +export function normalizeHttpBaseUrl(rawValue: string): string { + const url = new URL(rawValue); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`Endpoint must use HTTP or HTTPS. Received ${url.protocol}`); + } + + url.pathname = "/"; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +export function deriveWsBaseUrl(httpBaseUrl: string): string { + const url = new URL(normalizeHttpBaseUrl(httpBaseUrl)); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +export function classifyHostedHttpsCompatibility( + httpBaseUrl: string, + fallback: AdvertisedEndpointHostedHttpsCompatibility = "unknown", +): AdvertisedEndpointHostedHttpsCompatibility { + const url = new URL(normalizeHttpBaseUrl(httpBaseUrl)); + if (url.protocol === "http:") { + return "mixed-content-blocked"; + } + return fallback === "mixed-content-blocked" ? "unknown" : fallback; +} + +export function createAdvertisedEndpoint(input: CreateAdvertisedEndpointInput): AdvertisedEndpoint { + const httpBaseUrl = normalizeHttpBaseUrl(input.httpBaseUrl); + return { + id: input.id, + label: input.label, + provider: input.provider, + httpBaseUrl, + wsBaseUrl: deriveWsBaseUrl(httpBaseUrl), + reachability: input.reachability, + compatibility: { + hostedHttpsApp: + input.hostedHttpsCompatibility ?? classifyHostedHttpsCompatibility(httpBaseUrl), + desktopApp: input.desktopCompatibility ?? "compatible", + }, + source: input.source, + status: input.status ?? "available", + ...(input.isDefault === undefined ? {} : { isDefault: input.isDefault }), + ...(input.description === undefined ? {} : { description: input.description }), + }; +} diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts index f6f5b82758c..cb7472dff51 100644 --- a/packages/client-runtime/src/index.ts +++ b/packages/client-runtime/src/index.ts @@ -1,3 +1,4 @@ +export * from "./advertisedEndpoint.ts"; export * from "./knownEnvironment.ts"; export * from "./scoped.ts"; export * from "./sourceControlDiscoveryState.ts"; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index a0ccc624a5e..1a3647eb314 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,7 @@ export * from "./baseSchemas.ts"; export * from "./auth.ts"; export * from "./environment.ts"; +export * from "./remoteAccess.ts"; export * from "./ipc.ts"; export * from "./terminal.ts"; export * from "./provider.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 35539a86e33..5826238e942 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -53,6 +53,7 @@ import type { OrchestrationThreadStreamItem, } from "./orchestration.ts"; import type { EnvironmentId } from "./baseSchemas.ts"; +import type { AdvertisedEndpoint } from "./remoteAccess.ts"; import { EditorId } from "./editor.ts"; import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; import type { SourceControlDiscoveryResult } from "./sourceControl.ts"; @@ -162,6 +163,7 @@ export interface DesktopBridge { removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; getServerExposureState: () => Promise; setServerExposureMode: (mode: DesktopServerExposureMode) => Promise; + getAdvertisedEndpoints: () => Promise; pickFolder: (options?: PickFolderOptions) => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; diff --git a/packages/contracts/src/remoteAccess.ts b/packages/contracts/src/remoteAccess.ts new file mode 100644 index 00000000000..70a20d7aeb4 --- /dev/null +++ b/packages/contracts/src/remoteAccess.ts @@ -0,0 +1,68 @@ +import { Schema } from "effect"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const AdvertisedEndpointProviderKind = Schema.Literals([ + "core", + "private-network", + "tunnel", + "manual", +]); +export type AdvertisedEndpointProviderKind = typeof AdvertisedEndpointProviderKind.Type; + +export const AdvertisedEndpointReachability = Schema.Literals([ + "loopback", + "lan", + "private-network", + "public", +]); +export type AdvertisedEndpointReachability = typeof AdvertisedEndpointReachability.Type; + +export const AdvertisedEndpointHostedHttpsCompatibility = Schema.Literals([ + "compatible", + "mixed-content-blocked", + "requires-configuration", + "unknown", +]); +export type AdvertisedEndpointHostedHttpsCompatibility = + typeof AdvertisedEndpointHostedHttpsCompatibility.Type; + +export const AdvertisedEndpointStatus = Schema.Literals(["available", "unavailable", "unknown"]); +export type AdvertisedEndpointStatus = typeof AdvertisedEndpointStatus.Type; + +export const AdvertisedEndpointSource = Schema.Literals([ + "desktop-core", + "desktop-addon", + "server", + "user", +]); +export type AdvertisedEndpointSource = typeof AdvertisedEndpointSource.Type; + +export const AdvertisedEndpointProvider = Schema.Struct({ + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + kind: AdvertisedEndpointProviderKind, + isAddon: Schema.Boolean, +}); +export type AdvertisedEndpointProvider = typeof AdvertisedEndpointProvider.Type; + +export const AdvertisedEndpointCompatibility = Schema.Struct({ + hostedHttpsApp: AdvertisedEndpointHostedHttpsCompatibility, + desktopApp: Schema.Literals(["compatible", "unknown"]), +}); +export type AdvertisedEndpointCompatibility = typeof AdvertisedEndpointCompatibility.Type; + +export const AdvertisedEndpoint = Schema.Struct({ + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + provider: AdvertisedEndpointProvider, + httpBaseUrl: TrimmedNonEmptyString, + wsBaseUrl: TrimmedNonEmptyString, + reachability: AdvertisedEndpointReachability, + compatibility: AdvertisedEndpointCompatibility, + source: AdvertisedEndpointSource, + status: AdvertisedEndpointStatus, + isDefault: Schema.optional(Schema.Boolean), + description: Schema.optional(TrimmedNonEmptyString), +}); +export type AdvertisedEndpoint = typeof AdvertisedEndpoint.Type; From 19ef98837077c6ecf67548d29648ac9f2a6f2b75 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 19:55:58 -0700 Subject: [PATCH 06/74] docs(remote): describe advertised endpoint selection Co-authored-by: codex --- .docs/remote-architecture.md | 22 ++++++++++++++++++++++ REMOTE.md | 10 +++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md index 8e5ed37928e..ecaf45de1a7 100644 --- a/.docs/remote-architecture.md +++ b/.docs/remote-architecture.md @@ -110,6 +110,28 @@ A single environment may have many endpoints: The environment stays the same. Only the access path changes. +### AdvertisedEndpoint + +An `AdvertisedEndpoint` is a server or desktop-authored candidate endpoint for an environment. It is how the backend tells the client which URLs may be useful for pairing and reconnecting. + +`AdvertisedEndpoint` is deliberately narrower than the full access model: + +- it describes a concrete HTTP and WebSocket base URL pair +- it can mark the endpoint as default, available, or unavailable +- it includes reachability hints such as loopback, LAN, private, public, or tunnel +- it includes compatibility hints such as whether the endpoint can be used from the hosted HTTPS app + +Clients should treat advertised endpoints as hints, not as proof that a route works from the current device. The final connection attempt still decides whether the endpoint is reachable. + +Endpoint selection should prefer: + +1. endpoints compatible with the hosted HTTPS app +2. explicitly default endpoints +3. non-loopback endpoints +4. loopback endpoints only for same-machine clients + +This keeps endpoint discovery centralized without making any one provider, such as Tailscale or a future tunnel service, part of the core environment model. + ### Hosted pairing request A hosted pairing request is a bootstrap URL for the static web app, not a transport. diff --git a/REMOTE.md b/REMOTE.md index cec7b0ecccb..f2e9bd9aefc 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -22,9 +22,17 @@ If you are already running the desktop app and want to make it reachable from ot 1. Open **Settings** → **Connections**. 2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces. -3. The settings panel will show the address the server is reachable at (e.g. `http://192.168.x.y:3773`). +3. The settings panel will show reachable endpoints for the backend. At minimum this includes the local LAN HTTP endpoint when network access is enabled. 4. Use **Create Link** to generate a pairing link you can share with another device. +The app chooses the best reachable endpoint for pairing links: + +- HTTPS/WSS-compatible endpoints are preferred because they work from `https://app.t3.codes`. +- Non-loopback HTTP endpoints are used for direct LAN pairing when HTTPS is not available. +- Loopback-only endpoints are not useful for another device unless that device is the same machine. + +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. + ### Option 2: Headless Server (CLI) Use this when you want to run the server without a GUI, for example on a remote machine over SSH. From 6c4db9a7f607862361c290c97cda12aa775ba431 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 20:21:31 -0700 Subject: [PATCH 07/74] fix(web): hide endpoint rows when network access is disabled Co-authored-by: codex --- .../settings/ConnectionsSettings.tsx | 7 +- .../settings/SettingsPanels.browser.tsx | 70 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 3328952c7b6..9326bdd1faf 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1236,6 +1236,9 @@ export function ConnectionsSettings() { () => desktopPairingLinks.filter((pairingLink) => pairingLink.role === "client"), [desktopPairingLinks], ); + const visibleDesktopAdvertisedEndpoints = isLocalBackendNetworkAccessible + ? desktopAdvertisedEndpoints + : []; return ( {canManageLocalBackend ? ( @@ -1327,7 +1330,7 @@ export function ConnectionsSettings() { } /> - {desktopAdvertisedEndpoints.map((endpoint) => ( + {visibleDesktopAdvertisedEndpoints.map((endpoint) => ( ))} @@ -1380,7 +1383,7 @@ export function ConnectionsSettings() { ) : null} { .toBeInTheDocument(); }); + it("hides advertised endpoint rows when desktop network access is disabled", async () => { + window.desktopBridge = createDesktopBridgeStub({ + serverExposureState: { + mode: "local-only", + endpointUrl: null, + advertisedHost: null, + }, + advertisedEndpoints: [ + { + id: "loopback", + label: "This machine", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "manual", + isAddon: false, + }, + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + reachability: "loopback", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + isDefault: true, + }, + { + id: "tailscale-ip", + label: "Tailscale IP", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "http://100.105.39.17:3773/", + wsBaseUrl: "ws://100.105.39.17:3773/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "available", + }, + ], + }); + authAccessHarness.setSnapshot({ + pairingLinks: [], + clientSessions: [], + }); + setServerConfigSnapshot(createBaseServerConfig()); + + mounted = await render( + + + , + ); + + await expect.element(page.getByText("Limited to this machine.")).toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "This machine", exact: true })) + .not.toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "Tailscale IP", exact: true })) + .not.toBeInTheDocument(); + }); + it("shows diagnostics inside About with a single logs-folder action", async () => { setServerConfigSnapshot(createBaseServerConfig()); From e11c88db7f21dc633e2e4b07b38dd6366620ae38 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 21:06:01 -0700 Subject: [PATCH 08/74] feat(web): make advertised endpoint defaults explicit Co-authored-by: codex --- .docs/remote-architecture.md | 6 +- REMOTE.md | 6 +- .../settings/ConnectionsSettings.tsx | 337 ++++++++++++++---- .../settings/SettingsPanels.browser.tsx | 102 +++++- .../components/settings/settingsLayout.tsx | 2 +- apps/web/src/uiStateStore.test.ts | 25 ++ apps/web/src/uiStateStore.ts | 28 +- 7 files changed, 424 insertions(+), 82 deletions(-) diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md index ecaf45de1a7..1748df5db65 100644 --- a/.docs/remote-architecture.md +++ b/.docs/remote-architecture.md @@ -123,7 +123,11 @@ An `AdvertisedEndpoint` is a server or desktop-authored candidate endpoint for a Clients should treat advertised endpoints as hints, not as proof that a route works from the current device. The final connection attempt still decides whether the endpoint is reachable. -Endpoint selection should prefer: +The UI presents one default advertised endpoint in the network-access summary and keeps the rest behind an expandable advanced list. The default controls pairing QR codes and primary copy actions. Users can override it, but that override is a UI preference, not backend configuration. + +Persist the override by stable endpoint kind rather than raw URL whenever possible. For example, a LAN endpoint should be stored as the desktop LAN endpoint preference, not as `192.168.x.y`, because the address can change when the user switches networks. Provider endpoints should use provider-specific stable keys such as Tailscale IP or Tailscale MagicDNS HTTPS. Custom endpoints may fall back to their concrete identity. + +When no user default is saved, endpoint selection should prefer: 1. endpoints compatible with the hosted HTTPS app 2. explicitly default endpoints diff --git a/REMOTE.md b/REMOTE.md index f2e9bd9aefc..f5ddccaa85a 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -22,10 +22,12 @@ If you are already running the desktop app and want to make it reachable from ot 1. Open **Settings** → **Connections**. 2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces. -3. The settings panel will show reachable endpoints for the backend. At minimum this includes the local LAN HTTP endpoint when network access is enabled. +3. The settings panel will show the default reachable endpoint, with a `+N` control when more endpoints are available. Expand it to inspect alternatives such as loopback, LAN, private-network, or HTTPS endpoints. 4. Use **Create Link** to generate a pairing link you can share with another device. -The app chooses the best reachable endpoint for pairing links: +The default endpoint controls the QR code and primary copy action for pairing links. You can change it from the expanded endpoint list. The preference is stored by endpoint type, so choosing the local LAN endpoint survives normal IP address changes when you move between networks. + +When no user default is saved, the app chooses the best reachable endpoint for pairing links: - HTTPS/WSS-compatible endpoints are preferred because they work from `https://app.t3.codes`. - Non-loopback HTTP endpoints are used for direct LAN pairing when HTTPS is not available. diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 9326bdd1faf..31253781930 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,5 +1,5 @@ -import { PlusIcon, QrCodeIcon } from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronDownIcon, PlusIcon, QrCodeIcon } from "lucide-react"; +import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; import { type AuthClientSession, type AuthPairingLink, @@ -46,6 +46,8 @@ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; +import { Group, GroupSeparator } from "../ui/group"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Textarea } from "../ui/textarea"; import { setPairingTokenOnUrl } from "../../pairingUrl"; import { @@ -69,6 +71,7 @@ import { reconnectSavedEnvironment, removeSavedEnvironment, } from "~/environments/runtime"; +import { useUiStateStore } from "~/uiStateStore"; const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", @@ -164,6 +167,7 @@ function getSavedBackendStatusTooltip( /** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; +const ENDPOINT_ROW_CLASSNAME = "border-t border-border/60 px-4 py-2.5 first:border-t-0 sm:px-5"; const ITEM_ROW_INNER_CLASSNAME = "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; @@ -247,8 +251,17 @@ function removeDesktopClientSession( function selectPairingEndpoint( endpoints: ReadonlyArray, + defaultEndpointKey?: string | null, ): AdvertisedEndpoint | null { const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable"); + if (defaultEndpointKey) { + const selectedEndpoint = availableEndpoints.find( + (endpoint) => endpointDefaultPreferenceKey(endpoint) === defaultEndpointKey, + ); + if (selectedEndpoint) { + return selectedEndpoint; + } + } return ( availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ?? availableEndpoints.find((endpoint) => endpoint.isDefault) ?? @@ -257,6 +270,30 @@ function selectPairingEndpoint( ); } +function endpointDefaultPreferenceKey(endpoint: AdvertisedEndpoint): string { + if (endpoint.id.startsWith("desktop-loopback:")) { + return "desktop-core:loopback:http"; + } + if (endpoint.id.startsWith("desktop-lan:")) { + return "desktop-core:lan:http"; + } + if (endpoint.id.startsWith("tailscale-ip:")) { + return "tailscale:ip:http"; + } + if (endpoint.id.startsWith("tailscale-magicdns:")) { + return "tailscale:magicdns:https"; + } + + let scheme = "unknown"; + try { + scheme = new URL(endpoint.httpBaseUrl).protocol.replace(/:$/u, ""); + } catch { + // Keep the stored preference stable even if a custom endpoint is malformed. + } + + return `${endpoint.provider.id}:${endpoint.reachability}:${scheme}:${endpoint.label}`; +} + function resolveAdvertisedEndpointPairingUrl( endpoint: AdvertisedEndpoint, credential: string, @@ -279,6 +316,7 @@ type PairingLinkListRowProps = { pairingLink: ServerPairingLinkRecord; endpointUrl: string | null | undefined; endpoints: ReadonlyArray; + defaultEndpointKey: string | null; revokingPairingLinkId: string | null; onRevoke: (id: string) => void; }; @@ -287,6 +325,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ pairingLink, endpointUrl, endpoints, + defaultEndpointKey, revokingPairingLinkId, onRevoke, }: PairingLinkListRowProps) { @@ -309,9 +348,20 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ [endpointUrl, pairingLink.credential], ); const endpointPairingUrl = useMemo(() => { - const endpoint = selectPairingEndpoint(endpoints); + const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey); return endpoint ? resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential) : null; - }, [endpoints, pairingLink.credential]); + }, [defaultEndpointKey, endpoints, pairingLink.credential]); + const endpointCopyOptions = useMemo( + () => + endpoints + .filter((endpoint) => endpoint.status !== "unavailable") + .map((endpoint) => ({ + key: endpointDefaultPreferenceKey(endpoint), + label: endpoint.label, + url: resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential), + })), + [endpoints, pairingLink.credential], + ); const shareablePairingUrl = endpointPairingUrl ?? (endpointUrl != null && endpointUrl !== "" @@ -319,37 +369,54 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ : isLoopbackHostname(window.location.hostname) ? null : currentOriginPairingUrl); - const copyValue = shareablePairingUrl ?? pairingLink.credential; + const revealValue = shareablePairingUrl ?? pairingLink.credential; const canCopyToClipboard = typeof window !== "undefined" && window.isSecureContext && navigator.clipboard?.writeText != null; - const { copyToClipboard, isCopied } = useCopyToClipboard({ - onCopy: () => { + const { copyToClipboard } = useCopyToClipboard<"code" | "link">({ + onCopy: (kind) => { toastManager.add({ type: "success", - title: shareablePairingUrl ? "Pairing URL copied" : "Pairing token copied", - description: shareablePairingUrl - ? "Open it in the client you want to pair to this environment." - : "Paste it into another client with this backend's reachable host.", + title: kind === "link" ? "Pairing URL copied" : "Pairing code copied", + description: + kind === "link" + ? "Open it in the client you want to pair to this environment." + : "Paste it into another client to finish pairing.", }); }, - onError: (error) => { + onError: (error, kind) => { setIsRevealDialogOpen(true); toastManager.add( stackedThreadToast({ type: "error", - title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable", + title: canCopyToClipboard + ? kind === "link" + ? "Could not copy pairing URL" + : "Could not copy pairing code" + : "Clipboard copy unavailable", description: canCopyToClipboard ? error.message : "Showing the full value instead.", }), ); }, }); - const handleCopy = useCallback(() => { - copyToClipboard(copyValue, undefined); - }, [copyToClipboard, copyValue]); + const copyPairingValue = useCallback( + (value: string, kind: "code" | "link") => { + copyToClipboard(value, kind); + }, + [copyToClipboard], + ); + + const handleCopyCode = useCallback(() => { + copyPairingValue(pairingLink.credential, "code"); + }, [copyPairingValue, pairingLink.credential]); + + const handleCopyDefaultLink = useCallback(() => { + if (!shareablePairingUrl) return; + copyPairingValue(shareablePairingUrl, "link"); + }, [copyPairingValue, shareablePairingUrl]); const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); @@ -412,27 +479,71 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
{canCopyToClipboard ? ( - + <> + + {shareablePairingUrl ? ( + endpointCopyOptions.length > 1 ? ( + + + + + + } + > + + + + {endpointCopyOptions.map((option) => ( + copyPairingValue(option.url, "link")} + > + + {option.label} + + Pairing URL + + + + ))} + + + + ) : ( + + ) + ) : null} + ) : ( }> - {shareablePairingUrl ? "Show link" : "Show token"} + {shareablePairingUrl ? "Show link" : "Show code"} )} - {shareablePairingUrl ? "Pairing link" : "Pairing token"} + {shareablePairingUrl ? "Pairing link" : "Pairing code"} {shareablePairingUrl ? "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." - : "Clipboard copy is unavailable here. Manually copy this token and pair from another client using this backend's reachable host."} + : "Clipboard copy is unavailable here. Manually copy this code into another client."}