Improve mobile network interface refresh (#2088)

This commit is contained in:
Neil 2026-05-16 11:41:41 -07:00 committed by GitHub
parent 2fc3e10629
commit 75390e5fa9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 310 additions and 100 deletions

View File

@ -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: () => ({

View File

@ -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)
})
})

View File

@ -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>
)
}

View File

@ -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 && (

View File

@ -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()
})
})

View File

@ -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
}