mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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: [] })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user