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 1c5176a999c4..e53c9c98ef1f 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,12 +52,31 @@ 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 = 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 ( <> @@ -296,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 0b32a439dbd2..bd737f216b93 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 @@ -49,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, @@ -105,4 +134,144 @@ 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('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 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 + .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/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 new file mode 100644 index 000000000000..cb64457d4c1d --- /dev/null +++ b/src/renderer/src/components/settings/runtime-pairing-link-state.test.ts @@ -0,0 +1,88 @@ +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, + platform: 'darwin' + }) + ).toBe(BRIDGE.address) + }) + + it('keeps the saved Thunderbolt address when the link drops and Ethernet remains', () => { + expect( + resolveAnotherDevicePairingAddress({ + interfaces: [EN0], + selectedAddress: EN0.address, + ...PREFERENCE, + platform: 'darwin' + }) + ).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, + platform: 'darwin' + }) + ).toBe(ipv6.address) + }) + + it('auto-selects Ethernet when the user has not picked an interface', () => { + expect( + resolveAnotherDevicePairingAddress({ + interfaces: [BRIDGE, EN0], + selectedAddress: '', + preferredInterfaceName: null, + preferredAddress: '', + platform: 'darwin' + }) + ).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', () => { + it('returns to the saved Thunderbolt address when Another device is chosen again', () => { + 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, '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 ff777fc72fed..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,3 +1,8 @@ +import { normalizeMobilePairingCustomAddress } from '../../../../shared/mobile-pairing-custom-address' +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' @@ -18,10 +23,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 +64,8 @@ export const runtimePairingLinkCache: { selectedAddress: '', customAddress: '', intent: 'another', + advertisedInterfaceName: null, + advertisedAddress: '', generatedAddress: null, runtimePairingUrl: null, webClientUrl: null, @@ -55,17 +91,73 @@ export function cacheGeneratedRuntimePairingLink(args: { runtimePairingLinkCache.generatedAddress = args.address } +export function resolveAnotherDevicePairingAddress(args: { + interfaces: readonly RuntimePairingInterface[] + selectedAddress: string + preferredInterfaceName: string | null + preferredAddress: string + platform: NodeJS.Platform +}): 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 + } + const autoAdvertisedAddress = selectAutoAdvertisedPairingAddress(args.interfaces) + return ( + autoAdvertisedAddress ?? + args.interfaces.find((iface) => isThunderboltBridgeInterface(iface.name, args.platform)) + ?.address ?? + args.interfaces[0]?.address ?? + '' + ) +} + export function selectRuntimePairingIntent( intent: RuntimePairingIntent, - networkInterfaces: { address: string }[], - customAddress: string + networkInterfaces: readonly RuntimePairingInterface[], + customAddress: string, + preference: AnotherDevicePairingPreference = { + preferredInterfaceName: null, + preferredAddress: '' + }, + platform: NodeJS.Platform ): 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, + platform + }) : 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..113da5e1c698 --- /dev/null +++ b/src/renderer/src/components/settings/use-runtime-pairing-advertised-address.ts @@ -0,0 +1,139 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { + normalizeRuntimePairingAdvertisedAddress, + normalizeRuntimePairingAdvertisedInterfaceName, + resolveAnotherDevicePairingAddress, + runtimePairingLinkCache, + selectRuntimePairingIntent, + type RuntimePairingIntent, + type RuntimePairingInterface +} from './runtime-pairing-link-state' +import { readPairingHostPlatform } from './read-pairing-host-platform' + +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') { + 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: preferredInterfaceNameNow, + preferredAddress: rememberedAddress, + platform: readPairingHostPlatform() + }) + 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 + } + runtimePairingLinkCache.selectedAddress = nextAddress + setSelectedAddress(nextAddress) + }, [ + intent, + networkInterfaces, + selectedAddress, + setSelectedAddress, + storedAddress, + storedInterfaceName, + updateSettings + ]) + + 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 }, + readPairingHostPlatform() + ) + ) + } + + return { preferredInterfaceName, updateSelectedAddress, updateIntent } +} 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." }, 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..a68ec923fe2f 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,37 @@ describe('selectAutoAdvertisedPairingAddress', () => { expect(selectAutoAdvertisedPairingAddress([AMBIGUOUS_SWITCH])).toBeUndefined() }) + 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')).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')).toBe(true) + expect(selectAutoAdvertisedPairingAddress([linuxBridge])).toBeUndefined() + expect(selectAutoAdvertisedPairingAddress([linuxBridge, lan])).toBe(lan.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])).toBe(lan.address) + expect(selectAutoAdvertisedPairingAddress([thunderbolt, tailnet])).toBe(tailnet.address) + expect(selectAutoAdvertisedPairingAddress([HOST_LOCAL_BRIDGE, thunderbolt])).toBeUndefined() + }) + 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..b1c3f181e453 100644 --- a/src/shared/pairing-address-auto-selection.ts +++ b/src/shared/pairing-address-auto-selection.ts @@ -10,10 +10,17 @@ export type PairingNetworkInterface = { // External Switch management adapters are reachable; subnets overlap real corporate LANs. 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, 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): boolean { if (HOST_LOCAL_HYPER_V_INTERFACE_PATTERN.test(name)) { return true