From b9df34f6b056a6e5aeb6a778bfe2c60ec9c02dba Mon Sep 17 00:00:00 2001 From: PJalv <134026493+PJalv@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:47:48 -0700 Subject: [PATCH 1/3] feat(desktop): choose the network interface used for LAN pairing URLs --- .../DesktopBackendConfiguration.test.ts | 1 + .../src/backend/DesktopServerExposure.test.ts | 92 +++++++++++ .../src/backend/DesktopServerExposure.ts | 150 +++++++++++++++++- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + .../desktop/src/ipc/methods/serverExposure.ts | 13 ++ apps/desktop/src/preload.ts | 2 + .../src/settings/DesktopAppSettings.test.ts | 12 ++ .../src/settings/DesktopAppSettings.ts | 35 ++++ .../desktop/src/updates/updatesTestHarness.ts | 2 + apps/desktop/src/window/DesktopWindow.test.ts | 2 + .../desktop/src/wsl/DesktopWslBackend.test.ts | 1 + .../settings/ConnectionsSettings.tsx | 102 ++++++++++++ .../src/components/settings/settingsSearch.ts | 8 + .../src/state/desktopNetworkAccess.test.ts | 1 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/remoteAccess.ts | 2 + packages/shared/src/advertisedEndpoint.ts | 3 + 18 files changed, 424 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 747663b80ac0..4e72670d212a 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -45,6 +45,7 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp }), configureFromSettings: () => Effect.die("unexpected configureFromSettings"), setMode: () => Effect.die("unexpected setMode"), + setPreferredLanInterfaceName: () => Effect.die("unexpected preferred LAN interface change"), setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), getAdvertisedEndpoints: Effect.succeed([]), } satisfies DesktopServerExposure.DesktopServerExposure["Service"]); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index eb0becee0981..cccb40d3248c 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -38,6 +38,30 @@ const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { ], }; +const multiHomedNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { + docker0: [ + { + address: "172.17.0.1", + family: "IPv4", + internal: false, + }, + ], + en0: [ + { + address: "192.168.1.20", + family: "IPv4", + internal: false, + }, + ], + en1: [ + { + address: "192.168.1.21", + family: "IPv4", + internal: false, + }, + ], +}; + function mockSpawnerLayer(statusJson = "{}") { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -195,6 +219,7 @@ describe("DesktopServerExposure", () => { mode: "network-accessible", endpointUrl: "http://192.168.1.20:4173", advertisedHost: "192.168.1.20", + preferredLanInterfaceName: null, tailscaleServeEnabled: false, tailscaleServePort: 443, }); @@ -209,6 +234,71 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("advertises one endpoint per physical interface and skips virtual bridges", () => + withHarness( + multiHomedNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const state = yield* serverExposure.getState; + assert.equal(state.advertisedHost, "192.168.1.20"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints + .filter((endpoint) => endpoint.id.startsWith("desktop-lan:")) + .map((endpoint) => [endpoint.label, endpoint.httpBaseUrl, endpoint.interfaceName]), + [ + ["Local network", "http://192.168.1.20:4173/", "en0"], + ["Local network — en1 (192.168.1.21)", "http://192.168.1.21:4173/", "en1"], + ], + ); + }), + ), + ); + + it.effect("honors a preferred LAN interface", () => + withHarness( + multiHomedNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + yield* serverExposure.configureFromSettings({ port: 4173 }); + const change = yield* serverExposure.setPreferredLanInterfaceName({ name: "en1" }); + + assert.equal(change.state.advertisedHost, "192.168.1.21"); + assert.equal(change.state.preferredLanInterfaceName, "en1"); + assert.equal(change.requiresRelaunch, false); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + const defaultEndpoint = endpoints.find( + (endpoint) => endpoint.id.startsWith("desktop-lan:") && endpoint.isDefault === true, + ); + assert.equal(defaultEndpoint?.httpBaseUrl, "http://192.168.1.21:4173/"); + }), + ), + ); + + it.effect("falls back to automatic when the preferred interface disappears", () => + withHarness( + multiHomedNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + yield* serverExposure.configureFromSettings({ port: 4173 }); + const change = yield* serverExposure.setPreferredLanInterfaceName({ name: "en9" }); + + assert.equal(change.state.preferredLanInterfaceName, "en9"); + assert.equal(change.state.advertisedHost, "192.168.1.20"); + }), + ), + ); + it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => withHarness( emptyNetworkInterfaces, @@ -252,6 +342,7 @@ describe("DesktopServerExposure", () => { load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.fail(settingsFailure), + setPreferredLanInterfaceName: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), @@ -425,6 +516,7 @@ describe("DesktopServerExposure", () => { { id: "desktop-lan:http://192.168.1.20:3773", label: "Local network", + interfaceName: "en0", provider: { id: "desktop-core", label: "Desktop", diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index a04f4ecbc100..08dc1035020e 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -43,6 +43,9 @@ interface DesktopAdvertisedEndpointInput { readonly port: number; readonly exposure: ResolvedDesktopServerExposure; readonly customHttpsEndpointUrls?: readonly string[]; + readonly networkInterfaces?: DesktopNetworkInterfaces.NetworkInterfaces; + /** An explicit host override pins the advertisement; alternatives are then noise. */ + readonly advertisedHostOverride?: string | null; } const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { @@ -69,6 +72,50 @@ const isUsableLanIpv4Address = (address: string): boolean => !address.startsWith("169.254.") && !isTailscaleIpv4Address(address); +/** + * Interface name patterns that are container/VM/tunnel bridges rather than a + * physical adapter the phone would share a LAN with. Never advertised unless + * nothing else exists. + */ +const VIRTUAL_INTERFACE_NAME_PATTERN = + /^(?:docker\d*|br-[0-9a-f]+|virbr\d*|veth\w*|vmnet\d*|vEthernet[\s\w]*|wg\d*|tun\d+|lo)$/iu; + +export const isVirtualLanInterfaceName = (name: string): boolean => + VIRTUAL_INTERFACE_NAME_PATTERN.test(name); + +export interface LanInterfaceCandidate { + readonly name: string; + readonly address: string; + readonly virtual: boolean; +} + +/** + * One candidate per interface: the first usable IPv4 address on it, ordered + * physical interfaces before virtual ones. + */ +export const enumerateLanInterfaces = ( + networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, +): ReadonlyArray => { + const physical: LanInterfaceCandidate[] = []; + const virtual: LanInterfaceCandidate[] = []; + for (const [name, interfaceAddresses] of Object.entries(networkInterfaces)) { + if (!interfaceAddresses) continue; + for (const address of interfaceAddresses) { + if (!address || address.internal) continue; + if (address.family !== "IPv4") continue; + if (!isUsableLanIpv4Address(address.address)) continue; + const candidate = { + name, + address: address.address, + virtual: isVirtualLanInterfaceName(name), + }; + (candidate.virtual ? virtual : physical).push(candidate); + break; + } + } + return [...physical, ...virtual]; +}; + const isHttpsEndpointUrl = (value: string): boolean => { try { return new URL(value).protocol === "https:"; @@ -80,24 +127,28 @@ const isHttpsEndpointUrl = (value: string): boolean => { const resolveLanAdvertisedHost = ( networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, explicitHost: string | undefined, + preferredInterfaceName: string | null | undefined, ): string | null => { const normalizedExplicitHost = normalizeOptionalHost(explicitHost); if (normalizedExplicitHost) { return normalizedExplicitHost; } - for (const interfaceAddresses of Object.values(networkInterfaces)) { - if (!interfaceAddresses) continue; + const candidates = enumerateLanInterfaces(networkInterfaces); + if (candidates.length === 0) return null; - for (const address of interfaceAddresses) { - if (address.internal) continue; - if (address.family !== "IPv4") continue; - if (!isUsableLanIpv4Address(address.address)) continue; - return address.address; + if (preferredInterfaceName) { + const preferred = candidates.find((candidate) => candidate.name === preferredInterfaceName); + // A stored preference whose interface no longer exists (or lost its + // address) falls through to automatic selection instead of advertising a + // dead host. + if (preferred) { + return preferred.address; } } - return null; + const fallback = candidates.find((candidate) => !candidate.virtual) ?? candidates[0]; + return fallback ? fallback.address : null; }; const resolveDesktopServerExposure = (input: { @@ -105,6 +156,7 @@ const resolveDesktopServerExposure = (input: { readonly port: number; readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces; readonly advertisedHostOverride?: string; + readonly preferredLanInterfaceName?: string | null; }): ResolvedDesktopServerExposure => { const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; @@ -123,6 +175,7 @@ const resolveDesktopServerExposure = (input: { const advertisedHost = resolveLanAdvertisedHost( input.networkInterfaces, input.advertisedHostOverride, + input.preferredLanInterfaceName, ); return { @@ -168,6 +221,19 @@ const resolveDesktopCoreAdvertisedEndpoints = ( ]; if (input.exposure.endpointUrl) { + const lanCandidates = enumerateLanInterfaces(input.networkInterfaces ?? {}).filter( + (candidate) => !candidate.virtual, + ); + const advertisedHost = input.exposure.advertisedHost; + const advertisedInterfaceName = + lanCandidates.find((candidate) => candidate.address === advertisedHost)?.name ?? null; + const alternativeInterfaces = input.advertisedHostOverride + ? [] + : lanCandidates.filter((candidate) => candidate.address !== advertisedHost); + // The resolved (preferred or automatic) host keeps the classic single + // "Local network" endpoint and stays the default. On multi-homed machines + // each other physical interface gets its own endpoint so the pairing + // picker can pick the right one. endpoints.push( createDesktopEndpoint({ id: `desktop-lan:${input.exposure.endpointUrl}`, @@ -176,9 +242,24 @@ const resolveDesktopCoreAdvertisedEndpoints = ( reachability: "lan", status: "available", isDefault: true, + ...(advertisedInterfaceName ? { interfaceName: advertisedInterfaceName } : {}), description: "Reachable from devices on the same network.", }), ); + for (const candidate of alternativeInterfaces.slice(0, 8)) { + const url = `http://${candidate.address}:${input.port}`; + endpoints.push( + createDesktopEndpoint({ + id: `desktop-lan:${candidate.name}:${url}`, + label: `Local network — ${candidate.name} (${candidate.address})`, + httpBaseUrl: url, + reachability: "lan", + status: "available", + interfaceName: candidate.name, + description: "Alternative network interface on this machine.", + }), + ); + } } for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { @@ -247,10 +328,22 @@ export const DesktopServerExposureSetModeError = Schema.Union([ ]); export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type; +export class DesktopServerExposurePreferencePersistenceError extends Schema.TaggedError()( + "DesktopServerExposurePreferencePersistenceError", + { + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), + }, +) { + override get message(): string { + return "Failed to persist the preferred LAN interface."; + } +} + export const DesktopServerExposureError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, DesktopServerExposureModePersistenceError, DesktopTailscaleServePersistenceError, + DesktopServerExposurePreferencePersistenceError, ]); export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; @@ -278,6 +371,12 @@ export class DesktopServerExposure extends Context.Service< readonly setMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; + readonly setPreferredLanInterfaceName: (input: { + readonly name: string | null; + }) => Effect.Effect< + DesktopServerExposureChange, + DesktopServerExposurePreferencePersistenceError + >; readonly setTailscaleServeEnabled: (input: { readonly enabled: boolean; readonly port?: number; @@ -296,6 +395,7 @@ interface RuntimeState { readonly httpBaseUrl: URL; readonly endpointUrl: Option.Option; readonly advertisedHost: Option.Option; + readonly preferredLanInterfaceName: string | null; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; } @@ -321,6 +421,7 @@ const toContractState = (state: RuntimeState): DesktopServerExposureState => ({ mode: state.mode, endpointUrl: Option.getOrNull(state.endpointUrl), advertisedHost: Option.getOrNull(state.advertisedHost), + preferredLanInterfaceName: state.preferredLanInterfaceName, tailscaleServeEnabled: state.tailscaleServeEnabled, tailscaleServePort: state.tailscaleServePort, }); @@ -358,6 +459,7 @@ function runtimeStateFromResolvedExposure(input: { httpBaseUrl: new URL(input.exposure.localHttpUrl), endpointUrl: Option.fromNullishOr(input.exposure.endpointUrl), advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost), + preferredLanInterfaceName: input.settings.preferredLanInterfaceName, tailscaleServeEnabled: input.settings.tailscaleServeEnabled, tailscaleServePort: input.settings.tailscaleServePort, }; @@ -376,6 +478,7 @@ function resolveRuntimeState(input: { port: input.port, networkInterfaces: input.networkInterfaces, ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + preferredLanInterfaceName: input.settings.preferredLanInterfaceName, }); const unavailable = input.requestedMode === "network-accessible" && @@ -529,6 +632,34 @@ export const make = Effect.gen(function* () { }, ); + const setPreferredLanInterfaceName = Effect.fn( + "desktop.serverExposure.setPreferredLanInterfaceName", + )(function* (input: { readonly name: string | null }) { + yield* Effect.annotateCurrentSpan({ name: input.name }); + const previous = yield* Ref.get(stateRef); + const result = yield* desktopSettings + .setPreferredLanInterfaceName(input.name) + .pipe( + Effect.mapError((cause) => new DesktopServerExposurePreferencePersistenceError({ cause })), + ); + // Re-resolve against live interfaces: a preference for an interface that + // no longer exists falls back to automatic instead of advertising a dead + // host. The bind host and port are unchanged, so no relaunch is needed. + const currentNetworkInterfaces = yield* readNetworkInterfaces; + const resolved = resolveRuntimeState({ + requestedMode: previous.requestedMode, + settings: result.settings, + port: previous.port, + networkInterfaces: currentNetworkInterfaces, + advertisedHostOverride: config.desktopLanHostOverride, + }); + yield* Ref.set(stateRef, resolved.state); + return { + state: toContractState(resolved.state), + requiresRelaunch: requiresBackendRelaunch(previous, resolved.state), + }; + }); + const getAdvertisedEndpoints = Effect.gen(function* () { const state = yield* Ref.get(stateRef); const currentNetworkInterfaces = yield* readNetworkInterfaces; @@ -536,6 +667,8 @@ export const make = Effect.gen(function* () { port: state.port, exposure: toResolvedExposure(state), customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, + networkInterfaces: currentNetworkInterfaces, + advertisedHostOverride: Option.getOrNull(config.desktopLanHostOverride), }); // Don't spawn the Tailscale CLI when the user hasn't opted into any @@ -563,6 +696,7 @@ export const make = Effect.gen(function* () { backendConfig, configureFromSettings, setMode, + setPreferredLanInterfaceName, setTailscaleServeEnabled, getAdvertisedEndpoints, }); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index dc3769bb814f..2dbc50fbb1ad 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -10,6 +10,7 @@ import { import { getAdvertisedEndpoints, getServerExposureState, + setPreferredLanInterfaceName, setServerExposureMode, setTailscaleServeEnabled, } from "./methods/serverExposure.ts"; @@ -107,6 +108,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(getServerExposureState); yield* ipc.handle(setServerExposureMode); + yield* ipc.handle(setPreferredLanInterfaceName); yield* ipc.handle(setTailscaleServeEnabled); yield* ipc.handle(getAdvertisedEndpoints); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 43ecee06c0ca..d3e6b8909af4 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -55,6 +55,7 @@ export const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-to export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; +export const SET_PREFERRED_LAN_INTERFACE_NAME_CHANNEL = "desktop:set-preferred-lan-interface-name"; export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts index 9a9ce768973b..b69c1e76ca68 100644 --- a/apps/desktop/src/ipc/methods/serverExposure.ts +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -41,6 +41,19 @@ export const setServerExposureMode = DesktopIpc.makeIpcMethod({ }), }); +export const setPreferredLanInterfaceName = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_PREFERRED_LAN_INTERFACE_NAME_CHANNEL, + payload: Schema.NullOr(Schema.String), + result: DesktopServerExposureStateSchema, + handler: Effect.fn("desktop.ipc.serverExposure.setPreferredLanInterfaceName")(function* (name) { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const change = yield* serverExposure.setPreferredLanInterfaceName({ name }); + // The bind host and port are unchanged, so no relaunch: only the + // advertised URL and endpoint list move. + return change.state; + }), +}); + export const setTailscaleServeEnabled = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, payload: SetTailscaleServeEnabledInput, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7da32d7913ae..b864dbd818e2 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -133,6 +133,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), + setPreferredLanInterfaceName: (name) => + ipcRenderer.invoke(IpcChannels.SET_PREFERRED_LAN_INTERFACE_NAME_CHANNEL, name), setTailscaleServeEnabled: (input) => ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749abe9..a44ec43396e0 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -26,6 +26,7 @@ const DesktopSettingsPatch = Schema.Struct({ ), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), + preferredLanInterfaceName: Schema.optionalKey(Schema.NullOr(Schema.String)), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), @@ -109,6 +110,7 @@ describe("DesktopSettings", () => { mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", + preferredLanInterfaceName: null, tailscaleServeEnabled: false, tailscaleServePort: 443, updateChannel: "nightly", @@ -127,6 +129,7 @@ describe("DesktopSettings", () => { yield* writeSettingsPatch({ linuxPasswordStore: "gnome-libsecret", serverExposureMode: "network-accessible", + preferredLanInterfaceName: null, tailscaleServeEnabled: true, tailscaleServePort: 8443, updateChannel: "latest", @@ -138,6 +141,7 @@ describe("DesktopSettings", () => { mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", + preferredLanInterfaceName: null, tailscaleServeEnabled: true, tailscaleServePort: 8443, updateChannel: "latest", @@ -245,6 +249,7 @@ describe("DesktopSettings", () => { mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, serverExposureMode: "network-accessible", + preferredLanInterfaceName: null, tailscaleServeEnabled: true, tailscaleServePort: 8443, updateChannel: "latest", @@ -265,6 +270,7 @@ describe("DesktopSettings", () => { mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", + preferredLanInterfaceName: null, }); const loaded = yield* settings.load; @@ -301,6 +307,7 @@ describe("DesktopSettings", () => { mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", + preferredLanInterfaceName: null, tailscaleServeEnabled: true, tailscaleServePort: 8443, updateChannel: "nightly", @@ -341,6 +348,7 @@ describe("DesktopSettings", () => { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ serverExposureMode: "local-only", + preferredLanInterfaceName: null, updateChannel: "latest", }); @@ -349,6 +357,7 @@ describe("DesktopSettings", () => { mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", + preferredLanInterfaceName: null, tailscaleServeEnabled: false, tailscaleServePort: 443, updateChannel: "nightly", @@ -368,6 +377,7 @@ describe("DesktopSettings", () => { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ serverExposureMode: "local-only", + preferredLanInterfaceName: null, updateChannel: "latest", updateChannelConfiguredByUser: true, }); @@ -377,6 +387,7 @@ describe("DesktopSettings", () => { mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", + preferredLanInterfaceName: null, tailscaleServeEnabled: false, tailscaleServePort: 443, updateChannel: "latest", @@ -404,6 +415,7 @@ describe("DesktopSettings", () => { mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", + preferredLanInterfaceName: null, tailscaleServeEnabled: true, tailscaleServePort: 443, updateChannel: "latest", diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 3bd235018022..b19b69b4dae4 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -29,6 +29,13 @@ export interface DesktopSettings { readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; + /** + * Network interface whose IPv4 address is advertised as the "Local + * network" endpoint. `null` means automatic selection: the first usable + * non-virtual interface in enumeration order. Persisted as a name so a + * DHCP address change keeps working without rewriting the setting. + */ + readonly preferredLanInterfaceName: string | null; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; readonly updateChannel: DesktopUpdateChannel; @@ -77,6 +84,7 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", + preferredLanInterfaceName: null, tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, updateChannel: "latest", @@ -98,6 +106,7 @@ const DesktopSettingsDocument = Schema.Struct({ mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), + preferredLanInterfaceName: Schema.optionalKey(Schema.NullOr(Schema.String)), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), @@ -159,6 +168,9 @@ export class DesktopAppSettings extends Context.Service< readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; + readonly setPreferredLanInterfaceName: ( + name: string | null, + ) => Effect.Effect; readonly setTailscaleServe: (input: { readonly enabled: boolean; readonly port: Option.Option; @@ -229,6 +241,11 @@ function normalizeDesktopSettingsDocument( mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", + preferredLanInterfaceName: + typeof parsed.preferredLanInterfaceName === "string" && + parsed.preferredLanInterfaceName.length > 0 + ? parsed.preferredLanInterfaceName + : null, tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), updateChannel: updateChannelConfiguredByUser @@ -296,6 +313,18 @@ function setServerExposureMode( }; } +function setPreferredLanInterfaceName( + settings: DesktopSettings, + name: string | null, +): DesktopSettings { + return settings.preferredLanInterfaceName === name + ? settings + : { + ...settings, + preferredLanInterfaceName: name, + }; +} + function setMainWindowBounds( settings: DesktopSettings, bounds: DesktopWindowBounds, @@ -523,6 +552,10 @@ export const make = Effect.gen(function* () { persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), ), + setPreferredLanInterfaceName: (name) => + persist((settings) => setPreferredLanInterfaceName(settings, name)).pipe( + Effect.withSpan("desktop.settings.setPreferredLanInterfaceName", { attributes: { name } }), + ), setTailscaleServe: (input) => persist((settings) => setTailscaleServe(settings, input)).pipe( Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), @@ -580,6 +613,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), + setPreferredLanInterfaceName: (name) => + update((settings) => setPreferredLanInterfaceName(settings, name)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), setWslBackendEnabled: (enabled) => diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index cd1404a50464..57d81a6612f7 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -177,6 +177,8 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { load: Effect.sync(() => testSettings), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setPreferredLanInterfaceName: () => + Effect.die("unexpected preferred LAN interface update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: (channel) => setUpdateChannelError diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 7bbb5c1da024..0c1eee63a91d 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -174,6 +174,7 @@ const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopSe }), configureFromSettings: () => Effect.die("unexpected configureFromSettings"), setMode: () => Effect.die("unexpected setMode"), + setPreferredLanInterfaceName: () => Effect.die("unexpected preferred LAN interface change"), setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"), } satisfies DesktopServerExposure.DesktopServerExposure["Service"]); @@ -248,6 +249,7 @@ function makeTestLayer(input: { return { settings: desktopSettings, changed }; }), setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setPreferredLanInterfaceName: () => Effect.die("unexpected preferred LAN interface update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.die("unexpected update channel change"), setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index ed8911d40075..f2f3a4ac010b 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -61,6 +61,7 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp }), configureFromSettings: () => Effect.die("unexpected configureFromSettings"), setMode: () => Effect.die("unexpected setMode"), + setPreferredLanInterfaceName: () => Effect.die("unexpected preferred LAN interface change"), setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), getAdvertisedEndpoints: Effect.succeed([]), } satisfies DesktopServerExposure.DesktopServerExposure["Service"]); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index ad7665651171..8e5da81f393f 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1850,6 +1850,7 @@ export function ConnectionsSettings() { useState(null); const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); const [isDesktopServerExposureDialogOpen, setIsDesktopServerExposureDialogOpen] = useState(false); + const [isUpdatingLanInterface, setIsUpdatingLanInterface] = useState(false); const [isUpdatingTailscaleServe, setIsUpdatingTailscaleServe] = useState(false); const [isUpdatingWslBackend, setIsUpdatingWslBackend] = useState(false); const [desktopWslMutationError, setDesktopWslMutationError] = useState(null); @@ -2453,6 +2454,48 @@ export function ConnectionsSettings() { }, [setDefaultAdvertisedEndpointKey], ); + const desktopLanInterfaces = useMemo( + () => + desktopAdvertisedEndpoints.flatMap((endpoint) => + endpoint.interfaceName !== undefined + ? [ + { + name: endpoint.interfaceName, + label: endpoint.label, + address: endpoint.httpBaseUrl, + }, + ] + : [], + ), + [desktopAdvertisedEndpoints], + ); + const preferredLanInterfaceName = desktopServerExposureState?.preferredLanInterfaceName ?? null; + const isPreferredLanInterfaceMissing = + preferredLanInterfaceName !== null && + !desktopLanInterfaces.some((candidate) => candidate.name === preferredLanInterfaceName); + const handlePreferredLanInterfaceChange = useCallback( + async (value: string) => { + if (!desktopBridge) return; + setIsUpdatingLanInterface(true); + try { + await desktopBridge.setPreferredLanInterfaceName(value === "auto" ? null : value); + refreshDesktopNetworkAccessState(); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to update the network interface."; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not set the network interface", + description: message, + }), + ); + } finally { + setIsUpdatingLanInterface(false); + } + }, + [desktopBridge], + ); const handleSavedBackendHostChange = useCallback((value: string) => { const parsedPairingUrl = parsePairingUrlFields(value); if (parsedPairingUrl) { @@ -3099,6 +3142,64 @@ export function ConnectionsSettings() { control={renderNetworkAccessToggle()} /> ); + const renderPreferredLanInterfaceRow = () => { + if (!desktopBridge || desktopLanInterfaces.length < 2) { + return null; + } + const selectValue = preferredLanInterfaceName ?? "auto"; + const automaticCandidate = desktopLanInterfaces.find( + (candidate) => candidate.name === desktopServerExposureState?.advertisedHost, + ); + const automaticLabel = + desktopServerExposureState?.advertisedHost === null || automaticCandidate === undefined + ? "Automatic" + : `Automatic (${automaticCandidate.name})`; + return ( + + + Interface {preferredLanInterfaceName} is no longer detected. Using automatic + selection until it comes back. + + + ) : ( + "Interface whose address pairing links and the QR code use when several networks are available." + ) + } + control={ + + } + /> + ); + }; const renderDisabledNetworkAccessRow = () => ( {renderNetworkAccessRow()} + {renderPreferredLanInterfaceRow()} {renderEndpointRows("endpoint-rail")} {renderTailscaleRow()} {renderWslRow()} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e32baf6f3987..b71d1118ee29 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -501,6 +501,14 @@ export const SETTINGS_SEARCH_ITEMS = [ searchTerms: ["expose backend remote pairing local machine interfaces host restart"], localBackendManagementOnly: true, }, + { + id: "lan-interface", + title: "LAN interface", + to: "/settings/connections", + targetId: "connections-environment", + searchTerms: ["network interface lan ip address qr code pairing wifi ethernet multi homed"], + localBackendManagementOnly: true, + }, { id: "tailscale-https", title: "Tailscale HTTPS", diff --git a/apps/web/src/state/desktopNetworkAccess.test.ts b/apps/web/src/state/desktopNetworkAccess.test.ts index 0dde5f7d7dc8..38e5ceaa7cba 100644 --- a/apps/web/src/state/desktopNetworkAccess.test.ts +++ b/apps/web/src/state/desktopNetworkAccess.test.ts @@ -10,6 +10,7 @@ const serverExposureState: DesktopServerExposureState = { advertisedHost: "192.168.1.10", endpointUrl: "http://192.168.1.10:37737", mode: "network-accessible", + preferredLanInterfaceName: null, tailscaleServeEnabled: false, tailscaleServePort: 443, }; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index cd906aecdfce..8c4dd3142386 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -574,6 +574,7 @@ export interface DesktopServerExposureState { mode: DesktopServerExposureMode; endpointUrl: string | null; advertisedHost: string | null; + preferredLanInterfaceName: string | null; tailscaleServeEnabled: boolean; tailscaleServePort: number; } @@ -582,6 +583,7 @@ export const DesktopServerExposureStateSchema = Schema.Struct({ mode: DesktopServerExposureModeSchema, endpointUrl: Schema.NullOr(Schema.String), advertisedHost: Schema.NullOr(Schema.String), + preferredLanInterfaceName: Schema.NullOr(Schema.String), tailscaleServeEnabled: Schema.Boolean, tailscaleServePort: Schema.Number, }); @@ -1272,6 +1274,7 @@ export interface DesktopBridge { resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise; getServerExposureState: () => Promise; setServerExposureMode: (mode: DesktopServerExposureMode) => Promise; + setPreferredLanInterfaceName: (name: string | null) => Promise; setTailscaleServeEnabled: (input: { readonly enabled: boolean; readonly port?: number; diff --git a/packages/contracts/src/remoteAccess.ts b/packages/contracts/src/remoteAccess.ts index e3de3c29e122..a603f3361d20 100644 --- a/packages/contracts/src/remoteAccess.ts +++ b/packages/contracts/src/remoteAccess.ts @@ -63,6 +63,8 @@ export const AdvertisedEndpoint = Schema.Struct({ source: AdvertisedEndpointSource, status: AdvertisedEndpointStatus, isDefault: Schema.optional(Schema.Boolean), + /** Network interface this endpoint's address belongs to (LAN endpoints only). */ + interfaceName: Schema.optional(Schema.String), description: Schema.optional(TrimmedNonEmptyString), }); export type AdvertisedEndpoint = typeof AdvertisedEndpoint.Type; diff --git a/packages/shared/src/advertisedEndpoint.ts b/packages/shared/src/advertisedEndpoint.ts index 314d8272c816..ef499b8fb323 100644 --- a/packages/shared/src/advertisedEndpoint.ts +++ b/packages/shared/src/advertisedEndpoint.ts @@ -18,6 +18,8 @@ export interface CreateAdvertisedEndpointInput { readonly source: AdvertisedEndpointSource; readonly status?: AdvertisedEndpointStatus; readonly isDefault?: boolean; + /** Network interface this endpoint's address belongs to (LAN endpoints only). */ + readonly interfaceName?: string; readonly description?: string; } @@ -73,6 +75,7 @@ export function createAdvertisedEndpoint(input: CreateAdvertisedEndpointInput): source: input.source, status: input.status ?? "available", ...(input.isDefault === undefined ? {} : { isDefault: input.isDefault }), + ...(input.interfaceName === undefined ? {} : { interfaceName: input.interfaceName }), ...(input.description === undefined ? {} : { description: input.description }), }; } From 732318e4e4a7b1592092582a8702cf3396d6d691 Mon Sep 17 00:00:00 2001 From: PJalv <134026493+PJalv@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:24:01 -0700 Subject: [PATCH 2/3] fix(desktop): address LAN interface review findings - treat Windows virtual adapters with parentheses (vEthernet (Default Switch)) as virtual so they are never advertised - serialize the LAN interface preference in the settings document so unrelated settings saves no longer erase it - stop truncating per-interface endpoints at eight alternatives - label the automatic option with the interface actually backing the advertised host --- .../src/backend/DesktopServerExposure.test.ts | 39 +++++++++++++++++++ .../src/backend/DesktopServerExposure.ts | 4 +- .../src/settings/DesktopAppSettings.test.ts | 17 ++++++++ .../src/settings/DesktopAppSettings.ts | 3 ++ .../settings/ConnectionsSettings.tsx | 2 +- 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index cccb40d3248c..27f9e2e35343 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -260,6 +260,45 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("treats Windows virtual adapters with parentheses as virtual", () => + withHarness( + { + "vEthernet (Default Switch)": [{ address: "172.25.32.1", family: "IPv4", internal: false }], + ...multiHomedNetworkInterfaces, + }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.isFalse(endpoints.some((endpoint) => endpoint.httpBaseUrl.includes("172.25.32.1"))); + }), + ), + ); + + it.effect("advertises every physical interface without truncation", () => + withHarness( + Object.fromEntries( + Array.from({ length: 10 }, (_, index) => [ + `en${index}`, + [{ address: `10.0.0.${index + 2}`, family: "IPv4", internal: false }], + ]), + ), + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + const lanEndpoints = endpoints.filter((endpoint) => endpoint.id.startsWith("desktop-lan:")); + assert.equal(lanEndpoints.length, 10); + }), + ), + ); + it.effect("honors a preferred LAN interface", () => withHarness( multiHomedNetworkInterfaces, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 08dc1035020e..be7cd852f81f 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -78,7 +78,7 @@ const isUsableLanIpv4Address = (address: string): boolean => * nothing else exists. */ const VIRTUAL_INTERFACE_NAME_PATTERN = - /^(?:docker\d*|br-[0-9a-f]+|virbr\d*|veth\w*|vmnet\d*|vEthernet[\s\w]*|wg\d*|tun\d+|lo)$/iu; + /^(?:docker\d*|br-[0-9a-f]+|virbr\d*|veth\w*|vmnet\d*|vEthernet[\s\w()]*|wg\d*|tun\d+|lo)$/iu; export const isVirtualLanInterfaceName = (name: string): boolean => VIRTUAL_INTERFACE_NAME_PATTERN.test(name); @@ -246,7 +246,7 @@ const resolveDesktopCoreAdvertisedEndpoints = ( description: "Reachable from devices on the same network.", }), ); - for (const candidate of alternativeInterfaces.slice(0, 8)) { + for (const candidate of alternativeInterfaces) { const url = `http://${candidate.address}:${input.port}`; endpoints.push( createDesktopEndpoint({ diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index a44ec43396e0..0fdea76a9e91 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -170,6 +170,23 @@ describe("DesktopSettings", () => { ), ); + it.effect("persists the LAN interface preference and survives unrelated settings saves", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.setPreferredLanInterfaceName("en1"); + yield* settings.setServerExposureMode("network-accessible"); + + // `load` re-reads the settings file from disk, so both values must + // survive the second write's document serialization. + const reloaded = yield* settings.load; + assert.equal(reloaded.preferredLanInterfaceName, "en1"); + assert.equal(reloaded.serverExposureMode, "network-accessible"); + }), + ), + ); + it.effect("reports the failed desktop settings write operation and path", () => withSettings( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index b19b69b4dae4..0b4f215ab782 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -273,6 +273,9 @@ function toDesktopSettingsDocument( if (settings.mainWindowMaximized) { document.mainWindowMaximized = true; } + if (settings.preferredLanInterfaceName !== defaults.preferredLanInterfaceName) { + document.preferredLanInterfaceName = settings.preferredLanInterfaceName; + } if (settings.serverExposureMode !== defaults.serverExposureMode) { document.serverExposureMode = settings.serverExposureMode; } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 8e5da81f393f..1b8b3ac457cc 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -3148,7 +3148,7 @@ export function ConnectionsSettings() { } const selectValue = preferredLanInterfaceName ?? "auto"; const automaticCandidate = desktopLanInterfaces.find( - (candidate) => candidate.name === desktopServerExposureState?.advertisedHost, + (candidate) => candidate.address === desktopServerExposureState?.advertisedHost, ); const automaticLabel = desktopServerExposureState?.advertisedHost === null || automaticCandidate === undefined From 081599a5973160f4f5210f3bf3a547f34ac5056a Mon Sep 17 00:00:00 2001 From: PJalv <134026493+PJalv@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:32:09 -0700 Subject: [PATCH 3/3] fix(desktop): keep advertised LAN endpoints current with live interfaces - getAdvertisedEndpoints re-resolves the LAN host against fresh interfaces so a preferred interface that disappeared or changed address after startup no longer advertises a dead pairing URL - a machine whose only usable addresses sit on virtual bridges downgrades to loopback instead of advertising an unreachable host - classify macOS utun adapters and Windows vEthernet (Default Switch) as virtual - keep the LAN interface row visible while a stored preference names a missing interface, so it can be cleared --- .../src/backend/DesktopServerExposure.test.ts | 49 +++++++++++++++++++ .../src/backend/DesktopServerExposure.ts | 34 +++++++++++-- .../settings/ConnectionsSettings.tsx | 5 +- 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 27f9e2e35343..c121f3de9603 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -299,6 +299,55 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("re-resolves the default endpoint when interfaces change after startup", () => { + const interfaces: Record = { + ...multiHomedNetworkInterfaces, + }; + return withHarness( + interfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const preferred = yield* serverExposure.setPreferredLanInterfaceName({ name: "en0" }); + assert.equal(preferred.state.advertisedHost, "192.168.1.20"); + + // The harness network service returns the fixture object by + // reference, so removing en0 here simulates it disappearing after + // the exposure state was resolved at startup. + delete interfaces.en0; + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + const defaultEndpoint = endpoints.find( + (endpoint) => endpoint.id.startsWith("desktop-lan:") && endpoint.isDefault === true, + ); + assert.equal(defaultEndpoint?.httpBaseUrl, "http://192.168.1.21:4173/"); + assert.isFalse(endpoints.some((endpoint) => endpoint.httpBaseUrl.includes("192.168.1.20"))); + const refreshed = yield* serverExposure.getState; + assert.equal(refreshed.advertisedHost, "192.168.1.21"); + }), + ); + }); + + it.effect("downgrades to loopback when only virtual interfaces exist", () => + withHarness( + { + docker0: [{ address: "172.17.0.1", family: "IPv4", internal: false }], + }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const state = yield* serverExposure.getState; + assert.equal(state.mode, "local-only"); + assert.equal(state.endpointUrl, null); + }), + ), + ); + it.effect("honors a preferred LAN interface", () => withHarness( multiHomedNetworkInterfaces, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index be7cd852f81f..9fa2a7b50073 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -78,7 +78,7 @@ const isUsableLanIpv4Address = (address: string): boolean => * nothing else exists. */ const VIRTUAL_INTERFACE_NAME_PATTERN = - /^(?:docker\d*|br-[0-9a-f]+|virbr\d*|veth\w*|vmnet\d*|vEthernet[\s\w()]*|wg\d*|tun\d+|lo)$/iu; + /^(?:docker\d*|br-[0-9a-f]+|virbr\d*|veth\w*|vmnet\d*|vEthernet[\s\w()]*|wg\d*|utun\d*|tun\d+|lo)$/iu; export const isVirtualLanInterfaceName = (name: string): boolean => VIRTUAL_INTERFACE_NAME_PATTERN.test(name); @@ -147,7 +147,10 @@ const resolveLanAdvertisedHost = ( } } - const fallback = candidates.find((candidate) => !candidate.virtual) ?? candidates[0]; + const fallback = candidates.find((candidate) => !candidate.virtual); + // A machine whose only usable addresses sit on container/VM bridges has no + // reachable LAN host; returning null downgrades to loopback instead of + // advertising an address no other device can reach. return fallback ? fallback.address : null; }; @@ -663,9 +666,34 @@ export const make = Effect.gen(function* () { const getAdvertisedEndpoints = Effect.gen(function* () { const state = yield* Ref.get(stateRef); const currentNetworkInterfaces = yield* readNetworkInterfaces; + // Re-resolve the LAN host against live interfaces: the persisted state + // was resolved at bootstrap (or at the last settings change), so a + // preferred interface that vanished or changed address afterwards must + // not keep advertising a dead pairing URL. + const advertisedHostOverride = Option.getOrUndefined(config.desktopLanHostOverride); + const exposure = + state.mode === "network-accessible" + ? resolveDesktopServerExposure({ + mode: state.mode, + port: state.port, + networkInterfaces: currentNetworkInterfaces, + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + preferredLanInterfaceName: (yield* desktopSettings.get).preferredLanInterfaceName, + }) + : toResolvedExposure(state); + if ( + exposure.endpointUrl !== Option.getOrNull(state.endpointUrl) || + exposure.advertisedHost !== Option.getOrNull(state.advertisedHost) + ) { + yield* Ref.set(stateRef, { + ...state, + endpointUrl: Option.fromNullishOr(exposure.endpointUrl), + advertisedHost: Option.fromNullishOr(exposure.advertisedHost), + }); + } const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ port: state.port, - exposure: toResolvedExposure(state), + exposure, customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, networkInterfaces: currentNetworkInterfaces, advertisedHostOverride: Option.getOrNull(config.desktopLanHostOverride), diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 1b8b3ac457cc..7e3c655d789c 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -3143,7 +3143,10 @@ export function ConnectionsSettings() { /> ); const renderPreferredLanInterfaceRow = () => { - if (!desktopBridge || desktopLanInterfaces.length < 2) { + if (!desktopBridge || (desktopLanInterfaces.length < 2 && preferredLanInterfaceName === null)) { + // With a single interface and no preference there is nothing to pick, + // but a stored preference whose interface disappeared must stay + // visible so it can be cleared. return null; } const selectValue = preferredLanInterfaceName ?? "auto";