diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 585b8aaf1..dc78e888f 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -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: () => ({ diff --git a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx new file mode 100644 index 000000000..0c68f7651 --- /dev/null +++ b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection' + +type ReactElementLike = { + type: unknown + props: Record +} + +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) + }) +}) diff --git a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx new file mode 100644 index 000000000..b460f58f6 --- /dev/null +++ b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx @@ -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 ( +
+
+ + Network Interface +
+

+ 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. +

+
+
+ + {/* Why: VPN/tailnet interfaces can appear after this pane mounts. + Re-enumerating OS state here avoids requiring an Orca restart. */} + + + + + + Refresh network interfaces + + +
+ +
+ + + + Connect outside your Wi-Fi with a tailnet + + +

+ 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. +

+
    +
  1. + Install{' '} + {' '} + on your computer and phone. +
  2. +
  3. Sign in to the same tailnet on both devices.
  4. +
  5. + In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z + IP. +
  6. +
  7. Regenerate the QR code and scan it from the Orca mobile app.
  8. +
+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 6dd0ae7e0..97a438602 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -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([]) const [qrEnlarged, setQrEnlarged] = useState(false) - const [networkInterfaces, setNetworkInterfaces] = useState([]) + const [networkInterfaces, setNetworkInterfaces] = useState([]) const [selectedAddress, setSelectedAddress] = useState(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 (
- {/* Network interface selector + generate */} -
-
- - Network Interface -
-

- 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. -

-
- - -
- - - - Connect outside your Wi-Fi with a tailnet - - -

- 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. -

-
    -
  1. - Install{' '} - {' '} - on your computer and phone. -
  2. -
  3. Sign in to the same tailnet on both devices.
  4. -
  5. - In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z - IP. -
  6. -
  7. Regenerate the QR code and scan it from the Orca mobile app.
  8. -
-
-
-
-
+ void loadNetworkInterfaces({ notifyOnError: true })} + loading={loading} + hasQrCode={qrDataUrl != null} + onGenerateQr={() => void generateQR({ rotate: qrDataUrl != null })} + /> {/* QR code display */} {qrDataUrl && ( diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts new file mode 100644 index 000000000..68db67d11 --- /dev/null +++ b/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.ts new file mode 100644 index 000000000..fd38a4baf --- /dev/null +++ b/src/renderer/src/components/settings/mobile-network-interface-selection.ts @@ -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 +}