diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts index 43f634c4491..5b7e9dea220 100644 --- a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts @@ -20,6 +20,13 @@ export type NetworkInterfaces = Readonly< Record >; +// Some Node builds report the address family as the number 4/6 instead of +// "IPv4"/"IPv6" (see DesktopBackendConfiguration.isLocalHostIpv4). +export const isIpv4Family = (family: string | number): boolean => { + const normalized = String(family); + return normalized === "IPv4" || normalized === "4"; +}; + export class DesktopNetworkInterfacesReadError extends Schema.TaggedErrorClass()( "DesktopNetworkInterfacesReadError", { diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d..c98c9aebaa5 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -38,6 +38,16 @@ const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { ], }; +const multiNicNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { + wg0: [{ address: "10.8.0.5", family: "IPv4", internal: false }], + en0: [ + { address: "192.168.1.20", family: "IPv4", internal: false }, + { address: "fe80::1", family: "IPv6", internal: false }, + ], + en1: [{ address: "169.254.10.10", family: "IPv4", internal: false }], + lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }], +}; + function mockSpawnerLayer(statusJson = "{}") { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -468,4 +478,153 @@ describe("DesktopServerExposure", () => { }, ), ); + + it.effect("advertises one LAN endpoint per usable interface", () => + withHarness( + multiNicNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const change = yield* serverExposure.setMode("network-accessible"); + assert.equal(change.state.advertisedHost, "10.8.0.5"); + assert.equal(change.state.endpointUrl, "http://10.8.0.5:4173"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://10.8.0.5:4173/", "http://192.168.1.20:4173/"], + ); + + const wgEndpoint = endpoints.find( + (endpoint) => endpoint.httpBaseUrl === "http://10.8.0.5:4173/", + ); + const enEndpoint = endpoints.find( + (endpoint) => endpoint.httpBaseUrl === "http://192.168.1.20:4173/", + ); + assert.equal(wgEndpoint?.label, "Local network (wg0)"); + assert.equal(enEndpoint?.label, "Local network (en0)"); + assert.equal(wgEndpoint?.isDefault, true); + assert.isTrue(enEndpoint !== undefined && !("isDefault" in enEndpoint)); + }), + ), + ); + + it.effect("accepts numeric IPv4 family values", () => + withHarness( + { eth0: [{ address: "192.168.7.7", family: 4, internal: false }] }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const change = yield* serverExposure.setMode("network-accessible"); + assert.equal(change.state.advertisedHost, "192.168.7.7"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://192.168.7.7:4173/"], + ); + }), + ), + ); + + it.effect("stays network-accessible on tailnet-only machines without duplicate endpoints", () => + withHarness( + tailnetNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const change = yield* serverExposure.setMode("network-accessible"); + assert.equal(change.state.advertisedHost, "100.90.1.2"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + const matches = endpoints.filter( + (endpoint) => endpoint.httpBaseUrl === "http://100.90.1.2:4173/", + ); + assert.equal(matches.length, 1); + assert.isTrue(matches[0]!.id.startsWith("tailscale-ip:")); + // The dropped LAN duplicate carried the default marker; it must move + // to the surviving Tailscale entry instead of disappearing. + assert.equal(matches[0]!.isDefault, true); + }), + ), + ); + + it.effect("dedupes tailnet addresses reported with numeric family values", () => + withHarness( + { tailscale0: [{ address: "100.90.1.2", family: 4, internal: false }] }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setMode("network-accessible"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + const matches = endpoints.filter( + (endpoint) => endpoint.httpBaseUrl === "http://100.90.1.2:4173/", + ); + assert.equal(matches.length, 1); + assert.isTrue(matches[0]!.id.startsWith("tailscale-ip:")); + assert.equal(matches[0]!.isDefault, true); + }), + ), + ); + + it.effect("dedupes an address reported by multiple interfaces", () => + withHarness( + { + en0: [{ address: "192.168.1.20", family: "IPv4", internal: false }], + en1: [{ address: "192.168.1.20", family: "IPv4", internal: false }], + }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setMode("network-accessible"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/"], + ); + assert.equal(endpoints[1]!.label, "Local network"); + }), + ), + ); + + it.effect("reflects interface changes without reconfiguring", () => { + const mutableInterfaces: { + [name: string]: readonly DesktopNetworkInterfaces.DesktopNetworkInterfaceInfo[]; + } = { + en0: [{ address: "192.168.1.20", family: "IPv4", internal: false }], + }; + return withHarness( + mutableInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setMode("network-accessible"); + + const initial = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + initial.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/"], + ); + + mutableInterfaces["wg0"] = [{ address: "10.8.0.5", family: "IPv4", internal: false }]; + const afterConnect = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + afterConnect.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://10.8.0.5:4173/"], + ); + + delete mutableInterfaces["wg0"]; + const afterDisconnect = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + afterDisconnect.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/"], + ); + }), + ); + }); }); diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f04d2af7b1f..b09c7963175 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -9,7 +9,7 @@ import { type DesktopServerExposureMode, type DesktopServerExposureState, } from "@t3tools/contracts"; -import { readTailscaleStatus } from "@t3tools/tailscale"; +import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -42,6 +42,7 @@ interface ResolvedDesktopServerExposure { interface DesktopAdvertisedEndpointInput { readonly port: number; readonly exposure: ResolvedDesktopServerExposure; + readonly advertisedLanHosts: readonly LanAdvertisedHost[]; readonly customHttpsEndpointUrls?: readonly string[]; } @@ -67,6 +68,11 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined => const isUsableLanIpv4Address = (address: string): boolean => !address.startsWith("127.") && !address.startsWith("169.254."); +interface LanAdvertisedHost { + readonly address: string; + readonly interfaceName: string | null; +} + const isHttpsEndpointUrl = (value: string): boolean => { try { return new URL(value).protocol === "https:"; @@ -75,27 +81,39 @@ const isHttpsEndpointUrl = (value: string): boolean => { } }; -const resolveLanAdvertisedHost = ( +const resolveLanAdvertisedHosts = ( networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, explicitHost: string | undefined, -): string | null => { +): readonly LanAdvertisedHost[] => { const normalizedExplicitHost = normalizeOptionalHost(explicitHost); if (normalizedExplicitHost) { - return normalizedExplicitHost; + return [{ address: normalizedExplicitHost, interfaceName: null }]; } - for (const interfaceAddresses of Object.values(networkInterfaces)) { + const seenAddresses = new Set(); + const lanHosts: LanAdvertisedHost[] = []; + const tailnetHosts: LanAdvertisedHost[] = []; + + for (const [interfaceName, interfaceAddresses] of Object.entries(networkInterfaces)) { if (!interfaceAddresses) continue; for (const address of interfaceAddresses) { if (address.internal) continue; - if (address.family !== "IPv4") continue; + if (!DesktopNetworkInterfaces.isIpv4Family(address.family)) continue; if (!isUsableLanIpv4Address(address.address)) continue; - return address.address; + if (seenAddresses.has(address.address)) continue; + seenAddresses.add(address.address); + + const host: LanAdvertisedHost = { address: address.address, interfaceName }; + if (isTailscaleIpv4Address(address.address)) { + tailnetHosts.push(host); + } else { + lanHosts.push(host); + } } } - return null; + return [...lanHosts, ...tailnetHosts]; }; const resolveDesktopServerExposure = (input: { @@ -118,10 +136,11 @@ const resolveDesktopServerExposure = (input: { }; } - const advertisedHost = resolveLanAdvertisedHost( + const advertisedLanHosts = resolveLanAdvertisedHosts( input.networkInterfaces, input.advertisedHostOverride, ); + const advertisedHost = advertisedLanHosts[0]?.address ?? null; return { mode: input.mode, @@ -165,19 +184,30 @@ const resolveDesktopCoreAdvertisedEndpoints = ( }), ]; - if (input.exposure.endpointUrl) { + const advertisedLanHosts = input.advertisedLanHosts; + // Tailnet addresses are replaced by the Tailscale provider's own entries + // below, so only the remaining hosts count toward needing disambiguation. + const namedLanHostCount = advertisedLanHosts.filter( + (host) => !isTailscaleIpv4Address(host.address), + ).length; + advertisedLanHosts.forEach((host, index) => { + const httpBaseUrl = `http://${host.address}:${input.port}`; + const label = + namedLanHostCount >= 2 && host.interfaceName !== null + ? `Local network (${host.interfaceName})` + : "Local network"; endpoints.push( createDesktopEndpoint({ - id: `desktop-lan:${input.exposure.endpointUrl}`, - label: "Local network", - httpBaseUrl: input.exposure.endpointUrl, + id: `desktop-lan:${httpBaseUrl}`, + label, + httpBaseUrl, reachability: "lan", status: "available", - isDefault: true, + ...(index === 0 ? { isDefault: true } : {}), description: "Reachable from devices on the same network.", }), ); - } + }); for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { try { @@ -524,9 +554,21 @@ export const make = Effect.gen(function* () { const getAdvertisedEndpoints = Effect.gen(function* () { const state = yield* Ref.get(stateRef); const currentNetworkInterfaces = yield* readNetworkInterfaces; + // Re-resolve LAN hosts on every read so interfaces that appear or vanish + // after startup (VPNs connecting/disconnecting, hot-plugged NICs) stay + // accurate. The backend binds 0.0.0.0 in network-accessible mode, so any + // currently-present address is reachable. + const advertisedLanHosts = + state.mode === "network-accessible" + ? resolveLanAdvertisedHosts( + currentNetworkInterfaces, + Option.getOrUndefined(config.desktopLanHostOverride), + ) + : []; const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ port: state.port, exposure: toResolvedExposure(state), + advertisedLanHosts, customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, }); @@ -547,7 +589,23 @@ export const make = Effect.gen(function* () { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.provideService(HttpClient.HttpClient, httpClient), ); - return [...coreEndpoints, ...tailscaleEndpoints]; + // A tailnet address surfaces through the Tailscale provider's richer + // entry; drop the plain LAN duplicate but keep the default marker on + // whichever entry survives. + const tailscaleUrls = new Set(tailscaleEndpoints.map((endpoint) => endpoint.httpBaseUrl)); + const isDuplicateLanEndpoint = (endpoint: AdvertisedEndpoint): boolean => + endpoint.id.startsWith("desktop-lan:") && tailscaleUrls.has(endpoint.httpBaseUrl); + const droppedDefault = coreEndpoints.find( + (endpoint) => isDuplicateLanEndpoint(endpoint) && endpoint.isDefault === true, + ); + return [ + ...coreEndpoints.filter((endpoint) => !isDuplicateLanEndpoint(endpoint)), + ...tailscaleEndpoints.map((endpoint) => + endpoint.httpBaseUrl === droppedDefault?.httpBaseUrl + ? { ...endpoint, isDefault: true } + : endpoint, + ), + ]; }).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints")); return DesktopServerExposure.of({ diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 0b48adc308c..25f2e313ce6 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -12,7 +12,7 @@ import * as Option from "effect/Option"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts"; +import { isIpv4Family, type NetworkInterfaces } from "./DesktopNetworkInterfaces.ts"; export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; @@ -35,7 +35,7 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { for (const address of interfaceAddresses) { if (address.internal) continue; - if (address.family !== "IPv4") continue; + if (!isIpv4Family(address.family)) continue; if (!isTailscaleIpv4Address(address.address)) continue; if (seen.has(address.address)) continue; seen.add(address.address); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index c8f039e6428..709545cb663 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -1,6 +1,59 @@ -import type { DesktopWslState } from "@t3tools/contracts"; +import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + endpointDefaultPreferenceKey, + selectPairingEndpoint, +} from "./ConnectionsSettings.logic"; + +const DESKTOP_CORE_PROVIDER: AdvertisedEndpoint["provider"] = { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, +}; + +const COMPATIBLE_COMPATIBILITY: AdvertisedEndpoint["compatibility"] = { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", +}; + +function makeLanEndpoint(input: { + readonly address: string; + readonly port: number; + readonly isDefault?: boolean; +}): AdvertisedEndpoint { + const httpBaseUrl = `http://${input.address}:${input.port}/`; + return { + id: `desktop-lan:http://${input.address}:${input.port}`, + label: "Local network", + provider: DESKTOP_CORE_PROVIDER, + httpBaseUrl, + wsBaseUrl: `ws://${input.address}:${input.port}/`, + reachability: "lan", + compatibility: COMPATIBLE_COMPATIBILITY, + source: "desktop-core", + status: "available", + ...(input.isDefault ? { isDefault: true } : {}), + description: "Reachable from devices on the same network.", + }; +} + +function makeLoopbackEndpoint(port: number): AdvertisedEndpoint { + const httpBaseUrl = `http://127.0.0.1:${port}/`; + return { + id: `desktop-loopback:${port}`, + label: "This machine", + provider: DESKTOP_CORE_PROVIDER, + httpBaseUrl, + wsBaseUrl: `ws://127.0.0.1:${port}/`, + reachability: "loopback", + compatibility: COMPATIBLE_COMPATIBILITY, + source: "desktop-core", + status: "available", + description: "Loopback endpoint for this desktop app.", + }; +} const baseWslState: DesktopWslState = { enabled: false, @@ -73,3 +126,76 @@ describe("applyWslEnableSelection", () => { expect(state).toMatchObject({ enabled: true, wslOnly: true }); }); }); + +describe("endpointDefaultPreferenceKey", () => { + it("gives distinct desktop-lan endpoints on different hosts distinct preference keys", () => { + const first = makeLanEndpoint({ address: "10.8.0.5", port: 4173 }); + const second = makeLanEndpoint({ address: "192.168.1.20", port: 4173 }); + + expect(endpointDefaultPreferenceKey(first)).toBe("desktop-core:lan:http:10.8.0.5:4173"); + expect(endpointDefaultPreferenceKey(second)).toBe("desktop-core:lan:http:192.168.1.20:4173"); + expect(endpointDefaultPreferenceKey(first)).not.toBe(endpointDefaultPreferenceKey(second)); + }); + + it("keeps the loopback preference key stable", () => { + expect(endpointDefaultPreferenceKey(makeLoopbackEndpoint(4173))).toBe( + "desktop-core:loopback:http", + ); + }); + + it("distinguishes manual endpoints that share a label by host", () => { + const makeManualEndpoint = (url: string): AdvertisedEndpoint => ({ + id: `manual:${url}`, + label: "Custom HTTPS", + provider: { id: "manual", label: "Manual", kind: "manual", isAddon: false }, + httpBaseUrl: `${url}/`, + wsBaseUrl: `${url.replace("https:", "wss:")}/`, + reachability: "public", + compatibility: { hostedHttpsApp: "compatible", desktopApp: "compatible" }, + source: "user", + status: "unknown", + description: "User-configured HTTPS endpoint for this desktop backend.", + }); + const first = makeManualEndpoint("https://one.example.test"); + const second = makeManualEndpoint("https://two.example.test"); + + expect(endpointDefaultPreferenceKey(first)).not.toBe(endpointDefaultPreferenceKey(second)); + }); + + it("embeds the host in tailscale-ip preference keys", () => { + const endpoint: AdvertisedEndpoint = { + id: "tailscale-ip:http://100.90.1.2:4173", + label: "Tailscale IP", + provider: { id: "tailscale", label: "Tailscale", kind: "private-network", isAddon: true }, + httpBaseUrl: "http://100.90.1.2:4173/", + wsBaseUrl: "ws://100.90.1.2:4173/", + reachability: "private-network", + compatibility: COMPATIBLE_COMPATIBILITY, + source: "desktop-addon", + status: "available", + description: "Reachable from devices on the same Tailnet.", + }; + + expect(endpointDefaultPreferenceKey(endpoint)).toBe("tailscale:ip:http:100.90.1.2:4173"); + }); +}); + +describe("selectPairingEndpoint", () => { + it("selects the endpoint matching a stored preference key for the second LAN address", () => { + const first = makeLanEndpoint({ address: "10.8.0.5", port: 4173, isDefault: true }); + const second = makeLanEndpoint({ address: "192.168.1.20", port: 4173 }); + + const selected = selectPairingEndpoint([first, second], endpointDefaultPreferenceKey(second)); + + expect(selected).toBe(second); + }); + + it("falls back to the default endpoint when a legacy preference key matches nothing", () => { + const first = makeLanEndpoint({ address: "10.8.0.5", port: 4173, isDefault: true }); + const second = makeLanEndpoint({ address: "192.168.1.20", port: 4173 }); + + const selected = selectPairingEndpoint([first, second], "desktop-core:lan:http"); + + expect(selected).toBe(first); + }); +}); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index 362a24dd3ff..6b292b0ce6b 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -1,4 +1,4 @@ -import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts"; +import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts"; type WslEnableBridge = Pick; @@ -19,3 +19,60 @@ export async function applyWslEnableSelection(input: { } return await bridge.setWslBackendEnabled(true); } + +function endpointHostKey(endpoint: AdvertisedEndpoint): string { + try { + return new URL(endpoint.httpBaseUrl).host; + } catch { + return endpoint.httpBaseUrl; + } +} + +export 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.isDefault) ?? + availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ?? + availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ?? + null + ); +} + +export function isTailscaleHttpsEndpoint(endpoint: AdvertisedEndpoint): boolean { + return endpoint.id.startsWith("tailscale-magicdns:"); +} + +export 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:${endpointHostKey(endpoint)}`; + } + if (endpoint.id.startsWith("tailscale-ip:")) { + return `tailscale:ip:http:${endpointHostKey(endpoint)}`; + } + if (isTailscaleHttpsEndpoint(endpoint)) { + 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}:${endpointHostKey(endpoint)}`; +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 640412e4a6d..4969892e84d 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -42,7 +42,12 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + endpointDefaultPreferenceKey, + isTailscaleHttpsEndpoint, + selectPairingEndpoint, +} from "./ConnectionsSettings.logic"; import { SettingsPageContainer, SettingsRow, @@ -430,55 +435,6 @@ function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerC }; } -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.isDefault) ?? - availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ?? - availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ?? - null - ); -} - -function isTailscaleHttpsEndpoint(endpoint: AdvertisedEndpoint): boolean { - return endpoint.id.startsWith("tailscale-magicdns:"); -} - -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 (isTailscaleHttpsEndpoint(endpoint)) { - 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,