From 1563ee953ed6a3c98d280b20886a34e6ebf25cc1 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 19:39:17 +0900 Subject: [PATCH 1/6] fix(settings): keep a chosen Thunderbolt Bridge pairing address macOS names the Thunderbolt cable bridge0, so Share this host treated it as a container bridge and replaced an explicit pick with en0 when the link dropped or the app restarted. Remember the chosen interface and keep that address while it is briefly missing. Auto-advertise still prefers a LAN or tailnet address when one exists. Fixes #15980 --- .../runtime/pairing-network-interfaces.ts | 6 +- .../settings/RuntimePairingGeneratorForm.tsx | 13 +++ .../RuntimePairingUrlGenerator.test.tsx | 83 ++++++++++++- .../settings/RuntimePairingUrlGenerator.tsx | 51 ++------ .../runtime-pairing-link-state.test.ts | 72 ++++++++++++ .../settings/runtime-pairing-link-state.ts | 85 +++++++++++++- .../use-runtime-pairing-advertised-address.ts | 109 ++++++++++++++++++ src/shared/global-settings-types.ts | 4 + .../pairing-address-auto-selection.test.ts | 22 ++++ src/shared/pairing-address-auto-selection.ts | 24 +++- 10 files changed, 418 insertions(+), 51 deletions(-) create mode 100644 src/renderer/src/components/settings/runtime-pairing-link-state.test.ts create mode 100644 src/renderer/src/components/settings/use-runtime-pairing-advertised-address.ts diff --git a/src/main/runtime/pairing-network-interfaces.ts b/src/main/runtime/pairing-network-interfaces.ts index 8c0448ec7e3c..a7d31c7a5979 100644 --- a/src/main/runtime/pairing-network-interfaces.ts +++ b/src/main/runtime/pairing-network-interfaces.ts @@ -1,5 +1,6 @@ import { networkInterfaces } from 'node:os' import { + isThunderboltBridgeInterface, isVirtualBridgeInterface, selectAutoAdvertisedPairingAddress } from '../../shared/pairing-address-auto-selection' @@ -65,7 +66,10 @@ function rankInterface({ name, address, hasDefaultRoute }: NetworkInterface): nu if (isTailnetIPv4Address(address)) { return 0 } - const bridgePenalty = isVirtualBridgeInterface(name, hasDefaultRoute) ? 2 : 0 + // Why: Thunderbolt Bridge is a real link, but it is not the phone's default. Keep it behind + // LAN and IPv6 in the picker order so `bridge0` does not become interfaces[0] while en0 is up. + const bridgePenalty = + isVirtualBridgeInterface(name, hasDefaultRoute) || isThunderboltBridgeInterface(name) ? 2 : 0 return (address.includes(':') ? 2 : 1) + bridgePenalty } diff --git a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx index 1c5176a999c4..6149213dee8d 100644 --- a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx +++ b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx @@ -15,6 +15,7 @@ type RuntimePairingGeneratorFormProps = { intent: RuntimePairingIntent loopbackAddress: string networkInterfaces: { name: string; address: string }[] + retainedInterfaceName?: string | null selectedAddress: string refreshingNetworkInterfaces: boolean isGeneratingPairing: boolean @@ -33,6 +34,7 @@ export function RuntimePairingGeneratorForm({ intent, loopbackAddress, networkInterfaces, + retainedInterfaceName = null, selectedAddress, refreshingNetworkInterfaces, isGeneratingPairing, @@ -50,6 +52,17 @@ export function RuntimePairingGeneratorForm({ value: networkInterface.address, label: `${networkInterface.name} (${networkInterface.address})` })) + if ( + intent === 'another' && + retainedInterfaceName && + selectedAddress && + !networkInterfaces.some((networkInterface) => networkInterface.address === selectedAddress) + ) { + options.push({ + value: selectedAddress, + label: `${retainedInterfaceName} (${selectedAddress})` + }) + } const generatedIsCurrent = generatedAddress === selectedAddress const staleGeneratedLink = generatedAddress !== null && !generatedIsCurrent const customAddressResult = diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx index 0b32a439dbd2..5e2bb18ee649 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx @@ -8,19 +8,43 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ listNetworkInterfaces: vi.fn(), listRuntimeAccessGrants: vi.fn(), - getRuntimePairingUrl: vi.fn() + getRuntimePairingUrl: vi.fn(), + updateSettings: vi.fn(), + settings: null as null | { + runtimePairingAdvertisedInterfaceName?: string | null + runtimePairingAdvertisedAddress?: string | null + } })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (state: { + settings: typeof mocks.settings + updateSettings: typeof mocks.updateSettings + }) => unknown + ) => selector({ settings: mocks.settings, updateSettings: mocks.updateSettings }) +})) vi.mock('./RuntimeAccessGrantList', () => ({ RuntimeAccessGrantList: () => null })) vi.mock('./RuntimePairingGeneratorForm', () => ({ - RuntimePairingGeneratorForm: (props: { selectedAddress: string; onGenerate: () => void }) => ( + RuntimePairingGeneratorForm: (props: { + selectedAddress: string + onGenerate: () => void + onSelectedAddressChange: (address: string) => void + onRefreshNetworkInterfaces: () => void + }) => (
{props.selectedAddress}
+ +
) })) @@ -33,6 +57,10 @@ describe('RuntimePairingUrlGenerator', () => { runtimePairingLinkCache.selectedAddress = '100.76.32.125' runtimePairingLinkCache.customAddress = '' runtimePairingLinkCache.intent = 'another' + runtimePairingLinkCache.advertisedInterfaceName = null + runtimePairingLinkCache.advertisedAddress = '' + mocks.settings = null + mocks.updateSettings.mockReset() runtimePairingLinkCache.generatedAddress = null runtimePairingLinkCache.runtimePairingUrl = null runtimePairingLinkCache.webClientUrl = null @@ -105,4 +133,55 @@ describe('RuntimePairingUrlGenerator', () => { expect(mocks.getRuntimePairingUrl).toHaveBeenCalledWith({ address, rotate: true, reach }) ) }) + + it('restores a saved Thunderbolt interface instead of Ethernet after restart', async () => { + runtimePairingLinkCache.selectedAddress = '' + mocks.settings = { + runtimePairingAdvertisedInterfaceName: 'bridge0', + runtimePairingAdvertisedAddress: '10.99.88.1' + } + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [ + { name: 'en0', address: '192.168.4.191' }, + { name: 'bridge0', address: '10.99.88.1' } + ] + }) + + render() + + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.1') + ) + }) + + it('keeps an explicit Thunderbolt pick when refresh reports only Ethernet', async () => { + runtimePairingLinkCache.selectedAddress = '' + mocks.listNetworkInterfaces + .mockResolvedValueOnce({ + interfaces: [ + { name: 'en0', address: '192.168.4.191' }, + { name: 'bridge0', address: '10.99.88.1' } + ] + }) + .mockResolvedValueOnce({ + interfaces: [{ name: 'en0', address: '192.168.4.191' }] + }) + + render() + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent('192.168.4.191') + ) + + screen.getByRole('button', { name: 'Pick bridge' }).click() + await waitFor(() => + expect(mocks.updateSettings).toHaveBeenCalledWith({ + runtimePairingAdvertisedInterfaceName: 'bridge0', + runtimePairingAdvertisedAddress: '10.99.88.1' + }) + ) + + screen.getByRole('button', { name: 'Refresh' }).click() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledTimes(2)) + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.1') + }) }) diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx index c6d927de5938..3de9bad5a201 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx @@ -6,13 +6,13 @@ import { Label } from '../ui/label' import { RuntimeAccessGrantList } from './RuntimeAccessGrantList' import { translate } from '@/i18n/i18n' import { RuntimePairingGeneratorForm } from './RuntimePairingGeneratorForm' +import { useRuntimePairingAdvertisedAddress } from './use-runtime-pairing-advertised-address' import { RUNTIME_PAIRING_LOOPBACK_ADDRESS, cacheGeneratedRuntimePairingLink, clearGeneratedRuntimePairingLink, runtimePairingLinkCache, runtimePairingReachForIntent, - selectRuntimePairingIntent, type RuntimePairingIntent, type RuntimePairingUrlGeneratorProps } from './runtime-pairing-link-state' @@ -49,6 +49,14 @@ export function RuntimePairingUrlGenerator({ const accessGrantLoadIdRef = useRef(0) const copiedTargetResetTimerRef = useRef(null) const mountedRef = useMountedRef() + const { preferredInterfaceName, updateSelectedAddress, updateIntent } = + useRuntimePairingAdvertisedAddress({ + intent, + setIntent, + networkInterfaces, + selectedAddress, + setSelectedAddress + }) const clearCopiedTargetResetTimer = useCallback((): void => { if (copiedTargetResetTimerRef.current === null) { @@ -146,20 +154,6 @@ export function RuntimePairingUrlGenerator({ } }, [loadNetworkInterfaces]) - useEffect(() => { - if (intent !== 'another' || networkInterfaces.length === 0) { - return - } - const addressStillAvailable = networkInterfaces.some( - (networkInterface) => networkInterface.address === selectedAddress - ) - if (!addressStillAvailable) { - const nextAddress = networkInterfaces[0]?.address ?? '' - runtimePairingLinkCache.selectedAddress = nextAddress - setSelectedAddress(nextAddress) - } - }, [intent, networkInterfaces, selectedAddress]) - useEffect(() => { void loadRuntimeAccessGrants() return () => { @@ -345,32 +339,6 @@ export function RuntimePairingUrlGenerator({ : 'space-y-4' const sharedAccessClassName = showGeneratorForm ? 'border-t border-border/40 pt-3' : '' - const updateSelectedAddress = (address: string): void => { - runtimePairingLinkCache.selectedAddress = address - setSelectedAddress(address) - if ( - intent === 'another' && - !networkInterfaces.some((networkInterface) => networkInterface.address === address) - ) { - runtimePairingLinkCache.customAddress = address - runtimePairingLinkCache.intent = 'custom' - setIntent('custom') - } else if (intent === 'custom') { - runtimePairingLinkCache.customAddress = address - } - } - - const updateIntent = (nextIntent: RuntimePairingIntent): void => { - setIntent(nextIntent) - setSelectedAddress( - selectRuntimePairingIntent( - nextIntent, - networkInterfaces, - runtimePairingLinkCache.customAddress - ) - ) - } - return (
{showHeader ? ( @@ -394,6 +362,7 @@ export function RuntimePairingUrlGenerator({ intent={intent} loopbackAddress={RUNTIME_PAIRING_LOOPBACK_ADDRESS} networkInterfaces={networkInterfaces} + retainedInterfaceName={preferredInterfaceName} selectedAddress={selectedAddress} refreshingNetworkInterfaces={refreshingNetworkInterfaces} isGeneratingPairing={isGeneratingPairing} diff --git a/src/renderer/src/components/settings/runtime-pairing-link-state.test.ts b/src/renderer/src/components/settings/runtime-pairing-link-state.test.ts new file mode 100644 index 000000000000..5caab17321e6 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-pairing-link-state.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + resolveAnotherDevicePairingAddress, + selectRuntimePairingIntent, + type RuntimePairingInterface +} from './runtime-pairing-link-state' + +const EN0: RuntimePairingInterface = { name: 'en0', address: '192.168.4.191' } +const BRIDGE: RuntimePairingInterface = { name: 'bridge0', address: '10.99.88.1' } +const PREFERENCE = { + preferredInterfaceName: 'bridge0', + preferredAddress: '10.99.88.1' +} + +describe('resolveAnotherDevicePairingAddress', () => { + it('restores the saved Thunderbolt interface when it is up, ahead of Ethernet', () => { + expect( + resolveAnotherDevicePairingAddress({ + interfaces: [EN0, BRIDGE], + selectedAddress: '', + ...PREFERENCE + }) + ).toBe(BRIDGE.address) + }) + + it('keeps the saved Thunderbolt address when the link drops and Ethernet remains', () => { + expect( + resolveAnotherDevicePairingAddress({ + interfaces: [EN0], + selectedAddress: EN0.address, + ...PREFERENCE + }) + ).toBe(BRIDGE.address) + }) + + it('keeps the address already selected on that interface when it has more than one', () => { + const ipv6: RuntimePairingInterface = { name: 'bridge0', address: 'fd00::1' } + expect( + resolveAnotherDevicePairingAddress({ + interfaces: [BRIDGE, ipv6], + selectedAddress: ipv6.address, + preferredInterfaceName: 'bridge0', + preferredAddress: BRIDGE.address + }) + ).toBe(ipv6.address) + }) + + it('auto-selects Ethernet when the user has not picked an interface', () => { + expect( + resolveAnotherDevicePairingAddress({ + interfaces: [BRIDGE, EN0], + selectedAddress: '', + preferredInterfaceName: null, + preferredAddress: '' + }) + ).toBe(EN0.address) + }) +}) + +describe('selectRuntimePairingIntent', () => { + it('returns to the saved Thunderbolt address when Another device is chosen again', () => { + expect(selectRuntimePairingIntent('another', [EN0, BRIDGE], '', PREFERENCE)).toBe( + BRIDGE.address + ) + }) + + it('does not keep loopback when switching to Another device while Thunderbolt is down', () => { + expect(selectRuntimePairingIntent('another', [EN0], '127.0.0.1', PREFERENCE)).toBe( + BRIDGE.address + ) + }) +}) diff --git a/src/renderer/src/components/settings/runtime-pairing-link-state.ts b/src/renderer/src/components/settings/runtime-pairing-link-state.ts index ff777fc72fed..c484ea56eb62 100644 --- a/src/renderer/src/components/settings/runtime-pairing-link-state.ts +++ b/src/renderer/src/components/settings/runtime-pairing-link-state.ts @@ -1,3 +1,5 @@ +import { normalizeMobilePairingCustomAddress } from '../../../../shared/mobile-pairing-custom-address' +import { selectAutoAdvertisedPairingAddress } from '../../../../shared/pairing-address-auto-selection' import type { RuntimePairingReach } from '../../../../shared/runtime-pairing-reach' export const RUNTIME_PAIRING_LOOPBACK_ADDRESS = '127.0.0.1' @@ -18,10 +20,39 @@ export type RuntimePairingUrlGeneratorProps = { // Why: pairing tokens remain in the main-process registry, so the last link can // survive settings navigation without writing credential material to storage. +const ADVERTISED_INTERFACE_NAME_MAX = 128 + +export type RuntimePairingInterface = { + name: string + address: string +} + +export type AnotherDevicePairingPreference = { + preferredInterfaceName: string | null + preferredAddress: string +} + +export function normalizeRuntimePairingAdvertisedInterfaceName(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + if (!trimmed || trimmed.length > ADVERTISED_INTERFACE_NAME_MAX) { + return null + } + return trimmed +} + +export function normalizeRuntimePairingAdvertisedAddress(value: unknown): string { + return normalizeMobilePairingCustomAddress(value) ?? '' +} + export const runtimePairingLinkCache: { selectedAddress: string customAddress: string intent: RuntimePairingIntent + advertisedInterfaceName: string | null + advertisedAddress: string generatedAddress: string | null runtimePairingUrl: string | null webClientUrl: string | null @@ -30,6 +61,8 @@ export const runtimePairingLinkCache: { selectedAddress: '', customAddress: '', intent: 'another', + advertisedInterfaceName: null, + advertisedAddress: '', generatedAddress: null, runtimePairingUrl: null, webClientUrl: null, @@ -55,17 +88,63 @@ export function cacheGeneratedRuntimePairingLink(args: { runtimePairingLinkCache.generatedAddress = args.address } +export function resolveAnotherDevicePairingAddress(args: { + interfaces: readonly RuntimePairingInterface[] + selectedAddress: string + preferredInterfaceName: string | null + preferredAddress: string +}): string { + if (args.preferredInterfaceName) { + const onPreferred = args.interfaces.filter( + (iface) => iface.name === args.preferredInterfaceName + ) + const selectedOnPreferred = onPreferred.find((iface) => iface.address === args.selectedAddress) + if (selectedOnPreferred) { + return selectedOnPreferred.address + } + if (onPreferred.length > 0) { + const remembered = onPreferred.find((iface) => iface.address === args.preferredAddress) + return (remembered ?? onPreferred[0]).address + } + // Why: Thunderbolt drops out of a non-empty interface list while Ethernet remains. + // Keep the chosen address instead of adopting whichever interface is now first. + if (args.preferredAddress) { + return args.preferredAddress + } + if ( + args.selectedAddress && + !args.interfaces.some((iface) => iface.address === args.selectedAddress) + ) { + return args.selectedAddress + } + return '' + } + if (args.interfaces.some((iface) => iface.address === args.selectedAddress)) { + return args.selectedAddress + } + return selectAutoAdvertisedPairingAddress(args.interfaces) ?? args.interfaces[0]?.address ?? '' +} + export function selectRuntimePairingIntent( intent: RuntimePairingIntent, - networkInterfaces: { address: string }[], - customAddress: string + networkInterfaces: readonly RuntimePairingInterface[], + customAddress: string, + preference: AnotherDevicePairingPreference = { + preferredInterfaceName: null, + preferredAddress: '' + } ): string { runtimePairingLinkCache.intent = intent const selectedAddress = intent === 'local' ? RUNTIME_PAIRING_LOOPBACK_ADDRESS : intent === 'another' - ? (networkInterfaces[0]?.address ?? '') + ? resolveAnotherDevicePairingAddress({ + interfaces: networkInterfaces, + selectedAddress: '', + preferredInterfaceName: preference.preferredInterfaceName, + preferredAddress: preference.preferredAddress + }) : customAddress runtimePairingLinkCache.selectedAddress = selectedAddress return selectedAddress diff --git a/src/renderer/src/components/settings/use-runtime-pairing-advertised-address.ts b/src/renderer/src/components/settings/use-runtime-pairing-advertised-address.ts new file mode 100644 index 000000000000..b939233abf77 --- /dev/null +++ b/src/renderer/src/components/settings/use-runtime-pairing-advertised-address.ts @@ -0,0 +1,109 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { + normalizeRuntimePairingAdvertisedAddress, + normalizeRuntimePairingAdvertisedInterfaceName, + resolveAnotherDevicePairingAddress, + runtimePairingLinkCache, + selectRuntimePairingIntent, + type RuntimePairingIntent, + type RuntimePairingInterface +} from './runtime-pairing-link-state' + +export function useRuntimePairingAdvertisedAddress(args: { + intent: RuntimePairingIntent + setIntent: (intent: RuntimePairingIntent) => void + networkInterfaces: readonly RuntimePairingInterface[] + selectedAddress: string + setSelectedAddress: (address: string) => void +}): { + preferredInterfaceName: string | null + updateSelectedAddress: (address: string) => void + updateIntent: (intent: RuntimePairingIntent) => void +} { + const { intent, setIntent, networkInterfaces, selectedAddress, setSelectedAddress } = args + const updateSettings = useAppStore((state) => state.updateSettings) + const storedInterfaceName = useAppStore((state) => + normalizeRuntimePairingAdvertisedInterfaceName( + state.settings?.runtimePairingAdvertisedInterfaceName + ) + ) + const storedAddress = useAppStore((state) => + normalizeRuntimePairingAdvertisedAddress(state.settings?.runtimePairingAdvertisedAddress) + ) + const preferredInterfaceName = + runtimePairingLinkCache.advertisedInterfaceName ?? storedInterfaceName + const preferredAddress = runtimePairingLinkCache.advertisedAddress || storedAddress + + useEffect(() => { + if (intent !== 'another' || networkInterfaces.length === 0) { + return + } + if (runtimePairingLinkCache.advertisedInterfaceName === null && storedInterfaceName) { + runtimePairingLinkCache.advertisedInterfaceName = storedInterfaceName + runtimePairingLinkCache.advertisedAddress = storedAddress + } + const nextAddress = resolveAnotherDevicePairingAddress({ + interfaces: networkInterfaces, + selectedAddress, + preferredInterfaceName: + runtimePairingLinkCache.advertisedInterfaceName ?? storedInterfaceName, + preferredAddress: runtimePairingLinkCache.advertisedAddress || storedAddress + }) + if (nextAddress === selectedAddress) { + return + } + runtimePairingLinkCache.selectedAddress = nextAddress + setSelectedAddress(nextAddress) + }, [ + intent, + networkInterfaces, + selectedAddress, + setSelectedAddress, + storedAddress, + storedInterfaceName + ]) + + const updateSelectedAddress = (address: string): void => { + runtimePairingLinkCache.selectedAddress = address + setSelectedAddress(address) + const matchedInterface = networkInterfaces.find( + (networkInterface) => networkInterface.address === address + ) + if (intent === 'another' && matchedInterface) { + runtimePairingLinkCache.advertisedInterfaceName = matchedInterface.name + runtimePairingLinkCache.advertisedAddress = address + void updateSettings({ + runtimePairingAdvertisedInterfaceName: matchedInterface.name, + runtimePairingAdvertisedAddress: address + }) + return + } + if (intent === 'another' && !matchedInterface) { + // Why: a down Thunderbolt interface stays in the picker as the retained address. + // Choosing that row again is not a switch to a custom endpoint. + if (address === preferredAddress) { + return + } + runtimePairingLinkCache.customAddress = address + runtimePairingLinkCache.intent = 'custom' + setIntent('custom') + } else if (intent === 'custom') { + runtimePairingLinkCache.customAddress = address + } + } + + const updateIntent = (nextIntent: RuntimePairingIntent): void => { + setIntent(nextIntent) + setSelectedAddress( + selectRuntimePairingIntent( + nextIntent, + networkInterfaces, + runtimePairingLinkCache.customAddress, + { preferredInterfaceName, preferredAddress } + ) + ) + } + + return { preferredInterfaceName, updateSelectedAddress, updateIntent } +} diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index c078070d298a..ee6e43fe852a 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -474,6 +474,10 @@ export type GlobalSettings = { mobilePairingCustomAddress?: string | null /** Saved custom addresses available in both mobile pairing pickers. */ mobilePairingCustomAddresses?: string[] + /** Share this host: interface the user picked for "Another device". Restored across restarts. */ + runtimePairingAdvertisedInterfaceName?: string | null + /** Last address of that interface. Kept while the link is briefly absent from discovery. */ + runtimePairingAdvertisedAddress?: string | null /** Name this runtime reports to paired clients; empty uses the host's detected name. */ machineName: string /** Experimental: floating animated pet in the bottom-right corner. Opt-in cosmetic; diff --git a/src/shared/pairing-address-auto-selection.test.ts b/src/shared/pairing-address-auto-selection.test.ts index 79dc763e6e62..8cedc1de09d4 100644 --- a/src/shared/pairing-address-auto-selection.test.ts +++ b/src/shared/pairing-address-auto-selection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + isThunderboltBridgeInterface, isVirtualBridgeInterface, selectAutoAdvertisedPairingAddress, type PairingNetworkInterface @@ -53,6 +54,27 @@ describe('selectAutoAdvertisedPairingAddress', () => { expect(selectAutoAdvertisedPairingAddress([AMBIGUOUS_SWITCH])).toBeUndefined() }) + it('does not treat macOS Thunderbolt Bridge as a container bridge', () => { + expect(isThunderboltBridgeInterface('bridge0')).toBe(true) + expect(isThunderboltBridgeInterface('Bridge12')).toBe(true) + expect(isVirtualBridgeInterface('bridge0')).toBe(false) + expect(isVirtualBridgeInterface('bridge1')).toBe(false) + expect(isVirtualBridgeInterface('bridge')).toBe(true) + expect(isVirtualBridgeInterface('br-lan')).toBe(true) + expect(isVirtualBridgeInterface('docker0')).toBe(true) + }) + + it('prefers LAN and tailnet over Thunderbolt Bridge, and uses the bridge when it is the only direct address', () => { + const thunderbolt = { name: 'bridge0', address: '10.99.88.1' } + const lan = { name: 'en0', address: '192.168.4.191' } + const tailnet = { name: 'tailscale0', address: '100.64.1.20' } + expect(selectAutoAdvertisedPairingAddress([thunderbolt, lan])).toBe(lan.address) + expect(selectAutoAdvertisedPairingAddress([thunderbolt, tailnet])).toBe(tailnet.address) + expect(selectAutoAdvertisedPairingAddress([HOST_LOCAL_BRIDGE, thunderbolt])).toBe( + thunderbolt.address + ) + }) + it.each([ 'vEthernet (Default Switch)', 'vEthernet (default switch)', diff --git a/src/shared/pairing-address-auto-selection.ts b/src/shared/pairing-address-auto-selection.ts index a179a66b18fe..22e200ef8715 100644 --- a/src/shared/pairing-address-auto-selection.ts +++ b/src/shared/pairing-address-auto-selection.ts @@ -8,13 +8,25 @@ export type PairingNetworkInterface = { // Why: known bridge labels are host-local, while vEthernet needs positive route evidence because // External Switch management adapters are reachable; subnets overlap real corporate LANs. +// `bridge` still matches Linux bridge names. Apple's Thunderbolt Bridge is exactly `bridge` plus +// digits (`bridge0`), so it is excluded before this pattern runs. const VIRTUAL_BRIDGE_INTERFACE_PATTERN = /^(?:docker|br-|virbr|vmnet|vboxnet|veth|lxcbr|cni|flannel|cali|bridge)|VMware Network Adapter|VirtualBox Host-Only/i +const THUNDERBOLT_BRIDGE_INTERFACE_PATTERN = /^bridge\d+$/i const HYPER_V_INTERFACE_PATTERN = /^vEthernet /i const HOST_LOCAL_HYPER_V_INTERFACE_PATTERN = /^vEthernet \((?:Default Switch|WSL(?: \(Hyper-V firewall\))?)\)$/i +export function isThunderboltBridgeInterface(name: string): boolean { + return THUNDERBOLT_BRIDGE_INTERFACE_PATTERN.test(name) +} + export function isVirtualBridgeInterface(name: string, hasDefaultRoute?: boolean): boolean { + // Why: macOS names the Thunderbolt cable `bridge0`. That is a real link between two Macs, + // not a container bridge. `br-`, `docker0`, and a bare `bridge` stay excluded. + if (isThunderboltBridgeInterface(name)) { + return false + } if (HOST_LOCAL_HYPER_V_INTERFACE_PATTERN.test(name)) { return true } @@ -34,8 +46,12 @@ export function selectAutoAdvertisedPairingAddress( const advertisable = interfaces.filter( (iface) => !isVirtualBridgeInterface(iface.name, iface.hasDefaultRoute) ) - return ( - advertisable.find((iface) => isTailnetIPv4Address(iface.address))?.address ?? - advertisable[0]?.address - ) + const tailnet = advertisable.find((iface) => isTailnetIPv4Address(iface.address)) + if (tailnet) { + return tailnet.address + } + // Why: a phone on Wi-Fi cannot reach Thunderbolt Bridge. Auto-advertise uses `bridgeN` + // only when it is the only direct address, so Ethernet/Wi-Fi still wins when both are up. + const direct = advertisable.find((iface) => !isThunderboltBridgeInterface(iface.name)) + return direct?.address ?? advertisable[0]?.address } From 85c8e9982c6168e206555a00b8e52bdfbfa695ab Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 21:36:27 +0900 Subject: [PATCH 2/6] fix(settings): keep a changed Thunderbolt address and ignore Linux bridge0 Linux bridge0 is a virtual bridge, so it is no longer advertised as a direct pairing address. A saved interface that comes back with a new IP is remembered, and an empty discovery still restores the saved address. --- .../RuntimePairingUrlGenerator.test.tsx | 40 +++++++++++++++++++ .../use-runtime-pairing-advertised-address.ts | 37 ++++++++++++++--- .../pairing-address-auto-selection.test.ts | 31 +++++++++----- src/shared/pairing-address-auto-selection.ts | 27 ++++++++----- 4 files changed, 111 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx index 5e2bb18ee649..c82ed8f596c9 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx @@ -154,6 +154,46 @@ describe('RuntimePairingUrlGenerator', () => { ) }) + it('restores the saved address when discovery returns no interfaces', async () => { + runtimePairingLinkCache.selectedAddress = '' + mocks.settings = { + runtimePairingAdvertisedInterfaceName: 'bridge0', + runtimePairingAdvertisedAddress: '10.99.88.1' + } + mocks.listNetworkInterfaces.mockResolvedValue({ interfaces: [] }) + + render() + + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce()) + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.1') + }) + + it('remembers a new address on the saved interface', async () => { + runtimePairingLinkCache.selectedAddress = '' + mocks.settings = { + runtimePairingAdvertisedInterfaceName: 'bridge0', + runtimePairingAdvertisedAddress: '10.99.88.1' + } + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [ + { name: 'en0', address: '192.168.4.191' }, + { name: 'bridge0', address: '10.99.88.2' } + ] + }) + + render() + + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.2') + ) + await waitFor(() => + expect(mocks.updateSettings).toHaveBeenCalledWith({ + runtimePairingAdvertisedInterfaceName: 'bridge0', + runtimePairingAdvertisedAddress: '10.99.88.2' + }) + ) + }) + it('keeps an explicit Thunderbolt pick when refresh reports only Ethernet', async () => { runtimePairingLinkCache.selectedAddress = '' mocks.listNetworkInterfaces diff --git a/src/renderer/src/components/settings/use-runtime-pairing-advertised-address.ts b/src/renderer/src/components/settings/use-runtime-pairing-advertised-address.ts index b939233abf77..cbfa8fc9c289 100644 --- a/src/renderer/src/components/settings/use-runtime-pairing-advertised-address.ts +++ b/src/renderer/src/components/settings/use-runtime-pairing-advertised-address.ts @@ -36,20 +36,46 @@ export function useRuntimePairingAdvertisedAddress(args: { const preferredAddress = runtimePairingLinkCache.advertisedAddress || storedAddress useEffect(() => { - if (intent !== 'another' || networkInterfaces.length === 0) { + if (intent !== 'another') { return } if (runtimePairingLinkCache.advertisedInterfaceName === null && storedInterfaceName) { runtimePairingLinkCache.advertisedInterfaceName = storedInterfaceName runtimePairingLinkCache.advertisedAddress = storedAddress } + if (networkInterfaces.length === 0) { + const restored = + runtimePairingLinkCache.selectedAddress || + runtimePairingLinkCache.advertisedAddress || + storedAddress + if (!selectedAddress && restored) { + runtimePairingLinkCache.selectedAddress = restored + setSelectedAddress(restored) + } + return + } + const preferredInterfaceNameNow = + runtimePairingLinkCache.advertisedInterfaceName ?? storedInterfaceName + const rememberedAddress = runtimePairingLinkCache.advertisedAddress || storedAddress const nextAddress = resolveAnotherDevicePairingAddress({ interfaces: networkInterfaces, selectedAddress, - preferredInterfaceName: - runtimePairingLinkCache.advertisedInterfaceName ?? storedInterfaceName, - preferredAddress: runtimePairingLinkCache.advertisedAddress || storedAddress + preferredInterfaceName: preferredInterfaceNameNow, + preferredAddress: rememberedAddress }) + const adopted = networkInterfaces.find( + (networkInterface) => + networkInterface.name === preferredInterfaceNameNow && + networkInterface.address === nextAddress + ) + if (adopted && rememberedAddress !== nextAddress) { + runtimePairingLinkCache.advertisedInterfaceName = adopted.name + runtimePairingLinkCache.advertisedAddress = nextAddress + void updateSettings({ + runtimePairingAdvertisedInterfaceName: adopted.name, + runtimePairingAdvertisedAddress: nextAddress + }) + } if (nextAddress === selectedAddress) { return } @@ -61,7 +87,8 @@ export function useRuntimePairingAdvertisedAddress(args: { selectedAddress, setSelectedAddress, storedAddress, - storedInterfaceName + storedInterfaceName, + updateSettings ]) const updateSelectedAddress = (address: string): void => { diff --git a/src/shared/pairing-address-auto-selection.test.ts b/src/shared/pairing-address-auto-selection.test.ts index 8cedc1de09d4..c8f4b5c227dd 100644 --- a/src/shared/pairing-address-auto-selection.test.ts +++ b/src/shared/pairing-address-auto-selection.test.ts @@ -55,22 +55,33 @@ describe('selectAutoAdvertisedPairingAddress', () => { }) it('does not treat macOS Thunderbolt Bridge as a container bridge', () => { - expect(isThunderboltBridgeInterface('bridge0')).toBe(true) - expect(isThunderboltBridgeInterface('Bridge12')).toBe(true) - expect(isVirtualBridgeInterface('bridge0')).toBe(false) - expect(isVirtualBridgeInterface('bridge1')).toBe(false) - expect(isVirtualBridgeInterface('bridge')).toBe(true) - expect(isVirtualBridgeInterface('br-lan')).toBe(true) - expect(isVirtualBridgeInterface('docker0')).toBe(true) + expect(isThunderboltBridgeInterface('bridge0', 'darwin')).toBe(true) + expect(isThunderboltBridgeInterface('Bridge12', 'darwin')).toBe(true) + expect(isVirtualBridgeInterface('bridge0', undefined, 'darwin')).toBe(false) + expect(isVirtualBridgeInterface('bridge1', undefined, 'darwin')).toBe(false) + expect(isVirtualBridgeInterface('bridge', undefined, 'darwin')).toBe(true) + expect(isVirtualBridgeInterface('br-lan', undefined, 'darwin')).toBe(true) + expect(isVirtualBridgeInterface('docker0', undefined, 'darwin')).toBe(true) + }) + + it('keeps a Linux bridge0 out of the direct pairing address', () => { + const linuxBridge = { name: 'bridge0', address: '10.0.0.1' } + const lan = { name: 'eth0', address: '192.168.4.191' } + expect(isThunderboltBridgeInterface('bridge0', 'linux')).toBe(false) + expect(isVirtualBridgeInterface('bridge0', undefined, 'linux')).toBe(true) + expect(selectAutoAdvertisedPairingAddress([linuxBridge], 'linux')).toBeUndefined() + expect(selectAutoAdvertisedPairingAddress([linuxBridge, lan], 'linux')).toBe(lan.address) }) it('prefers LAN and tailnet over Thunderbolt Bridge, and uses the bridge when it is the only direct address', () => { const thunderbolt = { name: 'bridge0', address: '10.99.88.1' } const lan = { name: 'en0', address: '192.168.4.191' } const tailnet = { name: 'tailscale0', address: '100.64.1.20' } - expect(selectAutoAdvertisedPairingAddress([thunderbolt, lan])).toBe(lan.address) - expect(selectAutoAdvertisedPairingAddress([thunderbolt, tailnet])).toBe(tailnet.address) - expect(selectAutoAdvertisedPairingAddress([HOST_LOCAL_BRIDGE, thunderbolt])).toBe( + expect(selectAutoAdvertisedPairingAddress([thunderbolt, lan], 'darwin')).toBe(lan.address) + expect(selectAutoAdvertisedPairingAddress([thunderbolt, tailnet], 'darwin')).toBe( + tailnet.address + ) + expect(selectAutoAdvertisedPairingAddress([HOST_LOCAL_BRIDGE, thunderbolt], 'darwin')).toBe( thunderbolt.address ) }) diff --git a/src/shared/pairing-address-auto-selection.ts b/src/shared/pairing-address-auto-selection.ts index 22e200ef8715..1d6cb52fbb6b 100644 --- a/src/shared/pairing-address-auto-selection.ts +++ b/src/shared/pairing-address-auto-selection.ts @@ -9,7 +9,7 @@ export type PairingNetworkInterface = { // Why: known bridge labels are host-local, while vEthernet needs positive route evidence because // External Switch management adapters are reachable; subnets overlap real corporate LANs. // `bridge` still matches Linux bridge names. Apple's Thunderbolt Bridge is exactly `bridge` plus -// digits (`bridge0`), so it is excluded before this pattern runs. +// digits (`bridge0`) on macOS, so only that platform is excluded before this pattern runs. const VIRTUAL_BRIDGE_INTERFACE_PATTERN = /^(?:docker|br-|virbr|vmnet|vboxnet|veth|lxcbr|cni|flannel|cali|bridge)|VMware Network Adapter|VirtualBox Host-Only/i const THUNDERBOLT_BRIDGE_INTERFACE_PATTERN = /^bridge\d+$/i @@ -17,14 +17,22 @@ const HYPER_V_INTERFACE_PATTERN = /^vEthernet /i const HOST_LOCAL_HYPER_V_INTERFACE_PATTERN = /^vEthernet \((?:Default Switch|WSL(?: \(Hyper-V firewall\))?)\)$/i -export function isThunderboltBridgeInterface(name: string): boolean { - return THUNDERBOLT_BRIDGE_INTERFACE_PATTERN.test(name) +export function isThunderboltBridgeInterface( + name: string, + platform: NodeJS.Platform = process.platform +): boolean { + return platform === 'darwin' && THUNDERBOLT_BRIDGE_INTERFACE_PATTERN.test(name) } -export function isVirtualBridgeInterface(name: string, hasDefaultRoute?: boolean): boolean { +export function isVirtualBridgeInterface( + name: string, + hasDefaultRoute?: boolean, + platform: NodeJS.Platform = process.platform +): boolean { // Why: macOS names the Thunderbolt cable `bridge0`. That is a real link between two Macs, - // not a container bridge. `br-`, `docker0`, and a bare `bridge` stay excluded. - if (isThunderboltBridgeInterface(name)) { + // not a container bridge. Linux `bridge0` stays a virtual bridge. `br-`, `docker0`, and a + // bare `bridge` stay excluded on every platform. + if (isThunderboltBridgeInterface(name, platform)) { return false } if (HOST_LOCAL_HYPER_V_INTERFACE_PATTERN.test(name)) { @@ -41,10 +49,11 @@ export function isVirtualBridgeInterface(name: string, hasDefaultRoute?: boolean // pickable for an explicit choice but are never chosen here; `undefined` means "advertise no direct // address", which Relay tolerates (it carries its own invite) and the LAN-only path refuses. export function selectAutoAdvertisedPairingAddress( - interfaces: readonly PairingNetworkInterface[] + interfaces: readonly PairingNetworkInterface[], + platform: NodeJS.Platform = process.platform ): string | undefined { const advertisable = interfaces.filter( - (iface) => !isVirtualBridgeInterface(iface.name, iface.hasDefaultRoute) + (iface) => !isVirtualBridgeInterface(iface.name, iface.hasDefaultRoute, platform) ) const tailnet = advertisable.find((iface) => isTailnetIPv4Address(iface.address)) if (tailnet) { @@ -52,6 +61,6 @@ export function selectAutoAdvertisedPairingAddress( } // Why: a phone on Wi-Fi cannot reach Thunderbolt Bridge. Auto-advertise uses `bridgeN` // only when it is the only direct address, so Ethernet/Wi-Fi still wins when both are up. - const direct = advertisable.find((iface) => !isThunderboltBridgeInterface(iface.name)) + const direct = advertisable.find((iface) => !isThunderboltBridgeInterface(iface.name, platform)) return direct?.address ?? advertisable[0]?.address } From 2b80697690f6865a1864030fa068ea8760914b07 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 22:06:59 +0900 Subject: [PATCH 3/6] fix(settings): pass the host platform into pairing address selection The sandboxed renderer has no process global. Reading process.platform as a default crashes the pairing picker, and guessing a platform would advertise a different address from the one main already put on the QR. --- .../runtime/pairing-network-interfaces.ts | 8 +++- ...mobile-pairing-address-preference.test.tsx | 9 +++- .../RuntimePairingUrlGenerator.test.tsx | 1 + ...mobile-network-interface-selection.test.ts | 41 ++++++++++++++++++- .../mobile-network-interface-selection.ts | 14 +++++-- .../settings/read-pairing-host-platform.ts | 11 +++++ .../runtime-pairing-link-state.test.ts | 16 +++++--- .../settings/runtime-pairing-link-state.ts | 13 ++++-- .../use-runtime-pairing-advertised-address.ts | 7 +++- .../pairing-address-auto-selection.test.ts | 22 +++++----- src/shared/pairing-address-auto-selection.ts | 14 +++---- 11 files changed, 120 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/components/settings/read-pairing-host-platform.ts diff --git a/src/main/runtime/pairing-network-interfaces.ts b/src/main/runtime/pairing-network-interfaces.ts index a7d31c7a5979..b7b57599c39d 100644 --- a/src/main/runtime/pairing-network-interfaces.ts +++ b/src/main/runtime/pairing-network-interfaces.ts @@ -69,7 +69,10 @@ function rankInterface({ name, address, hasDefaultRoute }: NetworkInterface): nu // Why: Thunderbolt Bridge is a real link, but it is not the phone's default. Keep it behind // LAN and IPv6 in the picker order so `bridge0` does not become interfaces[0] while en0 is up. const bridgePenalty = - isVirtualBridgeInterface(name, hasDefaultRoute) || isThunderboltBridgeInterface(name) ? 2 : 0 + isVirtualBridgeInterface(name, hasDefaultRoute, process.platform) || + isThunderboltBridgeInterface(name, process.platform) + ? 2 + : 0 return (address.includes(':') ? 2 : 1) + bridgePenalty } @@ -78,7 +81,8 @@ export async function getDefaultPairingAddress( ): Promise { return ( selectAutoAdvertisedPairingAddress( - await getPairingNetworkInterfaces(getDefaultRouteInterfaceNames) + await getPairingNetworkInterfaces(getDefaultRouteInterfaceNames), + process.platform ) ?? null ) } diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx b/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx index c3dfe6d27341..87108a2be6d6 100644 --- a/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx +++ b/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { act, cleanup, renderHook } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' type StoreState = { @@ -48,6 +48,13 @@ function renderPreference(networkInterfaces: readonly MobileNetworkInterface[] = return { result, onSelectionInvalidated } } +beforeEach(() => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { platform: { get: () => ({ platform: 'darwin' as const }) } } + }) +}) + afterEach(() => cleanup()) describe('useMobilePairingAddressPreference', () => { diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx index c82ed8f596c9..74bf5c2e34cb 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx @@ -77,6 +77,7 @@ describe('RuntimePairingUrlGenerator', () => { Object.defineProperty(window, 'api', { configurable: true, value: { + platform: { get: () => ({ platform: 'darwin' as const }) }, mobile: { listNetworkInterfaces: mocks.listNetworkInterfaces, listRuntimeAccessGrants: mocks.listRuntimeAccessGrants, diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts index 5ad5fb39f6a6..bf2685e7e29e 100644 --- a/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts +++ b/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts @@ -1,9 +1,19 @@ -import { describe, it, expect } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { selectRefreshedNetworkAddress, type MobileNetworkInterface } from './mobile-network-interface-selection' +beforeEach(() => { + vi.stubGlobal('window', { + api: { platform: { get: () => ({ platform: 'linux' as const }) } } + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } const BRIDGE: MobileNetworkInterface = { name: 'docker0', address: '172.17.0.1' } @@ -117,4 +127,33 @@ describe('selectRefreshedNetworkAddress', () => { ).toBe(networkInterface.address) } ) + + it('prefers Ethernet over Thunderbolt Bridge when the renderer has no process', () => { + const priorProcess = globalThis.process + const priorWindow = globalThis.window + delete (globalThis as { process?: NodeJS.Process }).process + vi.stubGlobal('window', { + api: { platform: { get: () => ({ platform: 'darwin' as const }) } } + }) + let selectedWithLan: string | undefined + let selectedBridgeOnly: string | undefined + try { + selectedWithLan = selectRefreshedNetworkAddress(undefined, [ + { name: 'bridge0', address: '10.99.88.1' }, + { name: 'en0', address: '192.168.4.191' } + ]) + selectedBridgeOnly = selectRefreshedNetworkAddress(undefined, [ + { name: 'bridge0', address: '10.99.88.1' } + ]) + } finally { + globalThis.process = priorProcess + if (priorWindow === undefined) { + vi.unstubAllGlobals() + } else { + vi.stubGlobal('window', priorWindow) + } + } + expect(selectedWithLan).toBe('192.168.4.191') + expect(selectedBridgeOnly).toBe('10.99.88.1') + }) }) diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.ts index 4d1d7c617904..cbe44f218e13 100644 --- a/src/renderer/src/components/settings/mobile-network-interface-selection.ts +++ b/src/renderer/src/components/settings/mobile-network-interface-selection.ts @@ -2,6 +2,7 @@ import { isVirtualBridgeInterface, selectAutoAdvertisedPairingAddress } from '../../../../shared/pairing-address-auto-selection' +import { readPairingHostPlatform } from './read-pairing-host-platform' export type MobileNetworkInterface = { name: string @@ -16,7 +17,8 @@ export function selectRefreshedNetworkAddress( // (not an OS-enumerated one) pass this so the refresh path keeps their // selection instead of snapping back to a tailnet/LAN fallback. currentAddressIsManual: boolean = false, - currentAddressWasExplicitlySelected: boolean = false + currentAddressWasExplicitlySelected: boolean = false, + platform?: NodeJS.Platform ): string | undefined { // Why: transient discovery failure must not clobber a deliberate user choice. if (interfaces.length === 0) { @@ -27,15 +29,21 @@ export function selectRefreshedNetworkAddress( if (currentAddressIsManual) { return currentAddress } + // Read only once classification needs a host. An empty refresh must not require it. + const hostPlatform = platform ?? readPairingHostPlatform() const currentInterface = interfaces.find((iface) => iface.address === currentAddress) if ( currentInterface && (currentAddressWasExplicitlySelected || - !isVirtualBridgeInterface(currentInterface.name, currentInterface.hasDefaultRoute)) + !isVirtualBridgeInterface( + currentInterface.name, + currentInterface.hasDefaultRoute, + hostPlatform + )) ) { return currentAddress } // Why: shared with main so the picker never shows a default the QR didn't advertise. Undefined on // a bridge-only host: Relay pairs without a direct address rather than offering an unreachable one. - return selectAutoAdvertisedPairingAddress(interfaces) + return selectAutoAdvertisedPairingAddress(interfaces, hostPlatform) } diff --git a/src/renderer/src/components/settings/read-pairing-host-platform.ts b/src/renderer/src/components/settings/read-pairing-host-platform.ts new file mode 100644 index 000000000000..9f3e2981633c --- /dev/null +++ b/src/renderer/src/components/settings/read-pairing-host-platform.ts @@ -0,0 +1,11 @@ +// Why: the pairing helpers run in the sandboxed renderer, which has no `process`. +// The preload publishes the host platform. Guessing darwin or win32 would advertise +// a different address from the one main already put on the QR. +export function readPairingHostPlatform(): NodeJS.Platform { + const platform = + typeof window === 'undefined' ? undefined : window.api?.platform?.get?.()?.platform + if (!platform) { + throw new Error('The host platform is not available to the pairing picker.') + } + return platform +} diff --git a/src/renderer/src/components/settings/runtime-pairing-link-state.test.ts b/src/renderer/src/components/settings/runtime-pairing-link-state.test.ts index 5caab17321e6..2853273ca433 100644 --- a/src/renderer/src/components/settings/runtime-pairing-link-state.test.ts +++ b/src/renderer/src/components/settings/runtime-pairing-link-state.test.ts @@ -18,7 +18,8 @@ describe('resolveAnotherDevicePairingAddress', () => { resolveAnotherDevicePairingAddress({ interfaces: [EN0, BRIDGE], selectedAddress: '', - ...PREFERENCE + ...PREFERENCE, + platform: 'darwin' }) ).toBe(BRIDGE.address) }) @@ -28,7 +29,8 @@ describe('resolveAnotherDevicePairingAddress', () => { resolveAnotherDevicePairingAddress({ interfaces: [EN0], selectedAddress: EN0.address, - ...PREFERENCE + ...PREFERENCE, + platform: 'darwin' }) ).toBe(BRIDGE.address) }) @@ -40,7 +42,8 @@ describe('resolveAnotherDevicePairingAddress', () => { interfaces: [BRIDGE, ipv6], selectedAddress: ipv6.address, preferredInterfaceName: 'bridge0', - preferredAddress: BRIDGE.address + preferredAddress: BRIDGE.address, + platform: 'darwin' }) ).toBe(ipv6.address) }) @@ -51,7 +54,8 @@ describe('resolveAnotherDevicePairingAddress', () => { interfaces: [BRIDGE, EN0], selectedAddress: '', preferredInterfaceName: null, - preferredAddress: '' + preferredAddress: '', + platform: 'darwin' }) ).toBe(EN0.address) }) @@ -59,13 +63,13 @@ describe('resolveAnotherDevicePairingAddress', () => { describe('selectRuntimePairingIntent', () => { it('returns to the saved Thunderbolt address when Another device is chosen again', () => { - expect(selectRuntimePairingIntent('another', [EN0, BRIDGE], '', PREFERENCE)).toBe( + expect(selectRuntimePairingIntent('another', [EN0, BRIDGE], '', PREFERENCE, 'darwin')).toBe( BRIDGE.address ) }) it('does not keep loopback when switching to Another device while Thunderbolt is down', () => { - expect(selectRuntimePairingIntent('another', [EN0], '127.0.0.1', PREFERENCE)).toBe( + expect(selectRuntimePairingIntent('another', [EN0], '127.0.0.1', PREFERENCE, 'darwin')).toBe( BRIDGE.address ) }) diff --git a/src/renderer/src/components/settings/runtime-pairing-link-state.ts b/src/renderer/src/components/settings/runtime-pairing-link-state.ts index c484ea56eb62..9910181cca4c 100644 --- a/src/renderer/src/components/settings/runtime-pairing-link-state.ts +++ b/src/renderer/src/components/settings/runtime-pairing-link-state.ts @@ -93,6 +93,7 @@ export function resolveAnotherDevicePairingAddress(args: { selectedAddress: string preferredInterfaceName: string | null preferredAddress: string + platform: NodeJS.Platform }): string { if (args.preferredInterfaceName) { const onPreferred = args.interfaces.filter( @@ -122,7 +123,11 @@ export function resolveAnotherDevicePairingAddress(args: { if (args.interfaces.some((iface) => iface.address === args.selectedAddress)) { return args.selectedAddress } - return selectAutoAdvertisedPairingAddress(args.interfaces) ?? args.interfaces[0]?.address ?? '' + return ( + selectAutoAdvertisedPairingAddress(args.interfaces, args.platform) ?? + args.interfaces[0]?.address ?? + '' + ) } export function selectRuntimePairingIntent( @@ -132,7 +137,8 @@ export function selectRuntimePairingIntent( preference: AnotherDevicePairingPreference = { preferredInterfaceName: null, preferredAddress: '' - } + }, + platform: NodeJS.Platform ): string { runtimePairingLinkCache.intent = intent const selectedAddress = @@ -143,7 +149,8 @@ export function selectRuntimePairingIntent( interfaces: networkInterfaces, selectedAddress: '', preferredInterfaceName: preference.preferredInterfaceName, - preferredAddress: preference.preferredAddress + preferredAddress: preference.preferredAddress, + platform }) : customAddress runtimePairingLinkCache.selectedAddress = selectedAddress diff --git a/src/renderer/src/components/settings/use-runtime-pairing-advertised-address.ts b/src/renderer/src/components/settings/use-runtime-pairing-advertised-address.ts index cbfa8fc9c289..113da5e1c698 100644 --- a/src/renderer/src/components/settings/use-runtime-pairing-advertised-address.ts +++ b/src/renderer/src/components/settings/use-runtime-pairing-advertised-address.ts @@ -9,6 +9,7 @@ import { type RuntimePairingIntent, type RuntimePairingInterface } from './runtime-pairing-link-state' +import { readPairingHostPlatform } from './read-pairing-host-platform' export function useRuntimePairingAdvertisedAddress(args: { intent: RuntimePairingIntent @@ -61,7 +62,8 @@ export function useRuntimePairingAdvertisedAddress(args: { interfaces: networkInterfaces, selectedAddress, preferredInterfaceName: preferredInterfaceNameNow, - preferredAddress: rememberedAddress + preferredAddress: rememberedAddress, + platform: readPairingHostPlatform() }) const adopted = networkInterfaces.find( (networkInterface) => @@ -127,7 +129,8 @@ export function useRuntimePairingAdvertisedAddress(args: { nextIntent, networkInterfaces, runtimePairingLinkCache.customAddress, - { preferredInterfaceName, preferredAddress } + { preferredInterfaceName, preferredAddress }, + readPairingHostPlatform() ) ) } diff --git a/src/shared/pairing-address-auto-selection.test.ts b/src/shared/pairing-address-auto-selection.test.ts index c8f4b5c227dd..ca9028cda64e 100644 --- a/src/shared/pairing-address-auto-selection.test.ts +++ b/src/shared/pairing-address-auto-selection.test.ts @@ -36,22 +36,22 @@ const AMBIGUOUS_SWITCH: PairingNetworkInterface = { describe('selectAutoAdvertisedPairingAddress', () => { it('allows a reachable Hyper-V external-switch management address', () => { - expect(selectAutoAdvertisedPairingAddress([EXTERNAL_SWITCH])).toBe(EXTERNAL_SWITCH.address) + expect(selectAutoAdvertisedPairingAddress([EXTERNAL_SWITCH], 'win32')).toBe( + EXTERNAL_SWITCH.address + ) }) it('selects a physical address while filtering Default Switch, WSL, and host-local bridges', () => { expect( - selectAutoAdvertisedPairingAddress([ - DEFAULT_SWITCH, - WSL_SWITCH, - HOST_LOCAL_BRIDGE, - PHYSICAL_LAN - ]) + selectAutoAdvertisedPairingAddress( + [DEFAULT_SWITCH, WSL_SWITCH, HOST_LOCAL_BRIDGE, PHYSICAL_LAN], + 'win32' + ) ).toBe(PHYSICAL_LAN.address) }) it('filters a vEthernet adapter when route reachability is ambiguous', () => { - expect(selectAutoAdvertisedPairingAddress([AMBIGUOUS_SWITCH])).toBeUndefined() + expect(selectAutoAdvertisedPairingAddress([AMBIGUOUS_SWITCH], 'win32')).toBeUndefined() }) it('does not treat macOS Thunderbolt Bridge as a container bridge', () => { @@ -92,7 +92,7 @@ describe('selectAutoAdvertisedPairingAddress', () => { 'vEthernet (WSL)', 'vEthernet (WSL (Hyper-V firewall))' ])('keeps the known host-local %s label filtered with a default route', (name) => { - expect(isVirtualBridgeInterface(name, true)).toBe(true) + expect(isVirtualBridgeInterface(name, true, 'win32')).toBe(true) }) it.each([ @@ -101,7 +101,7 @@ describe('selectAutoAdvertisedPairingAddress', () => { 'vEthernet (WSL LAN)', 'vEthernet (WSL External)' ])('does not treat the route-backed near-match %s as a known host-local label', (name) => { - expect(isVirtualBridgeInterface(name, true)).toBe(false) + expect(isVirtualBridgeInterface(name, true, 'win32')).toBe(false) }) it.each([ @@ -112,6 +112,6 @@ describe('selectAutoAdvertisedPairingAddress', () => { ['host-local', HOST_LOCAL_BRIDGE, true], ['ambiguous', AMBIGUOUS_SWITCH, true] ] as const)('classifies the %s fixture', (_label, fixture, expected) => { - expect(isVirtualBridgeInterface(fixture.name, fixture.hasDefaultRoute)).toBe(expected) + expect(isVirtualBridgeInterface(fixture.name, fixture.hasDefaultRoute, 'win32')).toBe(expected) }) }) diff --git a/src/shared/pairing-address-auto-selection.ts b/src/shared/pairing-address-auto-selection.ts index 1d6cb52fbb6b..57f850ce1b12 100644 --- a/src/shared/pairing-address-auto-selection.ts +++ b/src/shared/pairing-address-auto-selection.ts @@ -17,17 +17,14 @@ const HYPER_V_INTERFACE_PATTERN = /^vEthernet /i const HOST_LOCAL_HYPER_V_INTERFACE_PATTERN = /^vEthernet \((?:Default Switch|WSL(?: \(Hyper-V firewall\))?)\)$/i -export function isThunderboltBridgeInterface( - name: string, - platform: NodeJS.Platform = process.platform -): boolean { +export function isThunderboltBridgeInterface(name: string, platform: NodeJS.Platform): boolean { return platform === 'darwin' && THUNDERBOLT_BRIDGE_INTERFACE_PATTERN.test(name) } export function isVirtualBridgeInterface( name: string, - hasDefaultRoute?: boolean, - platform: NodeJS.Platform = process.platform + hasDefaultRoute: boolean | undefined, + platform: NodeJS.Platform ): boolean { // Why: macOS names the Thunderbolt cable `bridge0`. That is a real link between two Macs, // not a container bridge. Linux `bridge0` stays a virtual bridge. `br-`, `docker0`, and a @@ -48,9 +45,12 @@ export function isVirtualBridgeInterface( // agree — a divergence would display one address while the QR advertises another. Bridges stay // pickable for an explicit choice but are never chosen here; `undefined` means "advertise no direct // address", which Relay tolerates (it carries its own invite) and the LAN-only path refuses. +// Why: this module is imported by the sandboxed renderer, which has no `process`. +// Callers pass the host platform. A guessed default would classify macOS `bridge0` +// differently from the address main already advertised. export function selectAutoAdvertisedPairingAddress( interfaces: readonly PairingNetworkInterface[], - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform ): string | undefined { const advertisable = interfaces.filter( (iface) => !isVirtualBridgeInterface(iface.name, iface.hasDefaultRoute, platform) From 88469530a102436220423fd60377e39b67db5a9d Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 22:33:28 +0900 Subject: [PATCH 4/6] fix(settings): replace an auto-selected Thunderbolt address when Ethernet appears An automatic bridge0 pick is only the fallback while it is the only direct link. A later Ethernet interface takes over, and an address the user chose stays. The mobile page fixtures now supply the host platform the picker reads. --- .../src/components/mobile/MobilePage.test.tsx | 2 +- .../components/settings/MobilePane.test.tsx | 4 +-- ...mobile-network-interface-selection.test.ts | 30 +++++++++++++++++++ .../mobile-network-interface-selection.ts | 21 +++++++------ 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/mobile/MobilePage.test.tsx b/src/renderer/src/components/mobile/MobilePage.test.tsx index 5805bca46e6d..c4fb35a78c13 100644 --- a/src/renderer/src/components/mobile/MobilePage.test.tsx +++ b/src/renderer/src/components/mobile/MobilePage.test.tsx @@ -172,7 +172,7 @@ describe('MobilePage pairing connection mode', () => { listDevices: vi.fn().mockResolvedValue({ devices: [] }), listNetworkInterfaces }, - shell: { openUrl: vi.fn() }, + shell: { openUrl: vi.fn() }, platform: { get: () => ({ platform: 'darwin' as const }) }, ui: { writeClipboardText: vi.fn().mockResolvedValue(undefined) } } }) diff --git a/src/renderer/src/components/settings/MobilePane.test.tsx b/src/renderer/src/components/settings/MobilePane.test.tsx index 28f83ccf6978..2039fed50ee3 100644 --- a/src/renderer/src/components/settings/MobilePane.test.tsx +++ b/src/renderer/src/components/settings/MobilePane.test.tsx @@ -201,7 +201,7 @@ describe('MobilePane pairing connection mode', () => { listNetworkInterfaces: mocks.listNetworkInterfaces, revokeDevice: mocks.revokeDevice }, - ui: { writeClipboardText: vi.fn().mockResolvedValue(undefined) } + ui: { writeClipboardText: vi.fn().mockResolvedValue(undefined) }, platform: { get: () => ({ platform: 'darwin' as const }) } } }) }) @@ -810,7 +810,7 @@ describe('MobilePane', () => { listDevices: mocks.listDevices, listNetworkInterfaces: mocks.listNetworkInterfaces, revokeDevice: mocks.revokeDevice - } + }, platform: { get: () => ({ platform: 'darwin' as const }) } } }) }) diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts index bf2685e7e29e..3814dc8d9814 100644 --- a/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts +++ b/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts @@ -128,6 +128,36 @@ describe('selectRefreshedNetworkAddress', () => { } ) + it('replaces an auto-selected Thunderbolt address when Ethernet appears', () => { + expect( + selectRefreshedNetworkAddress( + '10.99.88.1', + [ + { name: 'bridge0', address: '10.99.88.1' }, + { name: 'en0', address: '192.168.4.191' } + ], + false, + false, + 'darwin' + ) + ).toBe('192.168.4.191') + }) + + it('keeps a Thunderbolt address the user picked when Ethernet appears', () => { + expect( + selectRefreshedNetworkAddress( + '10.99.88.1', + [ + { name: 'bridge0', address: '10.99.88.1' }, + { name: 'en0', address: '192.168.4.191' } + ], + false, + true, + 'darwin' + ) + ).toBe('10.99.88.1') + }) + it('prefers Ethernet over Thunderbolt Bridge when the renderer has no process', () => { const priorProcess = globalThis.process const priorWindow = globalThis.window diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.ts index cbe44f218e13..ed09bb5c6e07 100644 --- a/src/renderer/src/components/settings/mobile-network-interface-selection.ts +++ b/src/renderer/src/components/settings/mobile-network-interface-selection.ts @@ -1,4 +1,5 @@ import { + isThunderboltBridgeInterface, isVirtualBridgeInterface, selectAutoAdvertisedPairingAddress } from '../../../../shared/pairing-address-auto-selection' @@ -32,15 +33,17 @@ export function selectRefreshedNetworkAddress( // Read only once classification needs a host. An empty refresh must not require it. const hostPlatform = platform ?? readPairingHostPlatform() const currentInterface = interfaces.find((iface) => iface.address === currentAddress) - if ( - currentInterface && - (currentAddressWasExplicitlySelected || - !isVirtualBridgeInterface( - currentInterface.name, - currentInterface.hasDefaultRoute, - hostPlatform - )) - ) { + // An auto-selected Thunderbolt address is only the fallback while it is the only + // direct link. Ethernet showing up later must replace it. An explicit pick stays. + const autoSelectedReachableAddress = + currentInterface !== undefined && + !isVirtualBridgeInterface( + currentInterface.name, + currentInterface.hasDefaultRoute, + hostPlatform + ) && + !isThunderboltBridgeInterface(currentInterface.name, hostPlatform) + if (currentInterface && (currentAddressWasExplicitlySelected || autoSelectedReachableAddress)) { return currentAddress } // Why: shared with main so the picker never shows a default the QR didn't advertise. Undefined on From 1dce410fb19a91e0456d4856ef1361b21f220d33 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 5 Oct 2026 02:54:35 +0900 Subject: [PATCH 5/6] fix(settings): prevent pairing with stale bridge addresses --- .../runtime/pairing-network-interfaces.ts | 12 +--- .../src/components/mobile/MobilePage.test.tsx | 2 +- ...mobile-pairing-address-preference.test.tsx | 9 +-- .../components/settings/MobilePane.test.tsx | 4 +- .../RuntimePairingGeneratorForm.test.tsx | 29 +++++++- .../settings/RuntimePairingGeneratorForm.tsx | 18 ++++- .../RuntimePairingUrlGenerator.test.tsx | 49 +++++++++++++ ...mobile-network-interface-selection.test.ts | 71 +------------------ .../mobile-network-interface-selection.ts | 25 ++----- .../runtime-pairing-link-state.test.ts | 12 ++++ .../settings/runtime-pairing-link-state.ts | 10 ++- .../pairing-address-auto-selection.test.ts | 55 +++++++------- src/shared/pairing-address-auto-selection.ts | 36 +++------- 13 files changed, 163 insertions(+), 169 deletions(-) diff --git a/src/main/runtime/pairing-network-interfaces.ts b/src/main/runtime/pairing-network-interfaces.ts index b7b57599c39d..8c0448ec7e3c 100644 --- a/src/main/runtime/pairing-network-interfaces.ts +++ b/src/main/runtime/pairing-network-interfaces.ts @@ -1,6 +1,5 @@ import { networkInterfaces } from 'node:os' import { - isThunderboltBridgeInterface, isVirtualBridgeInterface, selectAutoAdvertisedPairingAddress } from '../../shared/pairing-address-auto-selection' @@ -66,13 +65,7 @@ function rankInterface({ name, address, hasDefaultRoute }: NetworkInterface): nu if (isTailnetIPv4Address(address)) { return 0 } - // Why: Thunderbolt Bridge is a real link, but it is not the phone's default. Keep it behind - // LAN and IPv6 in the picker order so `bridge0` does not become interfaces[0] while en0 is up. - const bridgePenalty = - isVirtualBridgeInterface(name, hasDefaultRoute, process.platform) || - isThunderboltBridgeInterface(name, process.platform) - ? 2 - : 0 + const bridgePenalty = isVirtualBridgeInterface(name, hasDefaultRoute) ? 2 : 0 return (address.includes(':') ? 2 : 1) + bridgePenalty } @@ -81,8 +74,7 @@ export async function getDefaultPairingAddress( ): Promise { return ( selectAutoAdvertisedPairingAddress( - await getPairingNetworkInterfaces(getDefaultRouteInterfaceNames), - process.platform + await getPairingNetworkInterfaces(getDefaultRouteInterfaceNames) ) ?? null ) } diff --git a/src/renderer/src/components/mobile/MobilePage.test.tsx b/src/renderer/src/components/mobile/MobilePage.test.tsx index c4fb35a78c13..5805bca46e6d 100644 --- a/src/renderer/src/components/mobile/MobilePage.test.tsx +++ b/src/renderer/src/components/mobile/MobilePage.test.tsx @@ -172,7 +172,7 @@ describe('MobilePage pairing connection mode', () => { listDevices: vi.fn().mockResolvedValue({ devices: [] }), listNetworkInterfaces }, - shell: { openUrl: vi.fn() }, platform: { get: () => ({ platform: 'darwin' as const }) }, + shell: { openUrl: vi.fn() }, ui: { writeClipboardText: vi.fn().mockResolvedValue(undefined) } } }) diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx b/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx index 87108a2be6d6..c3dfe6d27341 100644 --- a/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx +++ b/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { act, cleanup, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' type StoreState = { @@ -48,13 +48,6 @@ function renderPreference(networkInterfaces: readonly MobileNetworkInterface[] = return { result, onSelectionInvalidated } } -beforeEach(() => { - Object.defineProperty(window, 'api', { - configurable: true, - value: { platform: { get: () => ({ platform: 'darwin' as const }) } } - }) -}) - afterEach(() => cleanup()) describe('useMobilePairingAddressPreference', () => { diff --git a/src/renderer/src/components/settings/MobilePane.test.tsx b/src/renderer/src/components/settings/MobilePane.test.tsx index 2039fed50ee3..28f83ccf6978 100644 --- a/src/renderer/src/components/settings/MobilePane.test.tsx +++ b/src/renderer/src/components/settings/MobilePane.test.tsx @@ -201,7 +201,7 @@ describe('MobilePane pairing connection mode', () => { listNetworkInterfaces: mocks.listNetworkInterfaces, revokeDevice: mocks.revokeDevice }, - ui: { writeClipboardText: vi.fn().mockResolvedValue(undefined) }, platform: { get: () => ({ platform: 'darwin' as const }) } + ui: { writeClipboardText: vi.fn().mockResolvedValue(undefined) } } }) }) @@ -810,7 +810,7 @@ describe('MobilePane', () => { listDevices: mocks.listDevices, listNetworkInterfaces: mocks.listNetworkInterfaces, revokeDevice: mocks.revokeDevice - }, platform: { get: () => ({ platform: 'darwin' as const }) } + } } }) }) diff --git a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx index 7515b204883c..19199f5841e7 100644 --- a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx +++ b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx @@ -13,14 +13,19 @@ function renderForm( address: string runtimePairingUrl: string webClientUrl: string - } + }, + networkInterfaces: { name: string; address: string }[] = [ + { name: 'tailscale0', address: '100.76.32.125' } + ], + retainedInterfaceName: string | null = null ): string { return renderToStaticMarkup( { expect(markup).toContain('The connection address changed.') expect(markup).not.toContain('stale-secret') }) + + it('blocks a new link while the remembered interface is missing', () => { + const markup = renderForm( + 'another', + '10.99.88.1', + undefined, + [{ name: 'en0', address: '192.168.4.191' }], + 'bridge0' + ) + + expect(markup).toContain('The selected network interface is unavailable.') + expect(markup).toMatch(/]*disabled=""[^>]*>.*Generate Access Link/s) + }) + + it('still allows a custom endpoint while no matching interface is listed', () => { + const markup = renderForm('custom', '10.99.88.1', undefined, []) + + expect(markup).not.toContain('The selected network interface is unavailable.') + expect(markup).not.toContain('disabled=""') + }) }) diff --git a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx index 6149213dee8d..e53c9c98ef1f 100644 --- a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx +++ b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx @@ -68,7 +68,15 @@ export function RuntimePairingGeneratorForm({ const customAddressResult = intent === 'custom' ? parseServerShareAddress(selectedAddress) : { ok: true as const } const customAddressInvalid = selectedAddress !== '' && !customAddressResult.ok - const canGenerate = selectedAddress !== '' && (intent !== 'custom' || customAddressResult.ok) + const selectedInterfaceAvailable = networkInterfaces.some( + (networkInterface) => + networkInterface.address === selectedAddress && + (!retainedInterfaceName || networkInterface.name === retainedInterfaceName) + ) + const canGenerate = + selectedAddress !== '' && + (intent !== 'another' || selectedInterfaceAvailable) && + (intent !== 'custom' || customAddressResult.ok) return ( <> @@ -309,6 +317,14 @@ export function RuntimePairingGeneratorForm({ ) : null}
)} + {intent === 'another' && selectedAddress !== '' && !selectedInterfaceAvailable ? ( +

+ {translate( + 'auto.components.settings.RuntimePairingUrlGenerator.selectedInterfaceUnavailable', + 'The selected network interface is unavailable. Reconnect it before generating a link.' + )} +

+ ) : null} {staleGeneratedLink && selectedAddress !== '' ? (
{translate( diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx index 74bf5c2e34cb..bd737f216b93 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx @@ -195,6 +195,55 @@ describe('RuntimePairingUrlGenerator', () => { ) }) + it('keeps the returned Bridge address after it changes and drops again', async () => { + runtimePairingLinkCache.selectedAddress = '' + mocks.settings = { + runtimePairingAdvertisedInterfaceName: 'bridge0', + runtimePairingAdvertisedAddress: '10.99.88.1' + } + mocks.listNetworkInterfaces + .mockResolvedValueOnce({ + interfaces: [ + { name: 'en0', address: '192.168.4.191' }, + { name: 'bridge0', address: '10.99.88.1' } + ] + }) + .mockResolvedValueOnce({ interfaces: [{ name: 'en0', address: '192.168.4.191' }] }) + .mockResolvedValueOnce({ + interfaces: [ + { name: 'en0', address: '192.168.4.191' }, + { name: 'bridge0', address: '10.99.88.2' } + ] + }) + .mockResolvedValueOnce({ interfaces: [{ name: 'en0', address: '192.168.4.191' }] }) + + render() + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.1') + ) + + screen.getByRole('button', { name: 'Refresh' }).click() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledTimes(2)) + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.1') + + screen.getByRole('button', { name: 'Refresh' }).click() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledTimes(3)) + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.2') + ) + await waitFor(() => + expect(mocks.updateSettings).toHaveBeenCalledWith({ + runtimePairingAdvertisedInterfaceName: 'bridge0', + runtimePairingAdvertisedAddress: '10.99.88.2' + }) + ) + + screen.getByRole('button', { name: 'Refresh' }).click() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledTimes(4)) + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.2') + expect(runtimePairingLinkCache.advertisedAddress).toBe('10.99.88.2') + }) + it('keeps an explicit Thunderbolt pick when refresh reports only Ethernet', async () => { runtimePairingLinkCache.selectedAddress = '' mocks.listNetworkInterfaces diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts index 3814dc8d9814..5ad5fb39f6a6 100644 --- a/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts +++ b/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts @@ -1,19 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, it, expect } from 'vitest' import { selectRefreshedNetworkAddress, type MobileNetworkInterface } from './mobile-network-interface-selection' -beforeEach(() => { - vi.stubGlobal('window', { - api: { platform: { get: () => ({ platform: 'linux' as const }) } } - }) -}) - -afterEach(() => { - vi.unstubAllGlobals() -}) - const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } const BRIDGE: MobileNetworkInterface = { name: 'docker0', address: '172.17.0.1' } @@ -127,63 +117,4 @@ describe('selectRefreshedNetworkAddress', () => { ).toBe(networkInterface.address) } ) - - it('replaces an auto-selected Thunderbolt address when Ethernet appears', () => { - expect( - selectRefreshedNetworkAddress( - '10.99.88.1', - [ - { name: 'bridge0', address: '10.99.88.1' }, - { name: 'en0', address: '192.168.4.191' } - ], - false, - false, - 'darwin' - ) - ).toBe('192.168.4.191') - }) - - it('keeps a Thunderbolt address the user picked when Ethernet appears', () => { - expect( - selectRefreshedNetworkAddress( - '10.99.88.1', - [ - { name: 'bridge0', address: '10.99.88.1' }, - { name: 'en0', address: '192.168.4.191' } - ], - false, - true, - 'darwin' - ) - ).toBe('10.99.88.1') - }) - - it('prefers Ethernet over Thunderbolt Bridge when the renderer has no process', () => { - const priorProcess = globalThis.process - const priorWindow = globalThis.window - delete (globalThis as { process?: NodeJS.Process }).process - vi.stubGlobal('window', { - api: { platform: { get: () => ({ platform: 'darwin' as const }) } } - }) - let selectedWithLan: string | undefined - let selectedBridgeOnly: string | undefined - try { - selectedWithLan = selectRefreshedNetworkAddress(undefined, [ - { name: 'bridge0', address: '10.99.88.1' }, - { name: 'en0', address: '192.168.4.191' } - ]) - selectedBridgeOnly = selectRefreshedNetworkAddress(undefined, [ - { name: 'bridge0', address: '10.99.88.1' } - ]) - } finally { - globalThis.process = priorProcess - if (priorWindow === undefined) { - vi.unstubAllGlobals() - } else { - vi.stubGlobal('window', priorWindow) - } - } - expect(selectedWithLan).toBe('192.168.4.191') - expect(selectedBridgeOnly).toBe('10.99.88.1') - }) }) diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.ts index ed09bb5c6e07..4d1d7c617904 100644 --- a/src/renderer/src/components/settings/mobile-network-interface-selection.ts +++ b/src/renderer/src/components/settings/mobile-network-interface-selection.ts @@ -1,9 +1,7 @@ import { - isThunderboltBridgeInterface, isVirtualBridgeInterface, selectAutoAdvertisedPairingAddress } from '../../../../shared/pairing-address-auto-selection' -import { readPairingHostPlatform } from './read-pairing-host-platform' export type MobileNetworkInterface = { name: string @@ -18,8 +16,7 @@ export function selectRefreshedNetworkAddress( // (not an OS-enumerated one) pass this so the refresh path keeps their // selection instead of snapping back to a tailnet/LAN fallback. currentAddressIsManual: boolean = false, - currentAddressWasExplicitlySelected: boolean = false, - platform?: NodeJS.Platform + currentAddressWasExplicitlySelected: boolean = false ): string | undefined { // Why: transient discovery failure must not clobber a deliberate user choice. if (interfaces.length === 0) { @@ -30,23 +27,15 @@ export function selectRefreshedNetworkAddress( if (currentAddressIsManual) { return currentAddress } - // Read only once classification needs a host. An empty refresh must not require it. - const hostPlatform = platform ?? readPairingHostPlatform() const currentInterface = interfaces.find((iface) => iface.address === currentAddress) - // An auto-selected Thunderbolt address is only the fallback while it is the only - // direct link. Ethernet showing up later must replace it. An explicit pick stays. - const autoSelectedReachableAddress = - currentInterface !== undefined && - !isVirtualBridgeInterface( - currentInterface.name, - currentInterface.hasDefaultRoute, - hostPlatform - ) && - !isThunderboltBridgeInterface(currentInterface.name, hostPlatform) - if (currentInterface && (currentAddressWasExplicitlySelected || autoSelectedReachableAddress)) { + if ( + currentInterface && + (currentAddressWasExplicitlySelected || + !isVirtualBridgeInterface(currentInterface.name, currentInterface.hasDefaultRoute)) + ) { return currentAddress } // Why: shared with main so the picker never shows a default the QR didn't advertise. Undefined on // a bridge-only host: Relay pairs without a direct address rather than offering an unreachable one. - return selectAutoAdvertisedPairingAddress(interfaces, hostPlatform) + return selectAutoAdvertisedPairingAddress(interfaces) } diff --git a/src/renderer/src/components/settings/runtime-pairing-link-state.test.ts b/src/renderer/src/components/settings/runtime-pairing-link-state.test.ts index 2853273ca433..cb64457d4c1d 100644 --- a/src/renderer/src/components/settings/runtime-pairing-link-state.test.ts +++ b/src/renderer/src/components/settings/runtime-pairing-link-state.test.ts @@ -59,6 +59,18 @@ describe('resolveAnotherDevicePairingAddress', () => { }) ).toBe(EN0.address) }) + + it('uses Thunderbolt Bridge as the Share this host fallback when it is the only interface', () => { + expect( + resolveAnotherDevicePairingAddress({ + interfaces: [BRIDGE], + selectedAddress: '', + preferredInterfaceName: null, + preferredAddress: '', + platform: 'darwin' + }) + ).toBe(BRIDGE.address) + }) }) describe('selectRuntimePairingIntent', () => { diff --git a/src/renderer/src/components/settings/runtime-pairing-link-state.ts b/src/renderer/src/components/settings/runtime-pairing-link-state.ts index 9910181cca4c..39dd532667c2 100644 --- a/src/renderer/src/components/settings/runtime-pairing-link-state.ts +++ b/src/renderer/src/components/settings/runtime-pairing-link-state.ts @@ -1,5 +1,8 @@ import { normalizeMobilePairingCustomAddress } from '../../../../shared/mobile-pairing-custom-address' -import { selectAutoAdvertisedPairingAddress } from '../../../../shared/pairing-address-auto-selection' +import { + isThunderboltBridgeInterface, + selectAutoAdvertisedPairingAddress +} from '../../../../shared/pairing-address-auto-selection' import type { RuntimePairingReach } from '../../../../shared/runtime-pairing-reach' export const RUNTIME_PAIRING_LOOPBACK_ADDRESS = '127.0.0.1' @@ -123,8 +126,11 @@ export function resolveAnotherDevicePairingAddress(args: { if (args.interfaces.some((iface) => iface.address === args.selectedAddress)) { return args.selectedAddress } + const autoAdvertisedAddress = selectAutoAdvertisedPairingAddress(args.interfaces) return ( - selectAutoAdvertisedPairingAddress(args.interfaces, args.platform) ?? + autoAdvertisedAddress ?? + args.interfaces.find((iface) => isThunderboltBridgeInterface(iface.name, args.platform)) + ?.address ?? args.interfaces[0]?.address ?? '' ) diff --git a/src/shared/pairing-address-auto-selection.test.ts b/src/shared/pairing-address-auto-selection.test.ts index ca9028cda64e..a68ec923fe2f 100644 --- a/src/shared/pairing-address-auto-selection.test.ts +++ b/src/shared/pairing-address-auto-selection.test.ts @@ -36,54 +36,53 @@ const AMBIGUOUS_SWITCH: PairingNetworkInterface = { describe('selectAutoAdvertisedPairingAddress', () => { it('allows a reachable Hyper-V external-switch management address', () => { - expect(selectAutoAdvertisedPairingAddress([EXTERNAL_SWITCH], 'win32')).toBe( - EXTERNAL_SWITCH.address - ) + expect(selectAutoAdvertisedPairingAddress([EXTERNAL_SWITCH])).toBe(EXTERNAL_SWITCH.address) }) it('selects a physical address while filtering Default Switch, WSL, and host-local bridges', () => { expect( - selectAutoAdvertisedPairingAddress( - [DEFAULT_SWITCH, WSL_SWITCH, HOST_LOCAL_BRIDGE, PHYSICAL_LAN], - 'win32' - ) + selectAutoAdvertisedPairingAddress([ + DEFAULT_SWITCH, + WSL_SWITCH, + HOST_LOCAL_BRIDGE, + PHYSICAL_LAN + ]) ).toBe(PHYSICAL_LAN.address) }) it('filters a vEthernet adapter when route reachability is ambiguous', () => { - expect(selectAutoAdvertisedPairingAddress([AMBIGUOUS_SWITCH], 'win32')).toBeUndefined() + expect(selectAutoAdvertisedPairingAddress([AMBIGUOUS_SWITCH])).toBeUndefined() }) - it('does not treat macOS Thunderbolt Bridge as a container bridge', () => { + it('keeps Thunderbolt Bridge explicit-only outside Share this host', () => { expect(isThunderboltBridgeInterface('bridge0', 'darwin')).toBe(true) expect(isThunderboltBridgeInterface('Bridge12', 'darwin')).toBe(true) - expect(isVirtualBridgeInterface('bridge0', undefined, 'darwin')).toBe(false) - expect(isVirtualBridgeInterface('bridge1', undefined, 'darwin')).toBe(false) - expect(isVirtualBridgeInterface('bridge', undefined, 'darwin')).toBe(true) - expect(isVirtualBridgeInterface('br-lan', undefined, 'darwin')).toBe(true) - expect(isVirtualBridgeInterface('docker0', undefined, 'darwin')).toBe(true) + expect(isVirtualBridgeInterface('bridge0')).toBe(true) + expect(isVirtualBridgeInterface('bridge1')).toBe(true) + expect(isVirtualBridgeInterface('bridge')).toBe(true) + expect(isVirtualBridgeInterface('br-lan')).toBe(true) + expect(isVirtualBridgeInterface('docker0')).toBe(true) + expect( + selectAutoAdvertisedPairingAddress([{ name: 'bridge0', address: '10.99.88.1' }]) + ).toBeUndefined() }) it('keeps a Linux bridge0 out of the direct pairing address', () => { const linuxBridge = { name: 'bridge0', address: '10.0.0.1' } const lan = { name: 'eth0', address: '192.168.4.191' } expect(isThunderboltBridgeInterface('bridge0', 'linux')).toBe(false) - expect(isVirtualBridgeInterface('bridge0', undefined, 'linux')).toBe(true) - expect(selectAutoAdvertisedPairingAddress([linuxBridge], 'linux')).toBeUndefined() - expect(selectAutoAdvertisedPairingAddress([linuxBridge, lan], 'linux')).toBe(lan.address) + expect(isVirtualBridgeInterface('bridge0')).toBe(true) + expect(selectAutoAdvertisedPairingAddress([linuxBridge])).toBeUndefined() + expect(selectAutoAdvertisedPairingAddress([linuxBridge, lan])).toBe(lan.address) }) - it('prefers LAN and tailnet over Thunderbolt Bridge, and uses the bridge when it is the only direct address', () => { + it('prefers LAN and tailnet while keeping Thunderbolt out of automatic pairing addresses', () => { const thunderbolt = { name: 'bridge0', address: '10.99.88.1' } const lan = { name: 'en0', address: '192.168.4.191' } const tailnet = { name: 'tailscale0', address: '100.64.1.20' } - expect(selectAutoAdvertisedPairingAddress([thunderbolt, lan], 'darwin')).toBe(lan.address) - expect(selectAutoAdvertisedPairingAddress([thunderbolt, tailnet], 'darwin')).toBe( - tailnet.address - ) - expect(selectAutoAdvertisedPairingAddress([HOST_LOCAL_BRIDGE, thunderbolt], 'darwin')).toBe( - thunderbolt.address - ) + expect(selectAutoAdvertisedPairingAddress([thunderbolt, lan])).toBe(lan.address) + expect(selectAutoAdvertisedPairingAddress([thunderbolt, tailnet])).toBe(tailnet.address) + expect(selectAutoAdvertisedPairingAddress([HOST_LOCAL_BRIDGE, thunderbolt])).toBeUndefined() }) it.each([ @@ -92,7 +91,7 @@ describe('selectAutoAdvertisedPairingAddress', () => { 'vEthernet (WSL)', 'vEthernet (WSL (Hyper-V firewall))' ])('keeps the known host-local %s label filtered with a default route', (name) => { - expect(isVirtualBridgeInterface(name, true, 'win32')).toBe(true) + expect(isVirtualBridgeInterface(name, true)).toBe(true) }) it.each([ @@ -101,7 +100,7 @@ describe('selectAutoAdvertisedPairingAddress', () => { 'vEthernet (WSL LAN)', 'vEthernet (WSL External)' ])('does not treat the route-backed near-match %s as a known host-local label', (name) => { - expect(isVirtualBridgeInterface(name, true, 'win32')).toBe(false) + expect(isVirtualBridgeInterface(name, true)).toBe(false) }) it.each([ @@ -112,6 +111,6 @@ describe('selectAutoAdvertisedPairingAddress', () => { ['host-local', HOST_LOCAL_BRIDGE, true], ['ambiguous', AMBIGUOUS_SWITCH, true] ] as const)('classifies the %s fixture', (_label, fixture, expected) => { - expect(isVirtualBridgeInterface(fixture.name, fixture.hasDefaultRoute, 'win32')).toBe(expected) + expect(isVirtualBridgeInterface(fixture.name, fixture.hasDefaultRoute)).toBe(expected) }) }) diff --git a/src/shared/pairing-address-auto-selection.ts b/src/shared/pairing-address-auto-selection.ts index 57f850ce1b12..b1c3f181e453 100644 --- a/src/shared/pairing-address-auto-selection.ts +++ b/src/shared/pairing-address-auto-selection.ts @@ -8,8 +8,6 @@ export type PairingNetworkInterface = { // Why: known bridge labels are host-local, while vEthernet needs positive route evidence because // External Switch management adapters are reachable; subnets overlap real corporate LANs. -// `bridge` still matches Linux bridge names. Apple's Thunderbolt Bridge is exactly `bridge` plus -// digits (`bridge0`) on macOS, so only that platform is excluded before this pattern runs. const VIRTUAL_BRIDGE_INTERFACE_PATTERN = /^(?:docker|br-|virbr|vmnet|vboxnet|veth|lxcbr|cni|flannel|cali|bridge)|VMware Network Adapter|VirtualBox Host-Only/i const THUNDERBOLT_BRIDGE_INTERFACE_PATTERN = /^bridge\d+$/i @@ -18,20 +16,12 @@ const HOST_LOCAL_HYPER_V_INTERFACE_PATTERN = /^vEthernet \((?:Default Switch|WSL(?: \(Hyper-V firewall\))?)\)$/i export function isThunderboltBridgeInterface(name: string, platform: NodeJS.Platform): boolean { + // Why: Share this host can choose a physical macOS link explicitly; generic pairing still + // filters every `bridge*` address so mobile QR defaults do not move onto the cable. return platform === 'darwin' && THUNDERBOLT_BRIDGE_INTERFACE_PATTERN.test(name) } -export function isVirtualBridgeInterface( - name: string, - hasDefaultRoute: boolean | undefined, - platform: NodeJS.Platform -): boolean { - // Why: macOS names the Thunderbolt cable `bridge0`. That is a real link between two Macs, - // not a container bridge. Linux `bridge0` stays a virtual bridge. `br-`, `docker0`, and a - // bare `bridge` stay excluded on every platform. - if (isThunderboltBridgeInterface(name, platform)) { - return false - } +export function isVirtualBridgeInterface(name: string, hasDefaultRoute?: boolean): boolean { if (HOST_LOCAL_HYPER_V_INTERFACE_PATTERN.test(name)) { return true } @@ -45,22 +35,14 @@ export function isVirtualBridgeInterface( // agree — a divergence would display one address while the QR advertises another. Bridges stay // pickable for an explicit choice but are never chosen here; `undefined` means "advertise no direct // address", which Relay tolerates (it carries its own invite) and the LAN-only path refuses. -// Why: this module is imported by the sandboxed renderer, which has no `process`. -// Callers pass the host platform. A guessed default would classify macOS `bridge0` -// differently from the address main already advertised. export function selectAutoAdvertisedPairingAddress( - interfaces: readonly PairingNetworkInterface[], - platform: NodeJS.Platform + interfaces: readonly PairingNetworkInterface[] ): string | undefined { const advertisable = interfaces.filter( - (iface) => !isVirtualBridgeInterface(iface.name, iface.hasDefaultRoute, platform) + (iface) => !isVirtualBridgeInterface(iface.name, iface.hasDefaultRoute) + ) + return ( + advertisable.find((iface) => isTailnetIPv4Address(iface.address))?.address ?? + advertisable[0]?.address ) - const tailnet = advertisable.find((iface) => isTailnetIPv4Address(iface.address)) - if (tailnet) { - return tailnet.address - } - // Why: a phone on Wi-Fi cannot reach Thunderbolt Bridge. Auto-advertise uses `bridgeN` - // only when it is the only direct address, so Ethernet/Wi-Fi still wins when both are up. - const direct = advertisable.find((iface) => !isThunderboltBridgeInterface(iface.name, platform)) - return direct?.address ?? advertisable[0]?.address } From 21e03735104853603eb1bc670a9de56293b9b977 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 5 Oct 2026 03:10:13 +0900 Subject: [PATCH 6/6] fix(i18n): add retained interface warning copy --- src/renderer/src/i18n/locales/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7b497327dac1..bc5d5060eeda 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8411,6 +8411,7 @@ "localLink": "Local-only link", "localLinkHelp": "This link only works in a browser or Orca client running on this computer.", "noExternalAddress": "No address for another device was found. Connect this computer to a LAN or Tailscale, refresh, or choose Custom address.", + "selectedInterfaceUnavailable": "The selected network interface is unavailable. Reconnect it before generating a link.", "staleAddress": "The connection address changed. Generate a new link for {{address}}.", "customInvalid": "Enter a valid host, host:port, IPv6 address, or ws(s):// URL." },