diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index f91c6699850..88199db7e41 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -498,6 +498,54 @@ describe('registerSettingsHandlers', () => { ) }) + it('normalizes custom mobile pairing addresses before persistence', async () => { + store.getSettings.mockReturnValue({ + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: [] + }) + store.updateSettings.mockReturnValue({ + mobilePairingCustomAddress: '100.126.117.25:6768', + mobilePairingCustomAddresses: ['first.example:6768'] + }) + registerSettingsHandlers(store as never) + + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + _event: unknown, + args: unknown + ) => Promise + + await handler(settingsInvokeEvent, { + mobilePairingCustomAddress: ' 100.126.117.25:6768 ', + mobilePairingCustomAddresses: [' first.example:6768 ', 'host:99999', 'first.example:6768'] + }) + + expect(store.updateSettings).toHaveBeenCalledWith( + { + mobilePairingCustomAddress: '100.126.117.25:6768', + mobilePairingCustomAddresses: ['first.example:6768'] + }, + { notifyListeners: true, originWebContentsId: 1 } + ) + }) + + it('clears malformed custom mobile pairing addresses before persistence', async () => { + store.getSettings.mockReturnValue({ mobilePairingCustomAddress: '100.126.117.25:6768' }) + store.updateSettings.mockReturnValue({ mobilePairingCustomAddress: null }) + registerSettingsHandlers(store as never) + + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + _event: unknown, + args: unknown + ) => Promise + + await handler(settingsInvokeEvent, { mobilePairingCustomAddress: 'host:99999' }) + + expect(store.updateSettings).toHaveBeenCalledWith( + { mobilePairingCustomAddress: null }, + { notifyListeners: true, originWebContentsId: 1 } + ) + }) + it('normalizes custom terminal themes from renderer settings IPC', async () => { store.getSettings.mockReturnValue({ terminalCustomThemes: [] }) store.updateSettings.mockReturnValue({ terminalCustomThemes: [] }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index c5be32dd32c..275ba54c82a 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -25,6 +25,10 @@ import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides' import { resolveEnvironment } from '../../shared/runtime-environment-store' import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection' +import { + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses +} from '../../shared/mobile-pairing-custom-address' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -135,6 +139,16 @@ export function registerSettingsHandlers( if ('uiLanguage' in args) { sanitizedArgs.uiLanguage = normalizeUiLanguage(args.uiLanguage) } + if ('mobilePairingCustomAddress' in args) { + sanitizedArgs.mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( + args.mobilePairingCustomAddress + ) + } + if ('mobilePairingCustomAddresses' in args) { + sanitizedArgs.mobilePairingCustomAddresses = normalizeMobilePairingCustomAddresses( + args.mobilePairingCustomAddresses + ) + } if (args.theme) { nativeTheme.themeSource = args.theme } diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d8e01166973..58a08f4a2f6 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -5389,6 +5389,59 @@ describe('Store', () => { expect(updated.prBotAuthorOverrides[499]).toBe('bot-0499') }) + it('normalizes custom mobile pairing addresses on load and every settings write', async () => { + writeDataFile({ + settings: { + mobilePairingCustomAddress: 'host:99999', + mobilePairingCustomAddresses: [' first.example:6768 ', 'host:99999', 'first.example:6768'] + } + }) + const store = await createStore() + + expect(store.getSettings().mobilePairingCustomAddress).toBeNull() + expect(store.getSettings().mobilePairingCustomAddresses).toEqual(['first.example:6768']) + store.flush() + expect( + (readDataFile() as { settings?: GlobalSettings }).settings?.mobilePairingCustomAddress + ).toBeNull() + + const updated = store.updateSettings({ + mobilePairingCustomAddress: ' 100.126.117.25:6768 ' + }) + expect(updated.mobilePairingCustomAddress).toBe('100.126.117.25:6768') + expect(updated.mobilePairingCustomAddresses).toEqual([ + 'first.example:6768', + '100.126.117.25:6768' + ]) + store.flush() + expect( + (readDataFile() as { settings?: GlobalSettings }).settings?.mobilePairingCustomAddress + ).toBe('100.126.117.25:6768') + + expect( + store.updateSettings({ mobilePairingCustomAddress: 42 as never }).mobilePairingCustomAddress + ).toBeNull() + + expect( + store.updateSettings({ + mobilePairingCustomAddresses: [' second.example ', 'host:99999', 'second.example'] + }).mobilePairingCustomAddresses + ).toEqual(['second.example']) + + expect( + store.updateSettings({ + mobilePairingCustomAddress: 'active.example:6768', + mobilePairingCustomAddresses: ['second.example'] + }).mobilePairingCustomAddresses + ).toEqual(['second.example', 'active.example:6768']) + + expect( + store.updateSettings({ + mobilePairingCustomAddresses: ['third.example'] + }).mobilePairingCustomAddresses + ).toEqual(['third.example', 'active.example:6768']) + }) + it('notifies settings listeners with changed keys only', async () => { const store = await createStore() const listener = vi.fn() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index e3c0fa329af..b9088181722 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -169,6 +169,11 @@ import { import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' import { normalizeTaskProviderSettings } from '../shared/task-providers' import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-rename-branch-from-work-settings' +import { + addMobilePairingCustomAddress, + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses +} from '../shared/mobile-pairing-custom-address' import { normalizeOpenInApplications } from '../shared/open-in-applications' import { normalizeTerminalShortcutPolicy } from '../shared/keybindings' import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order' @@ -3146,6 +3151,34 @@ export class Store { parsed.settings?.compactWorktreeCards ?? parsed.settings?.experimentalCompactWorktreeCards ?? defaults.settings.compactWorktreeCards + const mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( + parsed.settings?.mobilePairingCustomAddress + ) + const rawMobilePairingCustomAddresses = parsed.settings?.mobilePairingCustomAddresses + const mobilePairingCustomAddresses = mobilePairingCustomAddress + ? addMobilePairingCustomAddress( + normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses), + mobilePairingCustomAddress + ) + : normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses) + if ( + parsed.settings?.mobilePairingCustomAddress !== undefined && + parsed.settings.mobilePairingCustomAddress !== mobilePairingCustomAddress + ) { + this.loadNeedsSave = true + } + const customAddressesMatch = + Array.isArray(rawMobilePairingCustomAddresses) && + rawMobilePairingCustomAddresses.length === mobilePairingCustomAddresses.length && + rawMobilePairingCustomAddresses.every( + (address, index) => address === mobilePairingCustomAddresses[index] + ) + if ( + (rawMobilePairingCustomAddresses !== undefined || mobilePairingCustomAddress !== null) && + !customAddressesMatch + ) { + this.loadNeedsSave = true + } const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( parsed.settings?.sourceControlGroupOrder ) @@ -3229,6 +3262,8 @@ export class Store { parsed.settings?.terminalCustomThemes ), appIcon: normalizeAppIconId(parsed.settings?.appIcon), + mobilePairingCustomAddress, + mobilePairingCustomAddresses, // Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true. minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, // Why: missing means default-on; round-trips unchanged on non-mac since darwin consumers gate the effect. @@ -5523,6 +5558,33 @@ export class Store { updates.prBotAuthorOverrides ) } + if ('mobilePairingCustomAddress' in updates) { + sanitizedUpdates.mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( + updates.mobilePairingCustomAddress + ) + } + if ('mobilePairingCustomAddresses' in updates) { + sanitizedUpdates.mobilePairingCustomAddresses = normalizeMobilePairingCustomAddresses( + updates.mobilePairingCustomAddresses + ) + } + if ( + 'mobilePairingCustomAddress' in sanitizedUpdates || + 'mobilePairingCustomAddresses' in sanitizedUpdates + ) { + const mobilePairingCustomAddress = + 'mobilePairingCustomAddress' in sanitizedUpdates + ? sanitizedUpdates.mobilePairingCustomAddress + : this.state.settings.mobilePairingCustomAddress + if (mobilePairingCustomAddress) { + sanitizedUpdates.mobilePairingCustomAddresses = addMobilePairingCustomAddress( + sanitizedUpdates.mobilePairingCustomAddresses ?? + this.state.settings.mobilePairingCustomAddresses ?? + [], + mobilePairingCustomAddress + ) + } + } const historyWithPreviousLayout = buildWorkspaceDirHistoryForUpdate( this.state.settings, sanitizedUpdates diff --git a/src/renderer/src/components/mobile/MobileHero.test.tsx b/src/renderer/src/components/mobile/MobileHero.test.tsx index 88db885a548..6be96fff55a 100644 --- a/src/renderer/src/components/mobile/MobileHero.test.tsx +++ b/src/renderer/src/components/mobile/MobileHero.test.tsx @@ -87,8 +87,12 @@ describe('HeroFlow height', () => { canGeneratePairing onCopyPairingCode={vi.fn()} networkInterfaces={[]} + customAddresses={[]} selectedAddress={undefined} + selectedAddressIsCustom={false} onSelectedAddressChange={vi.fn()} + onCustomAddressSelect={vi.fn()} + onCustomAddressRemove={vi.fn()} beforeCustomAddressChange={vi.fn().mockResolvedValue(true)} onRefreshNetworkInterfaces={vi.fn()} refreshingNetworkInterfaces={false} @@ -130,8 +134,12 @@ describe('HeroFlow height', () => { canGeneratePairing onCopyPairingCode={vi.fn()} networkInterfaces={[]} + customAddresses={[]} selectedAddress={undefined} + selectedAddressIsCustom={false} onSelectedAddressChange={vi.fn()} + onCustomAddressSelect={vi.fn()} + onCustomAddressRemove={vi.fn()} beforeCustomAddressChange={vi.fn().mockResolvedValue(true)} onRefreshNetworkInterfaces={vi.fn()} refreshingNetworkInterfaces={false} @@ -239,8 +247,12 @@ describe('HeroFlow height', () => { canGeneratePairing: true, onCopyPairingCode: vi.fn(), networkInterfaces: [], + customAddresses: [], selectedAddress: undefined, + selectedAddressIsCustom: false, onSelectedAddressChange: vi.fn(), + onCustomAddressSelect: vi.fn(), + onCustomAddressRemove: vi.fn(), beforeCustomAddressChange: vi.fn().mockResolvedValue(true), onRefreshNetworkInterfaces: vi.fn(), refreshingNetworkInterfaces: false @@ -276,8 +288,12 @@ describe('HeroFlow height', () => { canGeneratePairing: true, onCopyPairingCode: vi.fn(), networkInterfaces: [], + customAddresses: [], selectedAddress: undefined, + selectedAddressIsCustom: false, onSelectedAddressChange: vi.fn(), + onCustomAddressSelect: vi.fn(), + onCustomAddressRemove: vi.fn(), beforeCustomAddressChange: vi.fn().mockResolvedValue(true), onRefreshNetworkInterfaces: vi.fn(), refreshingNetworkInterfaces: false diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index 078ee157175..e99b7c67369 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -38,8 +38,12 @@ type HeroFlowProps = { canGeneratePairing: boolean onCopyPairingCode: () => void networkInterfaces: readonly MobileNetworkInterface[] + customAddresses: readonly string[] selectedAddress: string | undefined + selectedAddressIsCustom: boolean onSelectedAddressChange: (address: string) => void + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void beforeCustomAddressChange: (address: string) => Promise onRefreshNetworkInterfaces: () => void refreshingNetworkInterfaces: boolean @@ -72,8 +76,12 @@ export function HeroFlow({ canGeneratePairing, onCopyPairingCode, networkInterfaces, + customAddresses, selectedAddress, + selectedAddressIsCustom, onSelectedAddressChange, + onCustomAddressSelect, + onCustomAddressRemove, beforeCustomAddressChange, onRefreshNetworkInterfaces, refreshingNetworkInterfaces, @@ -227,8 +235,12 @@ export function HeroFlow({ canGeneratePairing={canGeneratePairing} onCopyPairingCode={onCopyPairingCode} networkInterfaces={networkInterfaces} + customAddresses={customAddresses} selectedAddress={selectedAddress} + selectedAddressIsCustom={selectedAddressIsCustom} onSelectedAddressChange={onSelectedAddressChange} + onCustomAddressSelect={onCustomAddressSelect} + onCustomAddressRemove={onCustomAddressRemove} beforeCustomAddressChange={beforeCustomAddressChange} onRefreshNetworkInterfaces={onRefreshNetworkInterfaces} refreshingNetworkInterfaces={refreshingNetworkInterfaces} diff --git a/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx index 80bc79399cc..2be436a8001 100644 --- a/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx +++ b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx @@ -70,8 +70,12 @@ export function MobileHeroPairingStep({ canGeneratePairing, onCopyPairingCode, networkInterfaces, + customAddresses, selectedAddress, + selectedAddressIsCustom, onSelectedAddressChange, + onCustomAddressSelect, + onCustomAddressRemove, beforeCustomAddressChange, onRefreshNetworkInterfaces, refreshingNetworkInterfaces @@ -90,8 +94,12 @@ export function MobileHeroPairingStep({ canGeneratePairing: boolean onCopyPairingCode: () => void networkInterfaces: readonly MobileNetworkInterface[] + customAddresses: readonly string[] selectedAddress: string | undefined + selectedAddressIsCustom: boolean onSelectedAddressChange: (address: string) => void + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void beforeCustomAddressChange: (address: string) => Promise onRefreshNetworkInterfaces: () => void refreshingNetworkInterfaces: boolean @@ -218,8 +226,12 @@ export function MobileHeroPairingStep({ void orcaProfileAuthStatus: { state: 'connected' | 'local' } - settings: { showMobileButton: boolean; mobilePairingConnectionMode?: MobilePairingConnectionMode } + settings: { + showMobileButton: boolean + mobilePairingConnectionMode?: MobilePairingConnectionMode + mobilePairingCustomAddress?: string | null + mobilePairingCustomAddresses?: string[] + } updateSettings: () => Promise } @@ -44,6 +49,10 @@ vi.mock('./MobilePageContent', () => ({ enterFlow: () => void handleConnectionModeChange: (mode: MobilePairingConnectionMode) => void handleAddressChange: (address: string) => void + customAddresses: readonly string[] + selectedAddressIsCustom: boolean + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void beforeCustomAddressChange: (address: string) => Promise handleContinue: () => void pairQrDataUrl: string | null @@ -51,6 +60,9 @@ vi.mock('./MobilePageContent', () => ({ pairingQrError: boolean relayMintFailure: MobileRelayMintFailure | null onRetryRelay: () => void + selectedAddress: string | undefined + loadNetworkInterfaces: () => void + refreshingNetworkInterfaces: boolean stage: string | null stepIdx: number }) => ( @@ -63,6 +75,10 @@ vi.mock('./MobilePageContent', () => ({ {props.pairingUrl ?? 'none'} {String(props.pairingQrError)} {props.relayMintFailure?.stage ?? 'none'} + {props.selectedAddress ?? 'none'} + {String(props.selectedAddressIsCustom)} + {props.customAddresses.join(',')} + {String(props.refreshingNetworkInterfaces)} @@ -81,18 +97,27 @@ vi.mock('./MobilePageContent', () => ({ + + ) })) @@ -101,6 +126,7 @@ import MobilePage from './MobilePage' describe('MobilePage pairing connection mode', () => { const getPairingQR = vi.fn() + const listNetworkInterfaces = vi.fn() beforeEach(() => { getPairingQR.mockReset().mockResolvedValue({ @@ -108,6 +134,7 @@ describe('MobilePage pairing connection mode', () => { qrDataUrl: 'data:image/png;base64,qr', pairingUrl: 'orca://pair#automatic' }) + listNetworkInterfaces.mockReset().mockResolvedValue({ interfaces: [] }) mocks.storeState = { closeMobilePage: vi.fn(), orcaProfileAuthStatus: { state: 'connected' }, @@ -120,7 +147,7 @@ describe('MobilePage pairing connection mode', () => { mobile: { getPairingQR, listDevices: vi.fn().mockResolvedValue({ devices: [] }), - listNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }) + listNetworkInterfaces }, shell: { openUrl: vi.fn() }, ui: { writeClipboardText: vi.fn().mockResolvedValue(undefined) } @@ -189,6 +216,24 @@ describe('MobilePage pairing connection mode', () => { expect(screen.getByTestId('mode')).toHaveTextContent('local-only') }) + it('restores a saved custom address for future pairing codes', async () => { + mocks.storeState.settings = { + showMobileButton: true, + mobilePairingCustomAddress: '100.126.117.25:6768' + } + await openPairingStep() + + await waitFor(() => + expect(getPairingQR).toHaveBeenCalledWith({ + address: '100.126.117.25:6768', + connectionMode: 'automatic' + }) + ) + expect(screen.getByTestId('selected-address')).toHaveTextContent('100.126.117.25:6768') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('true') + expect(screen.getByTestId('custom-addresses')).toHaveTextContent('100.126.117.25:6768') + }) + it('does not auto-mint any QR when signed out with Anywhere selected', async () => { mocks.storeState.orcaProfileAuthStatus = { state: 'local' } await openPairingStep() @@ -422,5 +467,107 @@ describe('MobilePage pairing connection mode', () => { }) ) expect(getPairingQR).toHaveBeenCalledTimes(2) + expect(mocks.storeState.updateSettings).not.toHaveBeenCalledWith({ + mobilePairingCustomAddress: 'wss://custom.example/large', + mobilePairingCustomAddresses: ['wss://custom.example/large'] + }) + }) + + it('persists a custom address after its QR preflight succeeds', async () => { + const user = userEvent.setup() + await openPairingStep() + await waitFor(() => expect(getPairingQR).toHaveBeenCalledTimes(1)) + + await user.click(screen.getByRole('button', { name: 'Confirm custom address' })) + + await waitFor(() => + expect(mocks.storeState.updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddress: 'wss://custom.example/large', + mobilePairingCustomAddresses: ['wss://custom.example/large'] + }) + ) + expect(screen.getByTestId('custom-addresses')).toHaveTextContent('wss://custom.example/large') + }) + + it('removes the active custom address and remints with a discovered fallback', async () => { + mocks.storeState.settings = { + showMobileButton: true, + mobilePairingCustomAddress: 'wss://custom.example/large', + mobilePairingCustomAddresses: ['wss://custom.example/large', 'second.example:6768'] + } + listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] + }) + const user = userEvent.setup() + await openPairingStep() + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent('wss://custom.example/large') + ) + + await user.click(screen.getByRole('button', { name: 'Remove custom address' })) + + expect(mocks.storeState.updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: ['second.example:6768'] + }) + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.0.0.2') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('false') + await waitFor(() => + expect(getPairingQR).toHaveBeenLastCalledWith({ + address: '10.0.0.2', + connectionMode: 'automatic', + rotate: true + }) + ) + }) + + it('keeps custom intent when the saved address is also discovered', async () => { + mocks.storeState.settings = { + showMobileButton: true, + mobilePairingCustomAddress: '10.0.0.2', + mobilePairingCustomAddresses: ['10.0.0.2'] + } + listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] + }) + + await openPairingStep() + + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.0.0.2') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('true') + }) + + it('keeps a custom address when an older network refresh resolves', async () => { + let resolveRefresh: ((value: Record) => void) | undefined + listNetworkInterfaces.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }) + ) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByTestId('stage')).toHaveTextContent('intro')) + await user.click(screen.getByRole('button', { name: 'Enter flow' })) + await waitFor(() => expect(listNetworkInterfaces).toHaveBeenCalledOnce()) + expect(screen.getByTestId('refreshing-addresses')).toHaveTextContent('true') + + await user.click(screen.getByRole('button', { name: 'Confirm custom address' })) + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent('wss://custom.example/large') + ) + expect(listNetworkInterfaces).toHaveBeenCalledOnce() + + resolveRefresh?.({ interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] }) + + await waitFor(() => + expect(screen.getByTestId('refreshing-addresses')).toHaveTextContent('false') + ) + expect(screen.getByTestId('selected-address')).toHaveTextContent('wss://custom.example/large') + expect(getPairingQR).not.toHaveBeenCalledWith({ + address: '10.0.0.2', + connectionMode: 'automatic', + rotate: true + }) }) }) diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index d45083bc1d4..c6a258f3283 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -1,13 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import type { Platform, StepIndex } from './MobileHero' import type { IosChannel } from './mobile-platform-copy' -import { - selectRefreshedNetworkAddress, - type MobileNetworkInterface -} from '../settings/mobile-network-interface-selection' +import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' import { translate } from '@/i18n/i18n' import { useMobilePageEscape } from './use-mobile-page-escape' import { MobilePageContent } from './MobilePageContent' @@ -22,6 +19,10 @@ import { useMobilePairingQrInvalidation } from './use-mobile-pairing-qr-invalida import { useMobileInstallActions } from './use-mobile-install-actions' import { useMobilePagePairedDevices } from './use-mobile-page-paired-devices' import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' +import { + type MobilePairingAddressChange, + useMobilePairingAddressPreference +} from './use-mobile-pairing-address-preference' export default function MobilePage(): React.JSX.Element { const [stepIdx, setStepIdx] = useState(0) @@ -39,11 +40,23 @@ export default function MobilePage(): React.JSX.Element { const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode() const [networkInterfaces, setNetworkInterfaces] = useState([]) - const [selectedAddress, setSelectedAddress] = useState(undefined) - // Why: tracks whether `selectedAddress` came from the user typing a - // manual value rather than from an OS-enumerated interface, so the - // refresh path can keep their choice instead of snapping back to LAN. - const [addressIsManual, setAddressIsManual] = useState(false) + const pairingAddressChangeRef = useRef<(change: MobilePairingAddressChange) => void>(() => {}) + const notifyPairingAddressChange = useCallback( + (change: MobilePairingAddressChange): void => pairingAddressChangeRef.current(change), + [] + ) + const { + selectedAddress, + selectedAddressIsCustom, + customAddresses, + selectAddress: handleAddressChange, + selectCustomAddress: handleCustomAddressSelect, + removeCustomAddress: handleCustomAddressRemove, + selectAddressAfterRefresh + } = useMobilePairingAddressPreference({ + networkInterfaces, + onSelectionInvalidated: notifyPairingAddressChange + }) const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false) const hasGeneratedRef = useRef(false) const pairingRequestIdRef = useRef(0) @@ -77,6 +90,34 @@ export default function MobilePage(): React.JSX.Element { setPairLoading, setRelayMintFailure }) + useLayoutEffect(() => { + pairingAddressChangeRef.current = ({ address, source }) => { + const pairingContext = { connectionMode, signedIn } + if (source === 'user') { + if (canMintMobilePairingOffer(pairingContext)) { + void generatePairing(true, address ?? '') + } + return + } + if (source === 'refresh') { + if (hasGeneratedRef.current && canMintMobilePairingOffer(pairingContext)) { + void generatePairing(true, address) + } + return + } + const shouldRegenerate = hasGeneratedRef.current || pairLoading + pairingRequestIdRef.current += 1 + hasGeneratedRef.current = false + setPairQrDataUrl(null) + setPairingUrl(null) + setPairingQrError(false) + setRelayMintFailure(null) + setPairLoading(false) + if (shouldRegenerate && canMintMobilePairingOffer(pairingContext)) { + void generatePairing(true, address ?? '') + } + } + }, [connectionMode, generatePairing, pairLoading, signedIn]) const handleConnectionModeChange = useCallback( (nextMode: MobilePairingConnectionMode): void => { @@ -145,34 +186,7 @@ export default function MobilePage(): React.JSX.Element { const result = await window.api.mobile.listNetworkInterfaces() if (mountedRef.current) { setNetworkInterfaces(result.interfaces) - } - // Resolve the new address before committing it so we can detect a real - // change and remint the QR — otherwise the QR keeps encoding the stale - // endpoint after a network refresh swaps the active interface. - const newAddress = selectRefreshedNetworkAddress( - selectedAddress, - result.interfaces, - addressIsManual - ) - if (mountedRef.current) { - // Why: selectRefreshedNetworkAddress can rewrite selectedAddress - // (e.g. when a refresh surfaces a tailnet interface and the user - // had been on LAN). Re-derive `addressIsManual` from the new - // value so the next refresh doesn't snap the user back to LAN - // just because they once picked a non-tailnet interface. - setSelectedAddress(newAddress) - const nextIsManual = - newAddress !== undefined && - !result.interfaces.some((iface) => iface.address === newAddress) - setAddressIsManual(nextIsManual) - } - if ( - newAddress !== selectedAddress && - hasGeneratedRef.current && - canMintMobilePairingOffer({ connectionMode, signedIn }) && - mountedRef.current - ) { - void generatePairing(true, newAddress) + selectAddressAfterRefresh(result.interfaces) } } catch { // Network list is non-critical; the QR will still mint with default routing. @@ -181,7 +195,7 @@ export default function MobilePage(): React.JSX.Element { setRefreshingNetworkInterfaces(false) } } - }, [selectedAddress, generatePairing, mountedRef, addressIsManual, connectionMode, signedIn]) + }, [mountedRef, selectAddressAfterRefresh]) useEffect(() => { if (stage !== 'flow') { @@ -190,21 +204,6 @@ export default function MobilePage(): React.JSX.Element { void loadNetworkInterfaces() }, [stage, loadNetworkInterfaces]) - const handleAddressChange = useCallback( - (address: string) => { - setSelectedAddress(address) - // Why: if the picked address is not in the OS-enumerated list, it is - // a user-typed manual entry — remember that so the next refresh does - // not snap it back to a tailnet/LAN fallback. - const isManual = !networkInterfaces.some((iface) => iface.address === address) - setAddressIsManual(isManual) - if (canMintMobilePairingOffer({ connectionMode, signedIn })) { - void generatePairing(true, address) - } - }, - [generatePairing, networkInterfaces, connectionMode, signedIn] - ) - const beforeCustomAddressChange = useCallback( async (address: string): Promise => { if (!canMintMobilePairingOffer({ connectionMode, signedIn })) { @@ -311,6 +310,10 @@ export default function MobilePage(): React.JSX.Element { generatePairing={(rotate) => void generatePairing(rotate)} canGeneratePairing={canGenerate} handleAddressChange={handleAddressChange} + customAddresses={customAddresses} + selectedAddressIsCustom={selectedAddressIsCustom} + onCustomAddressSelect={handleCustomAddressSelect} + onCustomAddressRemove={handleCustomAddressRemove} beforeCustomAddressChange={beforeCustomAddressChange} handleBack={handleBack} handleContinue={handleContinue} diff --git a/src/renderer/src/components/mobile/MobilePageContent.tsx b/src/renderer/src/components/mobile/MobilePageContent.tsx index a5f4078b0c0..c0b44c18fd5 100644 --- a/src/renderer/src/components/mobile/MobilePageContent.tsx +++ b/src/renderer/src/components/mobile/MobilePageContent.tsx @@ -24,6 +24,10 @@ type MobilePageContentProps = { generatePairing: (rotate: boolean) => void canGeneratePairing: boolean handleAddressChange: (address: string) => void + customAddresses: readonly string[] + selectedAddressIsCustom: boolean + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void beforeCustomAddressChange: (address: string) => Promise handleBack: () => void handleContinue: () => void @@ -66,6 +70,10 @@ export function MobilePageContent({ generatePairing, canGeneratePairing, handleAddressChange, + customAddresses, + selectedAddressIsCustom, + onCustomAddressSelect, + onCustomAddressRemove, beforeCustomAddressChange, handleBack, handleContinue, @@ -141,8 +149,12 @@ export function MobilePageContent({ canGeneratePairing={canGeneratePairing} onCopyPairingCode={copyPairingCode} networkInterfaces={networkInterfaces} + customAddresses={customAddresses} selectedAddress={selectedAddress} + selectedAddressIsCustom={selectedAddressIsCustom} onSelectedAddressChange={handleAddressChange} + onCustomAddressSelect={onCustomAddressSelect} + onCustomAddressRemove={onCustomAddressRemove} beforeCustomAddressChange={beforeCustomAddressChange} onRefreshNetworkInterfaces={loadNetworkInterfaces} refreshingNetworkInterfaces={refreshingNetworkInterfaces} diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx index 23a0b0006ca..bd21eed05fb 100644 --- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx @@ -4,12 +4,22 @@ import { AddressPicker, type AddressOption } from '../network/AddressPicker' import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' -// Why: both pairing entry points must expose every endpoint form supported by the main process. +function formatCustomAddressLabel(address: string): string { + return translate( + 'auto.components.mobile.NetworkInterfacePicker.custom-option', + '{{address}} (custom)', + { address } + ) +} export type NetworkInterfacePickerProps = { networkInterfaces: readonly MobileNetworkInterface[] + customAddresses: readonly string[] selectedAddress: string | undefined + selectedAddressIsCustom: boolean onSelectedAddressChange: (address: string) => void + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void beforeCustomAddressChange?: (address: string) => boolean | Promise disabled?: boolean className?: string @@ -18,8 +28,12 @@ export type NetworkInterfacePickerProps = { export function NetworkInterfacePicker({ networkInterfaces, + customAddresses, selectedAddress, + selectedAddressIsCustom, onSelectedAddressChange, + onCustomAddressSelect, + onCustomAddressRemove, beforeCustomAddressChange, disabled = false, className, @@ -33,20 +47,37 @@ export function NetworkInterfacePicker({ })), [networkInterfaces] ) + const customOptions = useMemo( + () => + customAddresses.map((address) => ({ + value: address, + label: formatCustomAddressLabel(address) + })), + [customAddresses] + ) return ( + formatCustomLabel={formatCustomAddressLabel} + customSectionLabel={translate( + 'auto.components.mobile.NetworkInterfacePicker.custom-section', + 'Custom' + )} + removeCustomLabel={(address) => translate( - 'auto.components.mobile.NetworkInterfacePicker.custom-option', - '{{address}} (custom)', + 'auto.components.mobile.NetworkInterfacePicker.remove-custom', + 'Remove {{address}}', { address } ) } diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.ts b/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.ts new file mode 100644 index 00000000000..be9a14da8d8 --- /dev/null +++ b/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.ts @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { + selectRefreshedNetworkAddress, + type MobileNetworkInterface +} from '../settings/mobile-network-interface-selection' +import { + useMobilePairingCustomAddress, + useMobilePairingCustomAddresses +} from './use-mobile-pairing-custom-address' +import { + addMobilePairingCustomAddress, + removeMobilePairingCustomAddress +} from '../../../../shared/mobile-pairing-custom-address' + +function haveSameAddresses(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((address, index) => address === right[index]) +} + +export type MobilePairingAddressChange = { + address: string | undefined + source: 'external' | 'refresh' | 'user' +} + +export function useMobilePairingAddressPreference(args: { + networkInterfaces: readonly MobileNetworkInterface[] + onSelectionInvalidated: (change: MobilePairingAddressChange) => void +}): { + selectedAddress: string | undefined + selectedAddressIsCustom: boolean + customAddresses: readonly string[] + selectAddress: (address: string) => void + selectCustomAddress: (address: string) => void + removeCustomAddress: (address: string) => void + selectAddressAfterRefresh: (interfaces: readonly MobileNetworkInterface[]) => void +} { + const { networkInterfaces, onSelectionInvalidated } = args + const updateSettings = useAppStore((state) => state.updateSettings) + const savedCustomAddress = useMobilePairingCustomAddress() + const savedCustomAddresses = useMobilePairingCustomAddresses() + const [selectedAddress, setSelectedAddress] = useState(savedCustomAddress) + const [selectedAddressIsCustom, setSelectedAddressIsCustom] = useState( + savedCustomAddress !== undefined + ) + const [customAddresses, setCustomAddresses] = useState(savedCustomAddresses) + const selectedAddressRef = useRef(selectedAddress) + const selectedAddressIsManualRef = useRef(savedCustomAddress !== undefined) + const customAddressesRef = useRef(savedCustomAddresses) + const observedCustomAddressRef = useRef(savedCustomAddress) + const pendingCustomAddressWritesRef = useRef<(string | undefined)[]>([]) + + const selectAddressAfterRefresh = useCallback( + (interfaces: readonly MobileNetworkInterface[]): void => { + const nextAddress = selectRefreshedNetworkAddress( + selectedAddressRef.current, + interfaces, + selectedAddressIsManualRef.current + ) + if (nextAddress === selectedAddressRef.current) { + return + } + selectedAddressRef.current = nextAddress + selectedAddressIsManualRef.current = false + setSelectedAddress(nextAddress) + setSelectedAddressIsCustom(false) + onSelectionInvalidated({ address: nextAddress, source: 'refresh' }) + }, + [onSelectionInvalidated] + ) + + const commitAddress = useCallback( + (address: string, isManual: boolean): void => { + const addressChanged = selectedAddressRef.current !== address + if (!addressChanged && selectedAddressIsManualRef.current === isManual) { + return + } + selectedAddressRef.current = address + selectedAddressIsManualRef.current = isManual + setSelectedAddress(address) + setSelectedAddressIsCustom(isManual) + const customAddress = isManual ? address : undefined + const pendingWrites = pendingCustomAddressWritesRef.current + const effectiveCustomAddress = + pendingWrites.length > 0 ? pendingWrites.at(-1) : observedCustomAddressRef.current + if (customAddress !== effectiveCustomAddress) { + pendingWrites.push(customAddress) + if (customAddress) { + const nextCustomAddresses = addMobilePairingCustomAddress( + customAddressesRef.current, + customAddress + ) + customAddressesRef.current = nextCustomAddresses + setCustomAddresses(nextCustomAddresses) + void updateSettings({ + mobilePairingCustomAddress: customAddress, + mobilePairingCustomAddresses: nextCustomAddresses + }) + } else { + void updateSettings({ mobilePairingCustomAddress: null }) + } + } + if (addressChanged) { + onSelectionInvalidated({ address, source: 'user' }) + } + }, + [onSelectionInvalidated, updateSettings] + ) + + const selectAddress = useCallback( + (address: string): void => { + commitAddress(address, !networkInterfaces.some((iface) => iface.address === address)) + }, + [commitAddress, networkInterfaces] + ) + + const selectCustomAddress = useCallback( + (address: string): void => commitAddress(address, true), + [commitAddress] + ) + + const removeCustomAddress = useCallback( + (address: string): void => { + const nextCustomAddresses = removeMobilePairingCustomAddress( + customAddressesRef.current, + address + ) + if (haveSameAddresses(nextCustomAddresses, customAddressesRef.current)) { + return + } + customAddressesRef.current = nextCustomAddresses + setCustomAddresses(nextCustomAddresses) + const removingSelection = + selectedAddressIsManualRef.current && selectedAddressRef.current === address + if (!removingSelection) { + void updateSettings({ mobilePairingCustomAddresses: nextCustomAddresses }) + return + } + const nextAddress = selectRefreshedNetworkAddress(undefined, networkInterfaces) + const addressChanged = selectedAddressRef.current !== nextAddress + selectedAddressRef.current = nextAddress + selectedAddressIsManualRef.current = false + setSelectedAddress(nextAddress) + setSelectedAddressIsCustom(false) + pendingCustomAddressWritesRef.current.push(undefined) + void updateSettings({ + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: nextCustomAddresses + }) + if (addressChanged) { + onSelectionInvalidated({ address: nextAddress, source: 'user' }) + } + }, + [networkInterfaces, onSelectionInvalidated, updateSettings] + ) + + useEffect(() => { + if (savedCustomAddress === observedCustomAddressRef.current) { + return + } + observedCustomAddressRef.current = savedCustomAddress + const pendingWrites = pendingCustomAddressWritesRef.current + const acknowledgedWriteIndex = pendingWrites.indexOf(savedCustomAddress) + if (acknowledgedWriteIndex !== -1) { + pendingWrites.splice(0, acknowledgedWriteIndex + 1) + return + } + pendingWrites.length = 0 + const nextAddress = + savedCustomAddress ?? selectRefreshedNetworkAddress(undefined, networkInterfaces) + const addressChanged = selectedAddressRef.current !== nextAddress + selectedAddressRef.current = nextAddress + selectedAddressIsManualRef.current = savedCustomAddress !== undefined + setSelectedAddress(nextAddress) + setSelectedAddressIsCustom(savedCustomAddress !== undefined) + if (addressChanged) { + onSelectionInvalidated({ address: nextAddress, source: 'external' }) + } + }, [networkInterfaces, onSelectionInvalidated, savedCustomAddress]) + + useEffect(() => { + if (pendingCustomAddressWritesRef.current.length > 0) { + return + } + if (haveSameAddresses(savedCustomAddresses, customAddressesRef.current)) { + return + } + customAddressesRef.current = savedCustomAddresses + setCustomAddresses(savedCustomAddresses) + }, [savedCustomAddresses]) + + return { + selectedAddress, + selectedAddressIsCustom, + customAddresses, + selectAddress, + selectCustomAddress, + removeCustomAddress, + selectAddressAfterRefresh + } +} diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-custom-address.ts b/src/renderer/src/components/mobile/use-mobile-pairing-custom-address.ts new file mode 100644 index 00000000000..7d92f8d72d2 --- /dev/null +++ b/src/renderer/src/components/mobile/use-mobile-pairing-custom-address.ts @@ -0,0 +1,23 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { + addMobilePairingCustomAddress, + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses +} from '../../../../shared/mobile-pairing-custom-address' + +export function useMobilePairingCustomAddress(): string | undefined { + const savedAddress = useAppStore((state) => state.settings?.mobilePairingCustomAddress) + return normalizeMobilePairingCustomAddress(savedAddress) ?? undefined +} + +export function useMobilePairingCustomAddresses(): string[] { + const savedAddresses = useAppStore((state) => state.settings?.mobilePairingCustomAddresses) + const savedAddress = useAppStore((state) => state.settings?.mobilePairingCustomAddress) + return useMemo(() => { + const normalized = normalizeMobilePairingCustomAddresses(savedAddresses) + return typeof savedAddress === 'string' + ? addMobilePairingCustomAddress(normalized, savedAddress) + : normalized + }, [savedAddress, savedAddresses]) +} diff --git a/src/renderer/src/components/network/AddressPicker.tsx b/src/renderer/src/components/network/AddressPicker.tsx index 07cbee99d24..e2ecb5ad8b0 100644 --- a/src/renderer/src/components/network/AddressPicker.tsx +++ b/src/renderer/src/components/network/AddressPicker.tsx @@ -1,13 +1,10 @@ -import React, { useState } from 'react' -import { Plus } from 'lucide-react' -import { - Select, - SelectContent, - SelectItem, - SelectSeparator, - SelectTrigger, - SelectValue -} from '../ui/select' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Check, ChevronDown, Plus, X } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { Command, CommandGroup, CommandItem, CommandList } from '../ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { CustomAddressDialog, type CustomAddressDialogCopy, @@ -19,20 +16,80 @@ export type AddressOption = { label: string } -// Why: a sentinel Select value for the footer action. It is never committed as -// a real address; selecting it opens the custom-address dialog instead. -const ADD_CUSTOM_VALUE = '__add_custom_address__' +const EMPTY_ADDRESS_OPTIONS: readonly AddressOption[] = [] + +type AddressPickerItemProps = { + option: AddressOption + selected: boolean + commandValue: string + onSelect: () => void + onRemove?: () => void + removeLabel?: string +} + +function AddressPickerItem({ + option, + selected, + commandValue, + onSelect, + onRemove, + removeLabel +}: AddressPickerItemProps): React.JSX.Element { + return ( +
+ + + {option.label} + + {onRemove ? ( + + + + + + {removeLabel} + + + ) : null} +
+ ) +} export type AddressPickerProps = { options: readonly AddressOption[] + customOptions?: readonly AddressOption[] value: string | undefined + valueIsCustom?: boolean onValueChange: (value: string) => void + onCustomValueChange?: (value: string) => void + onCustomRemove?: (value: string) => void beforeCustomConfirm?: (value: string) => boolean | Promise - // Why: a value that isn't one of `options` is a custom entry; this renders - // its display label (e.g. `${value} (custom)`) so the Select can show it — - // Radix Select only displays values that have a matching item. formatCustomLabel: (value: string) => string addCustomLabel: string + customSectionLabel?: string + removeCustomLabel?: (value: string) => string customDialogCopy: CustomAddressDialogCopy validateCustom: CustomAddressValidator customInputId: string @@ -43,17 +100,20 @@ export type AddressPickerProps = { id?: string } -// Note (crash cluster C6, React #185 in settings): the throwing dispatch is in -// @radix-ui/react-select's SelectItem unmount cleanup, not in our code — this -// file holds no unmount-phase setState, so there is nothing here to guard. -// Item churn here is derived from props only; re-audit if that stops holding. +// Why: removable saved rows have two actions, which cannot be represented by a Select option. export function AddressPicker({ options, + customOptions = EMPTY_ADDRESS_OPTIONS, value, + valueIsCustom, onValueChange, + onCustomValueChange, + onCustomRemove, beforeCustomConfirm, formatCustomLabel, addCustomLabel, + customSectionLabel, + removeCustomLabel, customDialogCopy, validateCustom, customInputId, @@ -63,52 +123,244 @@ export function AddressPicker({ className, id }: AddressPickerProps): React.JSX.Element { + const [pickerOpen, setPickerOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false) - + const [commandValue, setCommandValue] = useState('') + const [listId, setListId] = useState() + const listRef = useRef(null) + const restoreFocusAfterRemovalRef = useRef(false) + const typeaheadRef = useRef({ query: '', updatedAt: 0 }) + const handleListRef = useCallback((node: HTMLDivElement | null) => { + listRef.current = node + setListId(node?.id) + }, []) const isCustomSelection = - value !== undefined && value !== '' && !options.some((option) => option.value === value) + value !== undefined && + value !== '' && + (valueIsCustom ?? !options.some((option) => option.value === value)) + const displayedCustomOptions = useMemo(() => { + if ( + !isCustomSelection || + value === undefined || + customOptions.some((option) => option.value === value) + ) { + return customOptions + } + return [...customOptions, { value, label: formatCustomLabel(value) }] + }, [customOptions, formatCustomLabel, isCustomSelection, value]) + const selectedOption = + (isCustomSelection + ? displayedCustomOptions.find((option) => option.value === value) + : options.find((option) => option.value === value)) ?? + displayedCustomOptions.find((option) => option.value === value) + const selectedCommandValue = value ? `${isCustomSelection ? 'custom' : 'detected'}:${value}` : '' + const customValueChange = onCustomValueChange ?? onValueChange + const firstCommandValue = + selectedCommandValue || + (options[0] + ? `detected:${options[0].value}` + : displayedCustomOptions[0] + ? `custom:${displayedCustomOptions[0].value}` + : 'add-custom-address') - const handleValueChange = (next: string): void => { - if (next === ADD_CUSTOM_VALUE) { - setDialogOpen(true) + useEffect(() => { + if (!pickerOpen) { return } - onValueChange(next) + const commandValueExists = + commandValue === 'add-custom-address' || + options.some((option) => commandValue === `detected:${option.value}`) || + displayedCustomOptions.some((option) => commandValue === `custom:${option.value}`) + if (!commandValueExists) { + setCommandValue(firstCommandValue) + } + }, [commandValue, displayedCustomOptions, firstCommandValue, options, pickerOpen]) + + useEffect(() => { + if (!pickerOpen || !restoreFocusAfterRemovalRef.current) { + return + } + restoreFocusAfterRemovalRef.current = false + listRef.current?.focus() + }, [displayedCustomOptions, pickerOpen]) + + useEffect(() => { + if (!pickerOpen) { + return + } + const frame = window.requestAnimationFrame(() => { + const list = listRef.current + const activeOption = list?.querySelector('[cmdk-item][aria-selected="true"]') + if (list && activeOption?.id) { + list.setAttribute('aria-activedescendant', activeOption.id) + } + }) + return () => window.cancelAnimationFrame(frame) + }, [commandValue, displayedCustomOptions, options, pickerOpen]) + + const handlePickerOpenChange = (nextOpen: boolean): void => { + typeaheadRef.current = { query: '', updatedAt: 0 } + if (nextOpen) { + setCommandValue(firstCommandValue) + } + setPickerOpen(nextOpen) + } + + const handleCommandKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === ' ') { + event.preventDefault() + listRef.current?.querySelector('[cmdk-item][aria-selected="true"]')?.click() + return + } + if ( + event.key.length !== 1 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.nativeEvent.isComposing + ) { + return + } + event.preventDefault() + const now = Date.now() + const previous = typeaheadRef.current + const query = now - previous.updatedAt > 700 ? event.key : previous.query + event.key + typeaheadRef.current = { query, updatedAt: now } + const repeatedKey = [...query].every((character) => character === query[0]) + const prefix = (repeatedKey ? event.key : query).toLocaleLowerCase() + const items = [ + ...options.map((option) => ({ command: `detected:${option.value}`, label: option.label })), + ...displayedCustomOptions.map((option) => ({ + command: `custom:${option.value}`, + label: option.label + })), + { command: 'add-custom-address', label: addCustomLabel } + ] + const currentIndex = items.findIndex((item) => item.command === commandValue) + const nextItem = [...items.slice(currentIndex + 1), ...items.slice(0, currentIndex + 1)].find( + (item) => item.label.toLocaleLowerCase().startsWith(prefix) + ) + if (nextItem) { + setCommandValue(nextItem.command) + } + } + + const selectValue = (next: string, custom: boolean): void => { + setPickerOpen(false) + if (custom) { + customValueChange(next) + } else { + onValueChange(next) + } } const handleCustomConfirm = async (next: string): Promise => { if (beforeCustomConfirm && !(await beforeCustomConfirm(next))) { return false } - onValueChange(next) + customValueChange(next) return true } return ( <> - + + {selectedOption?.label ?? placeholder} + + + + + { + event.preventDefault() + listRef.current?.focus() + }} + > + + + {options.length > 0 ? ( + + {options.map((option) => ( + selectValue(option.value, false)} + /> + ))} + + ) : null} + {displayedCustomOptions.length > 0 ? ( + 0 && 'border-t border-border pt-1')} + > + {displayedCustomOptions.map((option) => ( + selectValue(option.value, true)} + onRemove={ + onCustomRemove + ? () => { + restoreFocusAfterRemovalRef.current = true + onCustomRemove(option.value) + } + : undefined + } + removeLabel={removeCustomLabel?.(option.value)} + /> + ))} + + ) : null} + 0 || displayedCustomOptions.length > 0) && + 'border-t border-border pt-1' + )} + > + { + setPickerOpen(false) + setDialogOpen(true) + }} + className="text-muted-foreground data-[selected=true]:text-foreground" + > + + {addCustomLabel} + + + + + + > = {} ) { const onSelectedAddressChange = vi.fn() + const onCustomAddressSelect = vi.fn() + const onCustomAddressRemove = vi.fn() const onRefreshNetworkInterfaces = vi.fn() const onGenerateQr = vi.fn() const props: React.ComponentProps = { connectionMode: 'local-only', connectionPathControl:
path
, networkInterfaces: [LAN, TAILNET], + customAddresses: [], selectedAddress: TAILNET.address, + selectedAddressIsCustom: false, onSelectedAddressChange, + onCustomAddressSelect, + onCustomAddressRemove, refreshingNetworkInterfaces: false, onRefreshNetworkInterfaces, loading: false, @@ -40,7 +46,15 @@ function renderSection( ) - return { ...rendered, user, onSelectedAddressChange, onGenerateQr } + return { + ...rendered, + user, + props, + onSelectedAddressChange, + onCustomAddressSelect, + onCustomAddressRemove, + onGenerateQr + } } describe('MobilePairingSetupSection', () => { @@ -92,6 +106,114 @@ describe('MobilePairingSetupSection', () => { expect(onSelectedAddressChange).toHaveBeenCalledWith('192.168.1.24') }) + it('lists and removes saved custom addresses without selecting them', async () => { + const first = 'first.example:6768' + const second = 'second.example:6768' + const { user, onSelectedAddressChange, onCustomAddressSelect, onCustomAddressRemove } = + renderSection({ customAddresses: [first, second] }) + + await user.click(screen.getByRole('combobox')) + expect(screen.getByText('Custom')).toBeVisible() + expect(screen.getByRole('option', { name: `${first} (custom)` })).toBeVisible() + expect(screen.getByRole('option', { name: `${second} (custom)` })).toBeVisible() + const addOption = screen.getByRole('option', { name: 'Add custom address…' }) + expect( + screen + .getByRole('option', { name: `${second} (custom)` }) + .compareDocumentPosition(addOption) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + + const remove = screen.getByRole('button', { name: `Remove ${first}` }) + remove.focus() + expect(remove).toHaveFocus() + await user.click(remove) + + expect(onCustomAddressRemove).toHaveBeenCalledWith(first) + expect(onCustomAddressSelect).not.toHaveBeenCalled() + expect(onSelectedAddressChange).not.toHaveBeenCalled() + }) + + it('restores list focus after removing a custom address with the keyboard', async () => { + const address = 'first.example:6768' + const { user, props, rerender, onCustomAddressSelect } = renderSection({ + customAddresses: [address] + }) + + await user.click(screen.getByRole('combobox')) + const remove = screen.getByRole('button', { name: `Remove ${address}` }) + remove.focus() + await user.keyboard('{Enter}') + rerender( + + + + ) + + expect(screen.getByRole('listbox', { name: 'Network address to advertise' })).toHaveFocus() + expect(onCustomAddressSelect).not.toHaveBeenCalled() + }) + + it('selects a saved custom address through the custom path', async () => { + const address = '100.64.1.20' + const { user, onSelectedAddressChange, onCustomAddressSelect } = renderSection({ + customAddresses: [address], + selectedAddressIsCustom: true + }) + + await user.click(screen.getByRole('combobox')) + const customOption = screen.getByRole('option', { name: `${address} (custom)` }) + expect(customOption).toHaveAttribute('data-current', 'true') + await user.click(customOption) + + expect(onCustomAddressSelect).toHaveBeenCalledWith(address) + expect(onSelectedAddressChange).not.toHaveBeenCalled() + }) + + it('keeps keyboard selection working with removable custom rows', async () => { + const address = 'first.example:6768' + const { user, onCustomAddressSelect } = renderSection({ customAddresses: [address] }) + + const trigger = screen.getByRole('combobox') + await user.click(trigger) + const list = screen.getByRole('listbox', { name: 'Network address to advertise' }) + expect(list).toHaveFocus() + expect(trigger).toHaveAttribute('aria-controls', list.id) + const selectedOption = screen.getByRole('option', { name: '100.64.1.20 (tailscale0)' }) + await vi.waitFor(() => expect(list).toHaveAttribute('aria-activedescendant', selectedOption.id)) + await user.keyboard('{ArrowDown}{Enter}') + + expect(onCustomAddressSelect).toHaveBeenCalledWith(address) + }) + + it('supports prefix typeahead across custom addresses', async () => { + const address = 'zebra.example:6768' + const { user, onCustomAddressSelect } = renderSection({ + customAddresses: ['alpha.example:6768', address] + }) + + await user.click(screen.getByRole('combobox')) + await user.keyboard('z') + const list = screen.getByRole('listbox', { name: 'Network address to advertise' }) + const customOption = screen.getByRole('option', { name: `${address} (custom)` }) + await vi.waitFor(() => expect(list).toHaveAttribute('aria-activedescendant', customOption.id)) + await user.keyboard('{Enter}') + + expect(onCustomAddressSelect).toHaveBeenCalledWith(address) + }) + + it('selects the highlighted custom address with Space', async () => { + const address = 'first.example:6768' + const { user, onCustomAddressSelect, onCustomAddressRemove } = renderSection({ + customAddresses: [address] + }) + + await user.click(screen.getByRole('combobox')) + await user.keyboard('{ArrowDown} ') + + expect(onCustomAddressSelect).toHaveBeenCalledWith(address) + expect(onCustomAddressRemove).not.toHaveBeenCalled() + }) + it('generates a pairing code', async () => { const { user, onGenerateQr } = renderSection() await user.click(screen.getByRole('button', { name: 'Generate QR code' })) diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx index 12d63791f61..8367b49fdbe 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx @@ -13,8 +13,12 @@ type MobilePairingSetupSectionProps = { canGenerate?: boolean connectionPathControl: ReactNode networkInterfaces: MobileNetworkInterface[] + customAddresses: readonly string[] selectedAddress: string | undefined + selectedAddressIsCustom: boolean onSelectedAddressChange: (address: string) => void + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void refreshingNetworkInterfaces: boolean onRefreshNetworkInterfaces: () => void loading: boolean @@ -28,8 +32,12 @@ export function MobilePairingSetupSection({ canGenerate = true, connectionPathControl, networkInterfaces, + customAddresses, selectedAddress, + selectedAddressIsCustom, onSelectedAddressChange, + onCustomAddressSelect, + onCustomAddressRemove, refreshingNetworkInterfaces, onRefreshNetworkInterfaces, loading, @@ -71,8 +79,12 @@ export function MobilePairingSetupSection({
diff --git a/src/renderer/src/components/settings/MobilePane.test.tsx b/src/renderer/src/components/settings/MobilePane.test.tsx index 03bc813188b..948cc96615d 100644 --- a/src/renderer/src/components/settings/MobilePane.test.tsx +++ b/src/renderer/src/components/settings/MobilePane.test.tsx @@ -26,6 +26,8 @@ type StoreState = { settings: { mobileAutoRestoreFitMs: number | null mobilePairingConnectionMode?: MobilePairingConnectionMode + mobilePairingCustomAddress?: string | null + mobilePairingCustomAddresses?: string[] } updateSettings: (patch: Record) => Promise recordFeatureInteraction: (feature: string) => void @@ -71,12 +73,25 @@ vi.mock('./MobilePairingSetupSection', () => ({ canGenerate?: boolean loading: boolean connectionPathControl: React.ReactNode + networkInterfaces: { name: string; address: string }[] + customAddresses: readonly string[] + selectedAddress: string | undefined + selectedAddressIsCustom: boolean + onSelectedAddressChange: (address: string) => void + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void + refreshingNetworkInterfaces: boolean + onRefreshNetworkInterfaces: () => void onGenerateQr: () => void }) => (
{props.connectionMode} {String(props.canGenerate)} {String(props.loading)} + {props.selectedAddress ?? 'none'} + {String(props.selectedAddressIsCustom)} + {props.customAddresses.join(',')} + {String(props.refreshingNetworkInterfaces)} {props.connectionPathControl} {/* Mirror the real Generate gate (loading/canGenerate) so a stuck loading flag surfaces as a disabled control the tests can catch. */} @@ -87,6 +102,22 @@ vi.mock('./MobilePairingSetupSection', () => ({ > Generate + + + +
) })) @@ -376,6 +407,195 @@ describe('MobilePane pairing connection mode', () => { expect(screen.getByTestId('mode')).toHaveTextContent('local-only') }) + it('restores a saved custom address for future pairing codes', async () => { + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingCustomAddress: '100.126.117.25:6768' + } + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] + }) + const user = userEvent.setup() + render() + + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent('100.126.117.25:6768') + ) + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => + expect(getPairingQR).toHaveBeenCalledWith({ + address: '100.126.117.25:6768', + connectionMode: 'automatic' + }) + ) + }) + + it('persists a custom address and clears it when a discovered address is selected', async () => { + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] + }) + const user = userEvent.setup() + render() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce()) + + await user.click(screen.getByRole('button', { name: 'choose-custom-address' })) + expect(updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddress: '100.126.117.25:6768', + mobilePairingCustomAddresses: ['100.126.117.25:6768'] + }) + expect(screen.getByTestId('selected-address')).toHaveTextContent('100.126.117.25:6768') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('true') + expect(screen.getByTestId('custom-addresses')).toHaveTextContent('100.126.117.25:6768') + + await user.click(screen.getByRole('button', { name: 'choose-discovered-address' })) + expect(updateSettings).toHaveBeenCalledWith({ mobilePairingCustomAddress: null }) + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.0.0.2') + expect(screen.getByTestId('custom-addresses')).toHaveTextContent('100.126.117.25:6768') + }) + + it('keeps the current pairing code when the active custom address is reselected', async () => { + const customAddress = '100.126.117.25:6768' + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingCustomAddress: customAddress, + mobilePairingCustomAddresses: [customAddress] + } + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr')) + getPairingQR.mockClear() + updateSettings.mockClear() + + await user.click(screen.getByRole('button', { name: 'choose-custom-address' })) + + expect(updateSettings).not.toHaveBeenCalled() + expect(getPairingQR).not.toHaveBeenCalled() + expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr') + }) + + it('keeps the current pairing code when only custom address intent changes', async () => { + const address = '100.126.117.25:6768' + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingCustomAddress: address, + mobilePairingCustomAddresses: [address] + } + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Tailscale', address }] + }) + const user = userEvent.setup() + render() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce()) + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr')) + getPairingQR.mockClear() + updateSettings.mockClear() + + await user.click(screen.getByRole('button', { name: 'choose-discovered-address' })) + + expect(updateSettings).toHaveBeenCalledWith({ mobilePairingCustomAddress: null }) + expect(getPairingQR).not.toHaveBeenCalled() + expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('false') + + updateSettings.mockClear() + await user.click(screen.getByRole('button', { name: 'choose-custom-address' })) + expect(updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddress: address, + mobilePairingCustomAddresses: [address] + }) + updateSettings.mockClear() + await user.click(screen.getByRole('button', { name: 'remove-custom-address' })) + + expect(updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: [] + }) + expect(getPairingQR).not.toHaveBeenCalled() + expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('false') + }) + + it('removes the selected custom address and falls back to discovery', async () => { + const customAddress = '100.126.117.25:6768' + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingCustomAddress: customAddress, + mobilePairingCustomAddresses: [customAddress, 'second.example:6768'] + } + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] + }) + const user = userEvent.setup() + render() + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent(customAddress) + ) + + await user.click(screen.getByRole('button', { name: 'remove-custom-address' })) + + expect(updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: ['second.example:6768'] + }) + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.0.0.2') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('false') + }) + + it('removes an inactive custom address without changing the selection', async () => { + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingCustomAddress: 'second.example:6768', + mobilePairingCustomAddresses: ['100.126.117.25:6768', 'second.example:6768'] + } + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'remove-custom-address' })) + + expect(updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddresses: ['second.example:6768'] + }) + expect(screen.getByTestId('selected-address')).toHaveTextContent('second.example:6768') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('true') + }) + + it('keeps a saved custom override when discovery later stops listing it', async () => { + const customAddress = '100.126.117.25:6768' + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingCustomAddress: customAddress + } + mocks.listNetworkInterfaces.mockResolvedValueOnce({ + interfaces: [{ name: 'Tailscale', address: customAddress }] + }) + const user = userEvent.setup() + render() + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent(customAddress) + ) + + let resolveRefresh: ((value: Record) => void) | undefined + mocks.listNetworkInterfaces.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }) + ) + await user.click(screen.getByRole('button', { name: 'refresh-addresses' })) + expect(screen.getByTestId('refreshing-addresses')).toHaveTextContent('true') + + resolveRefresh?.({ interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] }) + await waitFor(() => + expect(screen.getByTestId('refreshing-addresses')).toHaveTextContent('false') + ) + + expect(screen.getByTestId('selected-address')).toHaveTextContent(customAddress) + expect(updateSettings).not.toHaveBeenCalled() + }) + it('discards a Relay QR that resolves after signing out mid-generate', async () => { const user = userEvent.setup() let resolveQr: ((value: Record) => void) | undefined diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index f7f1ae0c8be..00fb7f34399 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -8,10 +8,7 @@ import { usePairedMobileDevices } from '../mobile/paired-mobile-devices' import { useMobilePairingDevicePolling } from './mobile-pairing-device-polling' -import { - selectRefreshedNetworkAddress, - type MobileNetworkInterface -} from './mobile-network-interface-selection' +import type { MobileNetworkInterface } from './mobile-network-interface-selection' import { MobilePairingQrSection } from './MobilePairingQrSection' import { MobilePairedDevicesSection } from './MobilePairedDevicesSection' import { MobileAutoRestoreFitSection } from './MobileAutoRestoreFitSection' @@ -26,6 +23,7 @@ import { } from '../../../../shared/mobile-pairing-connection-mode' import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' import { useMobilePairingConnectionMode } from '../mobile/use-mobile-pairing-connection-mode' +import { useMobilePairingAddressPreference } from '../mobile/use-mobile-pairing-address-preference' export { getMobilePaneSearchEntries } from './mobile-pane-search' export function MobilePane(): React.JSX.Element { @@ -39,7 +37,6 @@ export function MobilePane(): React.JSX.Element { const [loading, setLoading] = useState(false) const [qrEnlarged, setQrEnlarged] = useState(false) const [networkInterfaces, setNetworkInterfaces] = useState([]) - const [selectedAddress, setSelectedAddress] = useState(undefined) const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false) const [codeCopied, setCodeCopied] = useState(false) const [deviceCountAtQr, setDeviceCountAtQr] = useState(null) @@ -55,8 +52,6 @@ export function MobilePane(): React.JSX.Element { // Tracks the mode we last acted on so the connectionMode effect can tell a // cross-window preference sync apart from our own path change. const handledModeRef = useRef(connectionMode) - // Latest address without stale-closure risk inside loadNetworkInterfaces. - const selectedAddressRef = useRef(selectedAddress) // Ref mirrors of QR-visible / loading so invalidatePairing stays stable and // cannot make loadNetworkInterfaces re-fetch on every generate. const qrDisplayedRef = useRef(false) @@ -100,6 +95,19 @@ export function MobilePane(): React.JSX.Element { setRotateNextQr(true) } }, []) + const invalidatePairingAddress = useCallback(() => invalidatePairing(), [invalidatePairing]) + const { + selectedAddress, + selectedAddressIsCustom, + customAddresses, + selectAddress: handleSelectedAddressChange, + selectCustomAddress: handleCustomAddressSelect, + removeCustomAddress: handleCustomAddressRemove, + selectAddressAfterRefresh + } = useMobilePairingAddressPreference({ + networkInterfaces, + onSelectionInvalidated: invalidatePairingAddress + }) // Why: a Relay QR minted while signed in must not linger on a now-signed-out // desktop — Generate is disabled in that state. Invalidate any pending relay @@ -135,17 +143,7 @@ export function MobilePane(): React.JSX.Element { const result = await window.api.mobile.listNetworkInterfaces() if (mountedRef.current) { setNetworkInterfaces(result.interfaces) - const nextAddress = selectRefreshedNetworkAddress( - selectedAddressRef.current, - result.interfaces - ) - if (nextAddress !== selectedAddressRef.current) { - selectedAddressRef.current = nextAddress - setSelectedAddress(nextAddress) - // A refresh moved the active interface; invalidate so a shown QR - // can't keep encoding the previous endpoint. - invalidatePairing() - } + selectAddressAfterRefresh(result.interfaces) } } catch { if (opts.notifyOnError && mountedRef.current) { @@ -162,7 +160,7 @@ export function MobilePane(): React.JSX.Element { } } }, - [mountedRef, invalidatePairing] + [mountedRef, selectAddressAfterRefresh] ) const generateQR = useCallback( @@ -324,16 +322,6 @@ export function MobilePane(): React.JSX.Element { } }, [connectionMode, mountedRef, relayMintFailure, selectedAddress]) - const handleSelectedAddressChange = useCallback( - (address: string): void => { - setSelectedAddress(address) - selectedAddressRef.current = address - // Switching endpoints: a shown QR now encodes the old address. - invalidatePairing() - }, - [invalidatePairing] - ) - // Why: another window can persist a different path; the shared hook syncs // connectionMode here without routing through changeConnectionMode. Treat // that external change like a user path change so a QR for the old policy @@ -409,8 +397,12 @@ export function MobilePane(): React.JSX.Element { /> } networkInterfaces={networkInterfaces} + customAddresses={customAddresses} selectedAddress={selectedAddress} + selectedAddressIsCustom={selectedAddressIsCustom} onSelectedAddressChange={handleSelectedAddressChange} + onCustomAddressSelect={handleCustomAddressSelect} + onCustomAddressRemove={handleCustomAddressRemove} refreshingNetworkInterfaces={refreshingNetworkInterfaces} onRefreshNetworkInterfaces={() => void loadNetworkInterfaces({ notifyOnError: true })} loading={loading} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 98b6ff3d646..f9990dc806b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11918,7 +11918,9 @@ "NetworkInterfacePicker": { "trigger-label": "Network address to advertise", "custom-option": "{{address}} (custom)", - "add-custom": "Add custom address…" + "add-custom": "Add custom address…", + "custom-section": "Custom", + "remove-custom": "Remove {{address}}" }, "WindowsFirewallNotice": { "repair-success": "Windows Firewall now allows Orca Mobile on private networks", diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index f5a706ff0de..612caab0be4 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -210,6 +210,23 @@ describe('createSettingsSlice checked persistence', () => { consoleError.mockRestore() } }) + + it('normalizes malformed mobile pairing addresses before renderer IPC', async () => { + const store = createTestStore() + store.setState({ + settings: { notifications: {} } as unknown as AppState['settings'] + }) + + await store.getState().updateSettingsOrThrow({ + mobilePairingCustomAddress: 'host:99999' as never, + mobilePairingCustomAddresses: [' first.example:6768 ', 'host:99999', 'first.example:6768'] + }) + + expect(settingsSet).toHaveBeenCalledWith({ + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: ['first.example:6768'] + }) + }) }) describe('createSettingsSlice runtime switching', () => { diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 7e42263ac8f..670a2556e10 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -23,6 +23,10 @@ import { bumpProviderRuntimeSessionGeneration } from '@/lib/provider-runtime-con import { normalizeUiLanguage } from '../../../../shared/ui-language' import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal-scrollback-policy' import { translate } from '@/i18n/i18n' +import { + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses +} from '../../../../shared/mobile-pairing-custom-address' export type SettingsSlice = SettingsSearchState & { settings: GlobalSettings | null @@ -105,6 +109,16 @@ function normalizeSettingsUpdates( updates.terminalScrollbackRows ) } + if ('mobilePairingCustomAddress' in updates) { + sanitizedUpdates.mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( + updates.mobilePairingCustomAddress + ) + } + if ('mobilePairingCustomAddresses' in updates) { + sanitizedUpdates.mobilePairingCustomAddresses = normalizeMobilePairingCustomAddresses( + updates.mobilePairingCustomAddresses + ) + } return sanitizedUpdates } diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index f6bd48c8fde..e8c55be052d 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -27,6 +27,11 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').sourceControlGroupOrder).toBe('changes-first') }) + it('defaults mobile pairing to discovered network addresses', () => { + expect(getDefaultSettings('/tmp').mobilePairingCustomAddress).toBeNull() + expect(getDefaultSettings('/tmp').mobilePairingCustomAddresses).toEqual([]) + }) + it('keeps first-work branch auto-renaming on by default for new settings', () => { expect(getDefaultSettings('/tmp').autoRenameBranchFromWork).toBe(true) expect(getDefaultSettings('/tmp').autoRenameBranchFromWorkDefaultedOn).toBe(true) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 536663e1d6a..95e0f33bcc9 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -351,6 +351,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings { mobileAutoRestoreFitMs: null, // Why: Anywhere (Relay + local) is the default; local-only is written only on explicit same-network choice. mobilePairingConnectionMode: 'automatic', + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: [], // Why: off keeps the cosmetic overlay unmounted for users who never opt in. experimentalPet: false, experimentalActivity: false, diff --git a/src/shared/mobile-pairing-custom-address.test.ts b/src/shared/mobile-pairing-custom-address.test.ts new file mode 100644 index 00000000000..b63e88cfa34 --- /dev/null +++ b/src/shared/mobile-pairing-custom-address.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_MOBILE_PAIRING_CUSTOM_ADDRESSES, + addMobilePairingCustomAddress, + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses, + removeMobilePairingCustomAddress +} from './mobile-pairing-custom-address' + +describe('normalizeMobilePairingCustomAddress', () => { + it('keeps a valid custom address', () => { + expect(normalizeMobilePairingCustomAddress(' 100.126.117.25:6768 ')).toBe('100.126.117.25:6768') + }) + + it.each([null, undefined, 42, '', '0.0.0.0', 'host:99999'])( + 'clears an invalid persisted value: %s', + (value) => { + expect(normalizeMobilePairingCustomAddress(value)).toBeNull() + } + ) +}) + +describe('mobile pairing custom address collection', () => { + it('normalizes, deduplicates, and drops invalid entries', () => { + expect( + normalizeMobilePairingCustomAddresses([ + ' first.example:6768 ', + 'host:99999', + 'first.example:6768', + 'second.example:6768' + ]) + ).toEqual(['first.example:6768', 'second.example:6768']) + }) + + it('bounds persisted entries', () => { + const addresses = Array.from( + { length: MAX_MOBILE_PAIRING_CUSTOM_ADDRESSES + 5 }, + (_, index) => `host-${index}.example:6768` + ) + expect(normalizeMobilePairingCustomAddresses(addresses)).toHaveLength( + MAX_MOBILE_PAIRING_CUSTOM_ADDRESSES + ) + }) + + it('adds the newest address and removes a saved address', () => { + expect(addMobilePairingCustomAddress(['first.example'], ' second.example ')).toEqual([ + 'first.example', + 'second.example' + ]) + expect( + removeMobilePairingCustomAddress(['first.example', 'second.example'], 'first.example') + ).toEqual(['second.example']) + }) +}) diff --git a/src/shared/mobile-pairing-custom-address.ts b/src/shared/mobile-pairing-custom-address.ts new file mode 100644 index 00000000000..ecf8d1cefaa --- /dev/null +++ b/src/shared/mobile-pairing-custom-address.ts @@ -0,0 +1,53 @@ +import { parseManualNetworkAddress } from './network/manual-address' + +export const MAX_MOBILE_PAIRING_CUSTOM_ADDRESSES = 50 + +export function normalizeMobilePairingCustomAddress(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const parsed = parseManualNetworkAddress(value) + return parsed.ok ? parsed.address : null +} + +export function normalizeMobilePairingCustomAddresses(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + const normalized: string[] = [] + for (const candidate of value) { + const address = normalizeMobilePairingCustomAddress(candidate) + if (address && !normalized.includes(address)) { + normalized.push(address) + } + if (normalized.length === MAX_MOBILE_PAIRING_CUSTOM_ADDRESSES) { + break + } + } + return normalized +} + +export function addMobilePairingCustomAddress( + addresses: readonly string[], + value: string +): string[] { + const address = normalizeMobilePairingCustomAddress(value) + if (!address) { + return normalizeMobilePairingCustomAddresses(addresses) + } + const normalized = normalizeMobilePairingCustomAddresses(addresses) + if (normalized.includes(address)) { + return normalized + } + return [...normalized, address].slice(-MAX_MOBILE_PAIRING_CUSTOM_ADDRESSES) +} + +export function removeMobilePairingCustomAddress( + addresses: readonly string[], + value: string +): string[] { + const address = normalizeMobilePairingCustomAddress(value) + return normalizeMobilePairingCustomAddresses(addresses).filter( + (candidate) => candidate !== address + ) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index f713ada0645..51a8d37dc53 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3002,6 +3002,10 @@ export type GlobalSettings = { /** Preferred mobile pairing path for new QR codes. Missing/'automatic' = Anywhere (Relay + local); * explicit 'local-only' = same-network only. */ mobilePairingConnectionMode?: 'automatic' | 'local-only' + /** Explicit custom address restored when generating future mobile pairing codes. */ + mobilePairingCustomAddress?: string | null + /** Saved custom addresses available in both mobile pairing pickers. */ + mobilePairingCustomAddresses?: string[] /** Experimental: floating animated pet in the bottom-right corner. Opt-in cosmetic; * off never mounts the overlay, and toggling takes effect instantly (renderer-side). */ experimentalPet: boolean