From 1a183d31d2b495800c2f70d0b28cb2847a6f14ff Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 29 Apr 2026 17:44:38 -0400 Subject: [PATCH] feat: network interface selector for QR pairing and UI polish - Add network interface selector so users can choose LAN or overlay network (Tailscale, ZeroTier) address for QR code pairing - Fall back to OS-assigned port when preferred port is in use - Filter paired devices list to only show actually connected devices - Poll device list after QR generation until phone connects - Mark device lastSeenAt on first WebSocket connection - Make CustomKeyModal toggle and button monochrome (remove blue accent) - Make clear filters text and group options monochrome Co-authored-by: Orca --- mobile/app/h/[hostId]/index.tsx | 6 +- mobile/src/components/CustomKeyModal.tsx | 7 +- src/main/ipc/mobile.ts | 57 ++-- src/main/runtime/rpc/ws-transport.test.ts | 24 ++ src/main/runtime/rpc/ws-transport.ts | 56 +++- src/main/runtime/runtime-rpc.ts | 7 + src/preload/api-types.ts | 7 +- src/preload/index.ts | 10 +- .../src/components/settings/MobilePane.tsx | 262 ++++++++++-------- 9 files changed, 280 insertions(+), 156 deletions(-) diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index b0363afed77..752da1874af 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -85,8 +85,8 @@ const SORT_OPTIONS: PickerOption[] = [ const GROUP_OPTIONS: PickerOption[] = [ { value: 'none', label: 'No Grouping' }, - { value: 'repo', label: 'By Repository' }, - { value: 'prStatus', label: 'By PR Status', subtitle: 'Linked PR vs none' } + { value: 'repo', label: 'Repository' }, + { value: 'prStatus', label: 'PR Status' } ] function getWorktreeStatus(w: Worktree): 'working' | 'active' | 'permission' | 'done' | 'inactive' { @@ -1213,7 +1213,7 @@ const styles = StyleSheet.create({ }, clearFiltersText: { fontSize: 13, - color: colors.accentBlue + color: colors.textSecondary }, filterSectionLabel: { fontSize: 11, diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index 70a57899985..ba264548dde 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -193,7 +193,8 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { 0 ? ifaces[0]!.address : null } // Why: the mobile IPC handlers provide the renderer with QR code pairing data, @@ -27,18 +39,25 @@ function getLanAddress(): string | null { // OrcaRuntimeRpcServer because it owns the device registry and TLS state. export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { - ipcMain.handle('mobile:getPairingQR', async () => { + ipcMain.handle('mobile:listNetworkInterfaces', (): { interfaces: NetworkInterface[] } => ({ + interfaces: getNetworkInterfaces() + })) + + ipcMain.handle('mobile:getPairingQR', async (_event, args?: { address?: string }) => { const rawEndpoint = rpcServer.getWebSocketEndpoint() const registry = rpcServer.getDeviceRegistry() if (!rawEndpoint || !registry) { return { available: false as const } } - const lanIp = getLanAddress() - if (!lanIp) { + // Why: allow the caller to specify which network interface address to + // embed in the QR code. This supports overlay networks (Tailscale, + // ZeroTier) where the default LAN IP isn't reachable from the phone. + const ip = args?.address ?? getLanAddress() + if (!ip) { return { available: false as const } } - const endpoint = rawEndpoint.replace('0.0.0.0', lanIp) + const endpoint = rawEndpoint.replace('0.0.0.0', ip) const device = registry.addDevice(`Mobile ${new Date().toLocaleDateString()}`) @@ -73,13 +92,19 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { if (!registry) { return { devices: [] } } + // Why: devices with lastSeenAt === 0 were created during QR generation + // but never actually scanned/connected. Showing them as "paired" is + // misleading, so we filter them out. return { - devices: registry.listDevices().map((d) => ({ - deviceId: d.deviceId, - name: d.name, - pairedAt: d.pairedAt, - lastSeenAt: d.lastSeenAt - })) + devices: registry + .listDevices() + .filter((d) => d.lastSeenAt > 0) + .map((d) => ({ + deviceId: d.deviceId, + name: d.name, + pairedAt: d.pairedAt, + lastSeenAt: d.lastSeenAt + })) } }) diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 341d23beef8..ea3acd486fb 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -229,4 +229,28 @@ describe('WebSocketTransport', () => { const { transport } = await createTransport() await transport.stop() }) + + it('falls back to OS-assigned port when preferred port is in use', async () => { + const { transport: first, port } = await createTransport() + await first.start() + + // Why: second transport requests the same port, which is now occupied. + // It should silently fall back to an OS-assigned port instead of throwing. + const tls = makeTls() + const second = new WebSocketTransport({ + host: '127.0.0.1', + port, + tlsCert: tls.cert, + tlsKey: tls.key + }) + transports.push(second) + + await second.start() + + expect(second.resolvedPort).not.toBe(port) + expect(second.resolvedPort).toBeGreaterThan(0) + + const ws = await connectWs(second.resolvedPort) + ws.close() + }) }) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index cd125c4209b..218bdfe2851 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -70,13 +70,43 @@ export class WebSocketTransport implements RpcTransport { return } - // Why: React Native's built-in WebSocket cannot disable TLS verification - // for self-signed certificates. Until we implement certificate pinning on - // the mobile side, use plain ws:// which still has per-device token auth. - const httpServer = - this.tlsCert && this.tlsKey - ? createHttpsServer({ cert: this.tlsCert, key: this.tlsKey }) - : createHttpServer() + // Why: when the preferred port is occupied (e.g. another Orca instance is + // already running), fall back to an OS-assigned port so mobile pairing + // still works. The QR code reads resolvedPort after start, so it will + // advertise the correct port regardless. + let port = this.port + try { + await this.tryListen(port) + } catch (error: unknown) { + if (isEAddressInUse(error) && port !== 0) { + console.warn(`[ws-transport] Port ${port} is in use, falling back to OS-assigned port`) + port = 0 + await this.tryListen(port) + } else { + throw error + } + } + } + + private createHttpServer(): HttpServer | HttpsServer { + return this.tlsCert && this.tlsKey + ? createHttpsServer({ cert: this.tlsCert, key: this.tlsKey }) + : createHttpServer() + } + + // Why: the WebSocketServer is attached only after listen succeeds. If we + // attached it before, the WSS would re-emit the EADDRINUSE error from the + // httpServer as an uncatchable exception, preventing the fallback from working. + private async tryListen(port: number): Promise { + const httpServer = this.createHttpServer() + + await new Promise((resolve, reject) => { + httpServer.once('error', reject) + httpServer.listen(port, this.host, () => { + httpServer.off('error', reject) + resolve() + }) + }) const wss = new WebSocketServer({ server: httpServer, @@ -91,14 +121,6 @@ export class WebSocketTransport implements RpcTransport { this.handleConnection(ws) }) - await new Promise((resolve, reject) => { - httpServer.once('error', reject) - httpServer.listen(this.port, this.host, () => { - httpServer.off('error', reject) - resolve() - }) - }) - this.httpServer = httpServer this.wss = wss } @@ -165,3 +187,7 @@ export class WebSocketTransport implements RpcTransport { }) } } + +function isEAddressInUse(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'EADDRINUSE' +} diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index a20673d1e4b..c592e654019 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -142,6 +142,13 @@ export class OrcaRuntimeRpcServer { onReady: (ch) => { if (ch.deviceToken) { wsTransport.setClientId(ws, ch.deviceToken) + // Why: mark the device as actually connected so it appears + // in the "Paired Devices" list. Devices that were only + // generated as QR codes but never scanned stay hidden. + const device = this.deviceRegistry?.validateToken(ch.deviceToken) + if (device) { + this.deviceRegistry?.updateLastSeen(device.deviceId) + } } }, onError: (code, reason) => { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 03e49df8c35..51f9c375c1c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -952,7 +952,12 @@ export type PreloadApi = { ) => () => void } mobile: { - getPairingQR: () => Promise< + listNetworkInterfaces: () => Promise<{ + interfaces: { name: string; address: string }[] + }> + getPairingQR: (args?: { + address?: string + }) => Promise< | { available: false } | { available: true; qrDataUrl: string; endpoint: string; deviceId: string } > diff --git a/src/preload/index.ts b/src/preload/index.ts index 4ea0e3f080b..474982246b7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1792,10 +1792,16 @@ const api = { }, mobile: { - getPairingQR: (): Promise< + listNetworkInterfaces: (): Promise<{ + interfaces: { name: string; address: string }[] + }> => ipcRenderer.invoke('mobile:listNetworkInterfaces'), + + getPairingQR: (args?: { + address?: string + }): Promise< | { available: false } | { available: true; qrDataUrl: string; endpoint: string; deviceId: string } - > => ipcRenderer.invoke('mobile:getPairingQR'), + > => ipcRenderer.invoke('mobile:getPairingQR', args), listDevices: (): Promise<{ devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 17b9988cc08..2dcc31ee03e 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' -import { Maximize2, RefreshCw, Trash2 } from 'lucide-react' +import { Maximize2, RefreshCw, Trash2, Wifi } 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' export const MOBILE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ @@ -15,6 +16,11 @@ export const MOBILE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ title: 'Connected Devices', description: 'Manage paired mobile devices.', keywords: ['mobile', 'devices', 'revoke', 'paired', 'connected'] + }, + { + title: 'Network Interface', + description: 'Choose which network address to use for mobile pairing.', + keywords: ['network', 'interface', 'tailscale', 'vpn', 'overlay', 'ip', 'address', 'wifi'] } ] @@ -25,12 +31,19 @@ type PairedDevice = { lastSeenAt: number } +type NetworkInterface = { + name: string + address: string +} + export function MobilePane(): React.JSX.Element { const [qrDataUrl, setQrDataUrl] = useState(null) const [endpoint, setEndpoint] = useState(null) const [loading, setLoading] = useState(false) const [devices, setDevices] = useState([]) const [qrEnlarged, setQrEnlarged] = useState(false) + const [networkInterfaces, setNetworkInterfaces] = useState([]) + const [selectedAddress, setSelectedAddress] = useState(undefined) const loadDevices = useCallback(async () => { try { @@ -41,10 +54,24 @@ export function MobilePane(): React.JSX.Element { } }, []) + const loadNetworkInterfaces = useCallback(async () => { + try { + const result = await window.api.mobile.listNetworkInterfaces() + setNetworkInterfaces(result.interfaces) + if (result.interfaces.length > 0 && !selectedAddress) { + setSelectedAddress(result.interfaces[0]!.address) + } + } catch { + // Silently fail + } + }, [selectedAddress]) + const generateQR = useCallback(async () => { setLoading(true) try { - const result = await window.api.mobile.getPairingQR() + const result = await window.api.mobile.getPairingQR( + selectedAddress ? { address: selectedAddress } : undefined + ) if (result.available) { setQrDataUrl(result.qrDataUrl) setEndpoint(result.endpoint) @@ -57,11 +84,31 @@ export function MobilePane(): React.JSX.Element { } finally { setLoading(false) } - }, [loadDevices]) + }, [loadDevices, selectedAddress]) useEffect(() => { void loadDevices() - }, [loadDevices]) + void loadNetworkInterfaces() + }, [loadDevices, loadNetworkInterfaces]) + + // Why: after generating a QR code the device only appears once the phone + // actually connects (lastSeenAt > 0). Poll until a new device shows up. + const [deviceCountAtQr, setDeviceCountAtQr] = useState(null) + useEffect(() => { + if (!qrDataUrl) { + setDeviceCountAtQr(null) + return + } + setDeviceCountAtQr(devices.length) + }, [qrDataUrl]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (deviceCountAtQr === null || devices.length > deviceCountAtQr) { + return + } + const interval = setInterval(() => void loadDevices(), 3000) + return () => clearInterval(interval) + }, [deviceCountAtQr, devices.length, loadDevices]) async function revokeDevice(deviceId: string) { try { @@ -73,125 +120,108 @@ export function MobilePane(): React.JSX.Element { } } + function formatInterfaceLabel(iface: NetworkInterface): string { + return `${iface.address} (${iface.name})` + } + return (
- {qrDataUrl ? ( -
-
- -
- -
- {endpoint && ( - {endpoint} - )} -
- -
-

Paired Devices

- {devices.length === 0 ? ( -

- No devices paired yet. Scan the QR code with the Orca mobile app. -

- ) : ( -
- {devices.map((device) => ( -
-
-
{device.name}
-
- Paired {new Date(device.pairedAt).toLocaleDateString()} -
-
- -
- ))} -
- )} -

- Each QR code creates a unique device token. Revoking a device disconnects it - immediately. -

-
+ {/* Network interface selector + generate */} +
+
+ + Network Interface
- ) : ( -
-
-
-

Pair a new device

-

- Generate a QR code and scan it with the Orca mobile app to connect. -

-
- -
+

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

+
+ + +
+
-
-

Paired Devices

- {devices.length === 0 ? ( -

No devices paired yet.

- ) : ( -
- {devices.map((device) => ( -
-
-
{device.name}
-
- Paired {new Date(device.pairedAt).toLocaleDateString()} -
-
- -
- ))} -
- )} -
+ {/* QR code display */} + {qrDataUrl && ( +
+ + {endpoint && {endpoint}} +

+ Scan this code with the Orca mobile app. Each code creates a unique device token. +

)} + {/* Paired devices */} +
+

Paired Devices

+ {devices.length === 0 ? ( +

+ {qrDataUrl + ? 'No devices paired yet. Scan the QR code with the Orca mobile app.' + : 'No devices paired yet.'} +

+ ) : ( +
+ {devices.map((device) => ( +
+
+
{device.name}
+
+ Paired {new Date(device.pairedAt).toLocaleDateString()} +
+
+ +
+ ))} +
+ )} + {devices.length > 0 && ( +

+ Revoking a device disconnects it immediately. +

+ )} +
+ + {/* Enlarged QR dialog */}