fix(mobile): remember custom pairing addresses (#11741)

* fix(mobile): remember custom pairing address

* fix(mobile): stabilize custom pairing address sync

* fix(mobile): update pairing refresh refs after commit

* feat(mobile): manage saved custom pairing addresses

* fix(mobile): harden custom address selection
This commit is contained in:
Brennan Benson
2026-07-31 13:03:11 -07:00
committed by GitHub
parent 129d8b32bb
commit e698241aab
26 changed files with 1523 additions and 141 deletions
+48
View File
@@ -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<unknown>
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<unknown>
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: [] })
+14
View File
@@ -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
}
+53
View File
@@ -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()
+62
View File
@@ -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
@@ -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
@@ -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<boolean>
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}
@@ -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<boolean>
onRefreshNetworkInterfaces: () => void
refreshingNetworkInterfaces: boolean
@@ -218,8 +226,12 @@ export function MobileHeroPairingStep({
</span>
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
customAddresses={customAddresses}
selectedAddress={selectedAddress}
selectedAddressIsCustom={selectedAddressIsCustom}
onSelectedAddressChange={onSelectedAddressChange}
onCustomAddressSelect={onCustomAddressSelect}
onCustomAddressRemove={onCustomAddressRemove}
beforeCustomAddressChange={beforeCustomAddressChange}
disabled={false}
className="mp-network-select"
@@ -11,7 +11,12 @@ import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-min
type StoreState = {
closeMobilePage: () => void
orcaProfileAuthStatus: { state: 'connected' | 'local' }
settings: { showMobileButton: boolean; mobilePairingConnectionMode?: MobilePairingConnectionMode }
settings: {
showMobileButton: boolean
mobilePairingConnectionMode?: MobilePairingConnectionMode
mobilePairingCustomAddress?: string | null
mobilePairingCustomAddresses?: string[]
}
updateSettings: () => Promise<void>
}
@@ -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<boolean>
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', () => ({
<span data-testid="pairing-url">{props.pairingUrl ?? 'none'}</span>
<span data-testid="pairing-qr-error">{String(props.pairingQrError)}</span>
<span data-testid="relay-failure">{props.relayMintFailure?.stage ?? 'none'}</span>
<span data-testid="selected-address">{props.selectedAddress ?? 'none'}</span>
<span data-testid="selected-address-is-custom">{String(props.selectedAddressIsCustom)}</span>
<span data-testid="custom-addresses">{props.customAddresses.join(',')}</span>
<span data-testid="refreshing-addresses">{String(props.refreshingNetworkInterfaces)}</span>
<button type="button" onClick={props.enterFlow}>
Enter flow
</button>
@@ -81,18 +97,27 @@ vi.mock('./MobilePageContent', () => ({
<button type="button" onClick={() => props.handleAddressChange('10.0.0.2')}>
Change address
</button>
<button type="button" onClick={props.loadNetworkInterfaces}>
Refresh addresses
</button>
<button
type="button"
onClick={() =>
void props.beforeCustomAddressChange('wss://custom.example/large').then((confirmed) => {
if (confirmed) {
props.handleAddressChange('wss://custom.example/large')
props.onCustomAddressSelect('wss://custom.example/large')
}
})
}
>
Confirm custom address
</button>
<button
type="button"
onClick={() => props.onCustomAddressRemove('wss://custom.example/large')}
>
Remove custom address
</button>
</div>
)
}))
@@ -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<string, unknown>) => void) | undefined
listNetworkInterfaces.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRefresh = resolve
})
)
const user = userEvent.setup()
render(<MobilePage />)
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
})
})
})
@@ -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<StepIndex>(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<MobileNetworkInterface[]>([])
const [selectedAddress, setSelectedAddress] = useState<string | undefined>(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<boolean> => {
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}
@@ -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<boolean>
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}
@@ -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<boolean>
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<AddressOption[]>(
() =>
customAddresses.map((address) => ({
value: address,
label: formatCustomAddressLabel(address)
})),
[customAddresses]
)
return (
<AddressPicker
options={options}
customOptions={customOptions}
value={selectedAddress}
valueIsCustom={selectedAddressIsCustom}
onValueChange={onSelectedAddressChange}
onCustomValueChange={onCustomAddressSelect}
onCustomRemove={onCustomAddressRemove}
beforeCustomConfirm={beforeCustomAddressChange}
disabled={disabled}
className={className}
id={id}
formatCustomLabel={(address) =>
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 }
)
}
@@ -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<string | undefined>(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
}
}
@@ -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])
}
@@ -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 (
<div className="group relative">
<CommandItem
value={commandValue}
onSelect={onSelect}
data-current={selected ? 'true' : undefined}
className={cn('peer min-w-0', onRemove && 'pr-8', selected && 'bg-accent')}
>
<Check className={cn('size-3.5 shrink-0', !selected && 'invisible')} aria-hidden />
<span className="min-w-0 flex-1 truncate">{option.label}</span>
</CommandItem>
{onRemove ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={removeLabel}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation()
}
}}
onClick={(event) => {
event.stopPropagation()
onRemove()
}}
className="absolute top-1/2 right-1 -translate-y-1/2 text-muted-foreground opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 peer-data-[selected=true]:opacity-100 hover:text-destructive focus-visible:opacity-100"
>
<X aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{removeLabel}
</TooltipContent>
</Tooltip>
) : null}
</div>
)
}
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<boolean>
// 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<string>()
const listRef = useRef<HTMLDivElement>(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<HTMLElement>('[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<HTMLElement>('[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<boolean> => {
if (beforeCustomConfirm && !(await beforeCustomConfirm(next))) {
return false
}
onValueChange(next)
customValueChange(next)
return true
}
return (
<>
<Select value={value ?? ''} onValueChange={handleValueChange} disabled={disabled}>
<SelectTrigger id={id} size="sm" className={className} aria-label={triggerAriaLabel}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
{isCustomSelection ? (
<SelectItem value={value}>{formatCustomLabel(value)}</SelectItem>
) : null}
{options.length > 0 || isCustomSelection ? <SelectSeparator /> : null}
<SelectItem
value={ADD_CUSTOM_VALUE}
className="text-muted-foreground focus:text-foreground"
<Popover open={pickerOpen} onOpenChange={handlePickerOpenChange}>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
size="sm"
role="combobox"
aria-controls={pickerOpen ? listId : undefined}
aria-expanded={pickerOpen}
aria-label={triggerAriaLabel}
disabled={disabled}
className={cn('w-fit min-w-0 justify-between px-3 font-normal', className)}
>
<Plus className="size-3.5" />
{addCustomLabel}
</SelectItem>
</SelectContent>
</Select>
<span className="min-w-0 flex-1 truncate text-left">
{selectedOption?.label ?? placeholder}
</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={4}
className="w-[var(--radix-popover-trigger-width)] min-w-[14rem] p-0"
onOpenAutoFocus={(event) => {
event.preventDefault()
listRef.current?.focus()
}}
>
<Command
shouldFilter={false}
loop
value={commandValue}
onValueChange={setCommandValue}
onKeyDown={handleCommandKeyDown}
>
<CommandList ref={handleListRef} label={triggerAriaLabel} className="max-h-72 py-1">
{options.length > 0 ? (
<CommandGroup>
{options.map((option) => (
<AddressPickerItem
key={option.value}
option={option}
selected={!isCustomSelection && option.value === value}
commandValue={`detected:${option.value}`}
onSelect={() => selectValue(option.value, false)}
/>
))}
</CommandGroup>
) : null}
{displayedCustomOptions.length > 0 ? (
<CommandGroup
heading={customSectionLabel}
className={cn(options.length > 0 && 'border-t border-border pt-1')}
>
{displayedCustomOptions.map((option) => (
<AddressPickerItem
key={option.value}
option={option}
selected={isCustomSelection && option.value === value}
commandValue={`custom:${option.value}`}
onSelect={() => selectValue(option.value, true)}
onRemove={
onCustomRemove
? () => {
restoreFocusAfterRemovalRef.current = true
onCustomRemove(option.value)
}
: undefined
}
removeLabel={removeCustomLabel?.(option.value)}
/>
))}
</CommandGroup>
) : null}
<CommandGroup
className={cn(
(options.length > 0 || displayedCustomOptions.length > 0) &&
'border-t border-border pt-1'
)}
>
<CommandItem
value="add-custom-address"
onSelect={() => {
setPickerOpen(false)
setDialogOpen(true)
}}
className="text-muted-foreground data-[selected=true]:text-foreground"
>
<Plus className="size-3.5" aria-hidden />
{addCustomLabel}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<CustomAddressDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
@@ -19,14 +19,20 @@ function renderSection(
overrides: Partial<React.ComponentProps<typeof MobilePairingSetupSection>> = {}
) {
const onSelectedAddressChange = vi.fn()
const onCustomAddressSelect = vi.fn()
const onCustomAddressRemove = vi.fn()
const onRefreshNetworkInterfaces = vi.fn()
const onGenerateQr = vi.fn()
const props: React.ComponentProps<typeof MobilePairingSetupSection> = {
connectionMode: 'local-only',
connectionPathControl: <div data-testid="path-control">path</div>,
networkInterfaces: [LAN, TAILNET],
customAddresses: [],
selectedAddress: TAILNET.address,
selectedAddressIsCustom: false,
onSelectedAddressChange,
onCustomAddressSelect,
onCustomAddressRemove,
refreshingNetworkInterfaces: false,
onRefreshNetworkInterfaces,
loading: false,
@@ -40,7 +46,15 @@ function renderSection(
<MobilePairingSetupSection {...props} />
</TooltipProvider>
)
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(
<TooltipProvider>
<MobilePairingSetupSection {...props} customAddresses={[]} />
</TooltipProvider>
)
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' }))
@@ -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({
<div className="flex flex-wrap items-center gap-2">
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
customAddresses={customAddresses}
selectedAddress={selectedAddress}
selectedAddressIsCustom={selectedAddressIsCustom}
onSelectedAddressChange={onSelectedAddressChange}
onCustomAddressSelect={onCustomAddressSelect}
onCustomAddressRemove={onCustomAddressRemove}
className="min-w-[220px] justify-between font-normal"
/>
<Tooltip>
@@ -26,6 +26,8 @@ type StoreState = {
settings: {
mobileAutoRestoreFitMs: number | null
mobilePairingConnectionMode?: MobilePairingConnectionMode
mobilePairingCustomAddress?: string | null
mobilePairingCustomAddresses?: string[]
}
updateSettings: (patch: Record<string, unknown>) => Promise<void>
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
}) => (
<div>
<span data-testid="mode">{props.connectionMode}</span>
<span data-testid="can-generate">{String(props.canGenerate)}</span>
<span data-testid="loading">{String(props.loading)}</span>
<span data-testid="selected-address">{props.selectedAddress ?? 'none'}</span>
<span data-testid="selected-address-is-custom">{String(props.selectedAddressIsCustom)}</span>
<span data-testid="custom-addresses">{props.customAddresses.join(',')}</span>
<span data-testid="refreshing-addresses">{String(props.refreshingNetworkInterfaces)}</span>
{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
</button>
<button type="button" onClick={() => props.onCustomAddressSelect('100.126.117.25:6768')}>
choose-custom-address
</button>
<button type="button" onClick={() => props.onCustomAddressRemove('100.126.117.25:6768')}>
remove-custom-address
</button>
<button
type="button"
disabled={props.networkInterfaces.length === 0}
onClick={() => props.onSelectedAddressChange(props.networkInterfaces[0]!.address)}
>
choose-discovered-address
</button>
<button type="button" onClick={props.onRefreshNetworkInterfaces}>
refresh-addresses
</button>
</div>
)
}))
@@ -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(<MobilePane />)
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(<MobilePane />)
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(<MobilePane />)
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(<MobilePane />)
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(<MobilePane />)
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(<MobilePane />)
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(<MobilePane />)
await waitFor(() =>
expect(screen.getByTestId('selected-address')).toHaveTextContent(customAddress)
)
let resolveRefresh: ((value: Record<string, unknown>) => 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<string, unknown>) => void) | undefined
@@ -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<MobileNetworkInterface[]>([])
const [selectedAddress, setSelectedAddress] = useState<string | undefined>(undefined)
const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
const [codeCopied, setCodeCopied] = useState(false)
const [deviceCountAtQr, setDeviceCountAtQr] = useState<number | null>(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<string | undefined>(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}
+3 -1
View File
@@ -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",
@@ -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', () => {
+14
View File
@@ -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
}
+5
View File
@@ -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)
+2
View File
@@ -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,
@@ -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'])
})
})
@@ -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
)
}
+4
View File
@@ -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