Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@ function renderForm(
address: string
runtimePairingUrl: string
webClientUrl: string
}
},
networkInterfaces: { name: string; address: string }[] = [
{ name: 'tailscale0', address: '100.76.32.125' }
],
retainedInterfaceName: string | null = null
): string {
return renderToStaticMarkup(
<TooltipProvider>
<RuntimePairingGeneratorForm
intent={intent}
loopbackAddress="127.0.0.1"
networkInterfaces={[{ name: 'tailscale0', address: '100.76.32.125' }]}
networkInterfaces={networkInterfaces}
retainedInterfaceName={retainedInterfaceName}
selectedAddress={selectedAddress}
refreshingNetworkInterfaces={false}
isGeneratingPairing={false}
Expand Down Expand Up @@ -65,4 +70,24 @@ describe('RuntimePairingGeneratorForm', () => {
expect(markup).toContain('The connection address changed.')
expect(markup).not.toContain('stale-secret')
})

it('blocks a new link while the remembered interface is missing', () => {
const markup = renderForm(
'another',
'10.99.88.1',
undefined,
[{ name: 'en0', address: '192.168.4.191' }],
'bridge0'
)

expect(markup).toContain('The selected network interface is unavailable.')
expect(markup).toMatch(/<button[^>]*disabled=""[^>]*>.*Generate Access Link/s)
})

it('still allows a custom endpoint while no matching interface is listed', () => {
const markup = renderForm('custom', '10.99.88.1', undefined, [])

expect(markup).not.toContain('The selected network interface is unavailable.')
expect(markup).not.toContain('disabled=""')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type RuntimePairingGeneratorFormProps = {
intent: RuntimePairingIntent
loopbackAddress: string
networkInterfaces: { name: string; address: string }[]
retainedInterfaceName?: string | null
selectedAddress: string
refreshingNetworkInterfaces: boolean
isGeneratingPairing: boolean
Expand All @@ -33,6 +34,7 @@ export function RuntimePairingGeneratorForm({
intent,
loopbackAddress,
networkInterfaces,
retainedInterfaceName = null,
selectedAddress,
refreshingNetworkInterfaces,
isGeneratingPairing,
Expand All @@ -50,12 +52,31 @@ export function RuntimePairingGeneratorForm({
value: networkInterface.address,
label: `${networkInterface.name} (${networkInterface.address})`
}))
if (
intent === 'another' &&
retainedInterfaceName &&
selectedAddress &&
!networkInterfaces.some((networkInterface) => networkInterface.address === selectedAddress)
) {
options.push({
value: selectedAddress,
label: `${retainedInterfaceName} (${selectedAddress})`
})
}
const generatedIsCurrent = generatedAddress === selectedAddress
const staleGeneratedLink = generatedAddress !== null && !generatedIsCurrent
const customAddressResult =
intent === 'custom' ? parseServerShareAddress(selectedAddress) : { ok: true as const }
const customAddressInvalid = selectedAddress !== '' && !customAddressResult.ok
const canGenerate = selectedAddress !== '' && (intent !== 'custom' || customAddressResult.ok)
const selectedInterfaceAvailable = networkInterfaces.some(
(networkInterface) =>
networkInterface.address === selectedAddress &&
(!retainedInterfaceName || networkInterface.name === retainedInterfaceName)
)
const canGenerate =
selectedAddress !== '' &&
(intent !== 'another' || selectedInterfaceAvailable) &&
(intent !== 'custom' || customAddressResult.ok)

return (
<>
Expand Down Expand Up @@ -296,6 +317,14 @@ export function RuntimePairingGeneratorForm({
) : null}
</div>
)}
{intent === 'another' && selectedAddress !== '' && !selectedInterfaceAvailable ? (
<p role="alert" className="text-xs text-destructive">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.selectedInterfaceUnavailable',
Comment thread
pullfrog[bot] marked this conversation as resolved.
'The selected network interface is unavailable. Reconnect it before generating a link.'
)}
</p>
) : null}
{staleGeneratedLink && selectedAddress !== '' ? (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
{translate(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,43 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
listNetworkInterfaces: vi.fn(),
listRuntimeAccessGrants: vi.fn(),
getRuntimePairingUrl: vi.fn()
getRuntimePairingUrl: vi.fn(),
updateSettings: vi.fn(),
settings: null as null | {
runtimePairingAdvertisedInterfaceName?: string | null
runtimePairingAdvertisedAddress?: string | null
}
}))

vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
vi.mock('@/store', () => ({
useAppStore: (
selector: (state: {
settings: typeof mocks.settings
updateSettings: typeof mocks.updateSettings
}) => unknown
) => selector({ settings: mocks.settings, updateSettings: mocks.updateSettings })
}))
vi.mock('./RuntimeAccessGrantList', () => ({ RuntimeAccessGrantList: () => null }))
vi.mock('./RuntimePairingGeneratorForm', () => ({
RuntimePairingGeneratorForm: (props: { selectedAddress: string; onGenerate: () => void }) => (
RuntimePairingGeneratorForm: (props: {
selectedAddress: string
onGenerate: () => void
onSelectedAddressChange: (address: string) => void
onRefreshNetworkInterfaces: () => void
}) => (
<div>
<div data-testid="selected-address">{props.selectedAddress}</div>
<button type="button" onClick={props.onGenerate}>
Generate
</button>
<button type="button" onClick={() => props.onSelectedAddressChange('10.99.88.1')}>
Pick bridge
</button>
<button type="button" onClick={props.onRefreshNetworkInterfaces}>
Refresh
</button>
</div>
)
}))
Expand All @@ -33,6 +57,10 @@ describe('RuntimePairingUrlGenerator', () => {
runtimePairingLinkCache.selectedAddress = '100.76.32.125'
runtimePairingLinkCache.customAddress = ''
runtimePairingLinkCache.intent = 'another'
runtimePairingLinkCache.advertisedInterfaceName = null
runtimePairingLinkCache.advertisedAddress = ''
mocks.settings = null
mocks.updateSettings.mockReset()
runtimePairingLinkCache.generatedAddress = null
runtimePairingLinkCache.runtimePairingUrl = null
runtimePairingLinkCache.webClientUrl = null
Expand All @@ -49,6 +77,7 @@ describe('RuntimePairingUrlGenerator', () => {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
platform: { get: () => ({ platform: 'darwin' as const }) },
mobile: {
listNetworkInterfaces: mocks.listNetworkInterfaces,
listRuntimeAccessGrants: mocks.listRuntimeAccessGrants,
Expand Down Expand Up @@ -105,4 +134,144 @@ describe('RuntimePairingUrlGenerator', () => {
expect(mocks.getRuntimePairingUrl).toHaveBeenCalledWith({ address, rotate: true, reach })
)
})

it('restores a saved Thunderbolt interface instead of Ethernet after restart', async () => {
runtimePairingLinkCache.selectedAddress = ''
mocks.settings = {
runtimePairingAdvertisedInterfaceName: 'bridge0',
runtimePairingAdvertisedAddress: '10.99.88.1'
}
mocks.listNetworkInterfaces.mockResolvedValue({
interfaces: [
{ name: 'en0', address: '192.168.4.191' },
{ name: 'bridge0', address: '10.99.88.1' }
]
})

render(<RuntimePairingUrlGenerator />)

await waitFor(() =>
expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.1')
)
})

it('restores the saved address when discovery returns no interfaces', async () => {
runtimePairingLinkCache.selectedAddress = ''
mocks.settings = {
runtimePairingAdvertisedInterfaceName: 'bridge0',
runtimePairingAdvertisedAddress: '10.99.88.1'
}
mocks.listNetworkInterfaces.mockResolvedValue({ interfaces: [] })

render(<RuntimePairingUrlGenerator />)

await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce())
expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.1')
})

it('remembers a new address on the saved interface', async () => {
runtimePairingLinkCache.selectedAddress = ''
mocks.settings = {
runtimePairingAdvertisedInterfaceName: 'bridge0',
runtimePairingAdvertisedAddress: '10.99.88.1'
}
mocks.listNetworkInterfaces.mockResolvedValue({
interfaces: [
{ name: 'en0', address: '192.168.4.191' },
{ name: 'bridge0', address: '10.99.88.2' }
]
})

render(<RuntimePairingUrlGenerator />)

await waitFor(() =>
expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.2')
)
await waitFor(() =>
expect(mocks.updateSettings).toHaveBeenCalledWith({
runtimePairingAdvertisedInterfaceName: 'bridge0',
runtimePairingAdvertisedAddress: '10.99.88.2'
})
)
})

it('keeps the returned Bridge address after it changes and drops again', async () => {
runtimePairingLinkCache.selectedAddress = ''
mocks.settings = {
runtimePairingAdvertisedInterfaceName: 'bridge0',
runtimePairingAdvertisedAddress: '10.99.88.1'
}
mocks.listNetworkInterfaces
.mockResolvedValueOnce({
interfaces: [
{ name: 'en0', address: '192.168.4.191' },
{ name: 'bridge0', address: '10.99.88.1' }
]
})
.mockResolvedValueOnce({ interfaces: [{ name: 'en0', address: '192.168.4.191' }] })
.mockResolvedValueOnce({
interfaces: [
{ name: 'en0', address: '192.168.4.191' },
{ name: 'bridge0', address: '10.99.88.2' }
]
})
.mockResolvedValueOnce({ interfaces: [{ name: 'en0', address: '192.168.4.191' }] })

render(<RuntimePairingUrlGenerator />)
await waitFor(() =>
expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.1')
)

screen.getByRole('button', { name: 'Refresh' }).click()
await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledTimes(2))
expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.1')

screen.getByRole('button', { name: 'Refresh' }).click()
await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledTimes(3))
await waitFor(() =>
expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.2')
)
await waitFor(() =>
expect(mocks.updateSettings).toHaveBeenCalledWith({
runtimePairingAdvertisedInterfaceName: 'bridge0',
runtimePairingAdvertisedAddress: '10.99.88.2'
})
)

screen.getByRole('button', { name: 'Refresh' }).click()
await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledTimes(4))
expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.2')
expect(runtimePairingLinkCache.advertisedAddress).toBe('10.99.88.2')
})

it('keeps an explicit Thunderbolt pick when refresh reports only Ethernet', async () => {
runtimePairingLinkCache.selectedAddress = ''
mocks.listNetworkInterfaces
.mockResolvedValueOnce({
interfaces: [
{ name: 'en0', address: '192.168.4.191' },
{ name: 'bridge0', address: '10.99.88.1' }
]
})
.mockResolvedValueOnce({
interfaces: [{ name: 'en0', address: '192.168.4.191' }]
})

render(<RuntimePairingUrlGenerator />)
await waitFor(() =>
expect(screen.getByTestId('selected-address')).toHaveTextContent('192.168.4.191')
)

screen.getByRole('button', { name: 'Pick bridge' }).click()
await waitFor(() =>
expect(mocks.updateSettings).toHaveBeenCalledWith({
runtimePairingAdvertisedInterfaceName: 'bridge0',
runtimePairingAdvertisedAddress: '10.99.88.1'
})
)

screen.getByRole('button', { name: 'Refresh' }).click()
await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledTimes(2))
expect(screen.getByTestId('selected-address')).toHaveTextContent('10.99.88.1')
})
})
Loading