Improve mobile network interface refresh (#2088)
This commit is contained in:
parent
2fc3e10629
commit
75390e5fa9
|
|
@ -1,7 +1,8 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handleMock } = vi.hoisted(() => ({
|
||||
handleMock: vi.fn()
|
||||
const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
networkInterfacesMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
|
|
@ -14,6 +15,10 @@ vi.mock('qrcode', () => ({
|
|||
}
|
||||
}))
|
||||
|
||||
vi.mock('os', () => ({
|
||||
networkInterfaces: networkInterfacesMock
|
||||
}))
|
||||
|
||||
import { registerMobileHandlers } from './mobile'
|
||||
|
||||
describe('registerMobileHandlers', () => {
|
||||
|
|
@ -22,11 +27,36 @@ describe('registerMobileHandlers', () => {
|
|||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
networkInterfacesMock.mockReset()
|
||||
networkInterfacesMock.mockReturnValue({})
|
||||
handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
})
|
||||
|
||||
it('re-reads system network interfaces on each request', () => {
|
||||
networkInterfacesMock
|
||||
.mockReturnValueOnce({
|
||||
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }]
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }],
|
||||
tailscale0: [{ family: 'IPv4', internal: false, address: '100.64.1.20' }]
|
||||
})
|
||||
|
||||
registerMobileHandlers({} as never)
|
||||
|
||||
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
|
||||
interfaces: [{ name: 'en0', address: '192.168.1.24' }]
|
||||
})
|
||||
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
|
||||
interfaces: [
|
||||
{ name: 'en0', address: '192.168.1.24' },
|
||||
{ name: 'tailscale0', address: '100.64.1.20' }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('lists only paired mobile-scoped devices', () => {
|
||||
const rpcServer = {
|
||||
getDeviceRegistry: () => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection'
|
||||
|
||||
type ReactElementLike = {
|
||||
type: unknown
|
||||
props: Record<string, unknown>
|
||||
}
|
||||
|
||||
function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
|
||||
if (node == null || typeof node === 'string' || typeof node === 'number') {
|
||||
return
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((entry) => visit(entry, cb))
|
||||
return
|
||||
}
|
||||
const element = node as ReactElementLike
|
||||
cb(element)
|
||||
if (element.props?.children) {
|
||||
visit(element.props.children, cb)
|
||||
}
|
||||
}
|
||||
|
||||
function collectText(node: unknown): string {
|
||||
if (node == null || typeof node === 'boolean') {
|
||||
return ''
|
||||
}
|
||||
if (typeof node === 'string' || typeof node === 'number') {
|
||||
return String(node)
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(collectText).join('')
|
||||
}
|
||||
const element = node as ReactElementLike
|
||||
return collectText(element.props?.children)
|
||||
}
|
||||
|
||||
function findByAriaLabel(node: unknown, ariaLabel: string): ReactElementLike {
|
||||
let found: ReactElementLike | null = null
|
||||
visit(node, (entry) => {
|
||||
if (entry.props['aria-label'] === ariaLabel) {
|
||||
found = entry
|
||||
}
|
||||
})
|
||||
if (!found) {
|
||||
throw new Error(`element not found: ${ariaLabel}`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
describe('MobileNetworkInterfaceSection', () => {
|
||||
it('shows refreshed tailnet interfaces and wires the refresh action', () => {
|
||||
const onRefreshNetworkInterfaces = vi.fn()
|
||||
const tree = MobileNetworkInterfaceSection({
|
||||
networkInterfaces: [
|
||||
{ name: 'en0', address: '192.168.1.24' },
|
||||
{ name: 'tailscale0', address: '100.64.1.20' }
|
||||
],
|
||||
selectedAddress: '192.168.1.24',
|
||||
onSelectedAddressChange: vi.fn(),
|
||||
refreshingNetworkInterfaces: false,
|
||||
onRefreshNetworkInterfaces,
|
||||
loading: false,
|
||||
hasQrCode: false,
|
||||
onGenerateQr: vi.fn()
|
||||
})
|
||||
|
||||
expect(collectText(tree)).toContain('100.64.1.20 (tailscale0)')
|
||||
|
||||
const refreshButton = findByAriaLabel(tree, 'Refresh network interfaces')
|
||||
const onClick = refreshButton.props.onClick as () => void
|
||||
onClick()
|
||||
|
||||
expect(onRefreshNetworkInterfaces).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import { ExternalLink, Loader2, QrCode, RefreshCw, Wifi } from 'lucide-react'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion'
|
||||
import { Button } from '../ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import type { MobileNetworkInterface } from './mobile-network-interface-selection'
|
||||
|
||||
const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download'
|
||||
|
||||
type MobileNetworkInterfaceSectionProps = {
|
||||
networkInterfaces: MobileNetworkInterface[]
|
||||
selectedAddress: string | undefined
|
||||
onSelectedAddressChange: (address: string) => void
|
||||
refreshingNetworkInterfaces: boolean
|
||||
onRefreshNetworkInterfaces: () => void
|
||||
loading: boolean
|
||||
hasQrCode: boolean
|
||||
onGenerateQr: () => void
|
||||
}
|
||||
|
||||
function formatInterfaceLabel(iface: MobileNetworkInterface): string {
|
||||
return `${iface.address} (${iface.name})`
|
||||
}
|
||||
|
||||
export function MobileNetworkInterfaceSection({
|
||||
networkInterfaces,
|
||||
selectedAddress,
|
||||
onSelectedAddressChange,
|
||||
refreshingNetworkInterfaces,
|
||||
onRefreshNetworkInterfaces,
|
||||
loading,
|
||||
hasQrCode,
|
||||
onGenerateQr
|
||||
}: MobileNetworkInterfaceSectionProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Wifi className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Network Interface</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
Choose which network address to advertise in the QR code. Use your LAN address for
|
||||
same-network pairing, or an overlay network address (Tailscale, ZeroTier) for cross-network
|
||||
access.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select value={selectedAddress} onValueChange={onSelectedAddressChange}>
|
||||
<SelectTrigger size="sm" className="min-w-[220px]">
|
||||
<SelectValue placeholder="No interfaces found" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{networkInterfaces.map((iface) => (
|
||||
<SelectItem key={`${iface.name}-${iface.address}`} value={iface.address}>
|
||||
{formatInterfaceLabel(iface)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Why: VPN/tailnet interfaces can appear after this pane mounts.
|
||||
Re-enumerating OS state here avoids requiring an Orca restart. */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onRefreshNetworkInterfaces}
|
||||
disabled={refreshingNetworkInterfaces}
|
||||
aria-label="Refresh network interfaces"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Refresh network interfaces
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onGenerateQr}
|
||||
disabled={loading || !selectedAddress}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : hasQrCode ? (
|
||||
<RefreshCw className="size-3.5" />
|
||||
) : (
|
||||
<QrCode className="size-3.5" />
|
||||
)}
|
||||
{hasQrCode ? 'Regenerate' : 'Generate QR Code'}
|
||||
</Button>
|
||||
</div>
|
||||
<Accordion type="single" collapsible className="mt-4 border-t border-border/60 pt-2">
|
||||
<AccordionItem value="remote-pairing-guide">
|
||||
<AccordionTrigger className="py-2 text-xs">
|
||||
Connect outside your Wi-Fi with a tailnet
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 text-xs text-muted-foreground">
|
||||
<p>
|
||||
Orca Mobile connects directly to this computer. To use it away from the same local
|
||||
network, put your computer and phone on the same private overlay network, then
|
||||
generate the QR code with that network address selected.
|
||||
</p>
|
||||
<ol className="list-decimal space-y-1 pl-4">
|
||||
<li>
|
||||
Install{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void window.api.shell.openUrl(TAILSCALE_DOWNLOAD_URL)}
|
||||
className="inline-flex items-center gap-1 font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
Tailscale
|
||||
<ExternalLink className="size-3" />
|
||||
</button>{' '}
|
||||
on your computer and phone.
|
||||
</li>
|
||||
<li>Sign in to the same tailnet on both devices.</li>
|
||||
<li>
|
||||
In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z
|
||||
IP.
|
||||
</li>
|
||||
<li>Regenerate the QR code and scan it from the Orca mobile app.</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,22 +1,17 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
RefreshCw,
|
||||
Smartphone,
|
||||
Trash2,
|
||||
Wifi
|
||||
} from 'lucide-react'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion'
|
||||
import { Check, Copy, Maximize2, Smartphone, Trash2 } from 'lucide-react'
|
||||
import { Button } from '../ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useMobilePairingDevicePolling } from './mobile-pairing-device-polling'
|
||||
import {
|
||||
selectRefreshedNetworkAddress,
|
||||
type MobileNetworkInterface
|
||||
} from './mobile-network-interface-selection'
|
||||
import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection'
|
||||
|
||||
// Why: the section heading "When you leave the mobile app" carries the
|
||||
// "what happens" framing so the option labels only need to vary on the
|
||||
|
|
@ -29,8 +24,6 @@ const AUTO_RESTORE_FIT_OPTIONS: { value: string; label: string; ms: number | nul
|
|||
{ value: '30m', label: 'After 30 minutes', ms: 30 * 60_000 }
|
||||
]
|
||||
|
||||
const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download'
|
||||
|
||||
function autoRestoreValueFromMs(ms: number | null | undefined): string {
|
||||
if (ms == null) {
|
||||
return 'indefinite'
|
||||
|
|
@ -94,11 +87,6 @@ type PairedDevice = {
|
|||
lastSeenAt: number
|
||||
}
|
||||
|
||||
type NetworkInterface = {
|
||||
name: string
|
||||
address: string
|
||||
}
|
||||
|
||||
export function MobilePane(): React.JSX.Element {
|
||||
const autoRestoreFitMs = useAppStore((s) => s.settings?.mobileAutoRestoreFitMs ?? null)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
|
|
@ -108,8 +96,9 @@ export function MobilePane(): React.JSX.Element {
|
|||
const [loading, setLoading] = useState(false)
|
||||
const [devices, setDevices] = useState<PairedDevice[]>([])
|
||||
const [qrEnlarged, setQrEnlarged] = useState(false)
|
||||
const [networkInterfaces, setNetworkInterfaces] = useState<NetworkInterface[]>([])
|
||||
const [networkInterfaces, setNetworkInterfaces] = useState<MobileNetworkInterface[]>([])
|
||||
const [selectedAddress, setSelectedAddress] = useState<string | undefined>(undefined)
|
||||
const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
|
||||
const [codeCopied, setCodeCopied] = useState(false)
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
|
|
@ -121,17 +110,22 @@ export function MobilePane(): React.JSX.Element {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const loadNetworkInterfaces = useCallback(async () => {
|
||||
const loadNetworkInterfaces = useCallback(async (opts: { notifyOnError?: boolean } = {}) => {
|
||||
setRefreshingNetworkInterfaces(true)
|
||||
try {
|
||||
const result = await window.api.mobile.listNetworkInterfaces()
|
||||
setNetworkInterfaces(result.interfaces)
|
||||
if (result.interfaces.length > 0 && !selectedAddress) {
|
||||
setSelectedAddress(result.interfaces[0]!.address)
|
||||
}
|
||||
setSelectedAddress((currentAddress) =>
|
||||
selectRefreshedNetworkAddress(currentAddress, result.interfaces)
|
||||
)
|
||||
} catch {
|
||||
// Silently fail
|
||||
if (opts.notifyOnError) {
|
||||
toast.error('Failed to refresh network interfaces')
|
||||
}
|
||||
} finally {
|
||||
setRefreshingNetworkInterfaces(false)
|
||||
}
|
||||
}, [selectedAddress])
|
||||
}, [])
|
||||
|
||||
const generateQR = useCallback(
|
||||
async (opts: { rotate?: boolean } = {}) => {
|
||||
|
|
@ -210,81 +204,18 @@ export function MobilePane(): React.JSX.Element {
|
|||
}
|
||||
}
|
||||
|
||||
function formatInterfaceLabel(iface: NetworkInterface): string {
|
||||
return `${iface.address} (${iface.name})`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Network interface selector + generate */}
|
||||
<div className="rounded-lg border border-border/60 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Wifi className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Network Interface</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
Choose which network address to advertise in the QR code. Use your LAN address for
|
||||
same-network pairing, or an overlay network address (Tailscale, ZeroTier) for
|
||||
cross-network access.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={selectedAddress} onValueChange={setSelectedAddress}>
|
||||
<SelectTrigger size="sm" className="min-w-[220px]">
|
||||
<SelectValue placeholder="No interfaces found" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{networkInterfaces.map((iface) => (
|
||||
<SelectItem key={`${iface.name}-${iface.address}`} value={iface.address}>
|
||||
{formatInterfaceLabel(iface)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={() => void generateQR({ rotate: qrDataUrl != null })}
|
||||
disabled={loading || !selectedAddress}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
{qrDataUrl ? 'Regenerate' : 'Generate QR Code'}
|
||||
</Button>
|
||||
</div>
|
||||
<Accordion type="single" collapsible className="mt-4 border-t border-border/60 pt-2">
|
||||
<AccordionItem value="remote-pairing-guide">
|
||||
<AccordionTrigger className="py-2 text-xs">
|
||||
Connect outside your Wi-Fi with a tailnet
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 text-xs text-muted-foreground">
|
||||
<p>
|
||||
Orca Mobile connects directly to this computer. To use it away from the same local
|
||||
network, put your computer and phone on the same private overlay network, then
|
||||
generate the QR code with that network address selected.
|
||||
</p>
|
||||
<ol className="list-decimal space-y-1 pl-4">
|
||||
<li>
|
||||
Install{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void window.api.shell.openUrl(TAILSCALE_DOWNLOAD_URL)}
|
||||
className="inline-flex items-center gap-1 font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
Tailscale
|
||||
<ExternalLink className="size-3" />
|
||||
</button>{' '}
|
||||
on your computer and phone.
|
||||
</li>
|
||||
<li>Sign in to the same tailnet on both devices.</li>
|
||||
<li>
|
||||
In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z
|
||||
IP.
|
||||
</li>
|
||||
<li>Regenerate the QR code and scan it from the Orca mobile app.</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
<MobileNetworkInterfaceSection
|
||||
networkInterfaces={networkInterfaces}
|
||||
selectedAddress={selectedAddress}
|
||||
onSelectedAddressChange={setSelectedAddress}
|
||||
refreshingNetworkInterfaces={refreshingNetworkInterfaces}
|
||||
onRefreshNetworkInterfaces={() => void loadNetworkInterfaces({ notifyOnError: true })}
|
||||
loading={loading}
|
||||
hasQrCode={qrDataUrl != null}
|
||||
onGenerateQr={() => void generateQR({ rotate: qrDataUrl != null })}
|
||||
/>
|
||||
|
||||
{/* QR code display */}
|
||||
{qrDataUrl && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { selectRefreshedNetworkAddress } from './mobile-network-interface-selection'
|
||||
|
||||
const LAN = { name: 'en0', address: '192.168.1.24' }
|
||||
const TAILNET = { name: 'tailscale0', address: '100.64.1.20' }
|
||||
|
||||
describe('selectRefreshedNetworkAddress', () => {
|
||||
it('keeps the selected address when refresh discovers a new tailnet interface', () => {
|
||||
expect(selectRefreshedNetworkAddress(LAN.address, [LAN, TAILNET])).toBe(LAN.address)
|
||||
})
|
||||
|
||||
it('selects the first refreshed interface when there is no current address', () => {
|
||||
expect(selectRefreshedNetworkAddress(undefined, [TAILNET, LAN])).toBe(TAILNET.address)
|
||||
})
|
||||
|
||||
it('moves to the first refreshed interface when the current address disappeared', () => {
|
||||
expect(selectRefreshedNetworkAddress('10.0.0.4', [TAILNET, LAN])).toBe(TAILNET.address)
|
||||
})
|
||||
|
||||
it('clears the selection when no interfaces are available', () => {
|
||||
expect(selectRefreshedNetworkAddress(LAN.address, [])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
export type MobileNetworkInterface = {
|
||||
name: string
|
||||
address: string
|
||||
}
|
||||
|
||||
export function selectRefreshedNetworkAddress(
|
||||
currentAddress: string | undefined,
|
||||
interfaces: readonly MobileNetworkInterface[]
|
||||
): string | undefined {
|
||||
if (interfaces.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
if (currentAddress && interfaces.some((iface) => iface.address === currentAddress)) {
|
||||
return currentAddress
|
||||
}
|
||||
return interfaces[0]!.address
|
||||
}
|
||||
Loading…
Reference in New Issue