From 92fcad4a5e93a721a285d33bd22a2125626d5615 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 19:13:05 -0700 Subject: [PATCH 01/23] 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 165395507ef..316228fd1a3 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 => { @@ -307,6 +308,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 c361cbd7870..de5f057875e 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -179,6 +179,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 a8dc482f464..f405bba68ef 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 9ca76328a8e..2a1f15ae0eb 100644 --- a/packages/client-runtime/src/index.ts +++ b/packages/client-runtime/src/index.ts @@ -1,2 +1,3 @@ +export * from "./advertisedEndpoint.ts"; export * from "./knownEnvironment.ts"; export * from "./scoped.ts"; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 47081d8df1b..c86b7a2f6cb 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 a1abc0fa4a0..1aeaf02a45b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -52,6 +52,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"; @@ -160,6 +161,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 2d4d014d2076ecd49641818976e6e4fea52348fb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 19:55:58 -0700 Subject: [PATCH 02/23] 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 973e9e76c20b437fbe5c1c4c8c3fe05a0de7abbb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 20:21:31 -0700 Subject: [PATCH 03/23] 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 3009dd05bb3f48140bdbdd165385ec691bd42930 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 21:06:01 -0700 Subject: [PATCH 04/23] 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."}