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 <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-30 18:03:54 -04:00
co-authored by Orca
parent 322c597018
commit 1a183d31d2
9 changed files with 280 additions and 156 deletions
+3 -3
View File
@@ -85,8 +85,8 @@ const SORT_OPTIONS: PickerOption<SortMode>[] = [
const GROUP_OPTIONS: PickerOption<GroupMode>[] = [
{ 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,
+4 -3
View File
@@ -193,7 +193,8 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
<Switch
value={macroEnter}
onValueChange={setMacroEnter}
trackColor={{ false: colors.bgRaised, true: colors.accentBlue }}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
<Pressable
@@ -321,7 +322,7 @@ const styles = StyleSheet.create({
color: colors.textPrimary
},
saveButton: {
backgroundColor: colors.accentBlue,
backgroundColor: colors.textPrimary,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button,
alignItems: 'center'
@@ -330,7 +331,7 @@ const styles = StyleSheet.create({
opacity: 0.5
},
saveButtonText: {
color: '#fff',
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
}
+41 -16
View File
@@ -4,22 +4,34 @@ import QRCode from 'qrcode'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
export type NetworkInterface = {
name: string
address: string
}
// Why: the WebSocket transport advertises 0.0.0.0 as its endpoint, which isn't
// connectable from a mobile device. We resolve the first non-internal IPv4
// address so the QR code contains a reachable LAN IP.
function getLanAddress(): string | null {
// connectable from a mobile device. We enumerate all non-internal IPv4
// addresses so the user can choose which one to advertise in the QR code
// (e.g. LAN vs Tailscale).
function getNetworkInterfaces(): NetworkInterface[] {
const result: NetworkInterface[] = []
const interfaces = networkInterfaces()
for (const addrs of Object.values(interfaces)) {
for (const [name, addrs] of Object.entries(interfaces)) {
if (!addrs) {
continue
}
for (const addr of addrs) {
if (addr.family === 'IPv4' && !addr.internal) {
return addr.address
result.push({ name, address: addr.address })
}
}
}
return null
return result
}
function getLanAddress(): string | null {
const ifaces = getNetworkInterfaces()
return ifaces.length > 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
}))
}
})
+24
View File
@@ -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()
})
})
+41 -15
View File
@@ -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<void> {
const httpServer = this.createHttpServer()
await new Promise<void>((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<void>((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'
}
+7
View File
@@ -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) => {
+6 -1
View File
@@ -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 }
>
+8 -2
View File
@@ -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 }[]
@@ -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<string | null>(null)
const [endpoint, setEndpoint] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [devices, setDevices] = useState<PairedDevice[]>([])
const [qrEnlarged, setQrEnlarged] = useState(false)
const [networkInterfaces, setNetworkInterfaces] = useState<NetworkInterface[]>([])
const [selectedAddress, setSelectedAddress] = useState<string | undefined>(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<number | null>(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 (
<div className="space-y-6">
{qrDataUrl ? (
<div className="flex items-start gap-8">
<div className="flex flex-col items-center gap-3">
<button
type="button"
onClick={() => setQrEnlarged(true)}
className="group relative cursor-pointer rounded-lg border border-border/60 bg-white p-3"
>
<img src={qrDataUrl} alt="QR Code for mobile pairing" className="size-48" />
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
<div className="flex items-center gap-2">
<Button
onClick={() => void generateQR()}
disabled={loading}
size="sm"
className="gap-1.5"
>
<RefreshCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
Regenerate
</Button>
</div>
{endpoint && (
<span className="text-muted-foreground font-mono text-xs">{endpoint}</span>
)}
</div>
<div className="flex-1 pt-0.5">
<h3 className="mb-2 text-sm font-medium">Paired Devices</h3>
{devices.length === 0 ? (
<p className="text-muted-foreground text-sm">
No devices paired yet. Scan the QR code with the Orca mobile app.
</p>
) : (
<div className="space-y-2">
{devices.map((device) => (
<div
key={device.deviceId}
className="flex items-center justify-between rounded-lg border border-border/60 px-3 py-2"
>
<div>
<div className="text-sm font-medium">{device.name}</div>
<div className="text-muted-foreground text-xs">
Paired {new Date(device.pairedAt).toLocaleDateString()}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => void revokeDevice(device.deviceId)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
)}
<p className="text-muted-foreground mt-4 text-xs">
Each QR code creates a unique device token. Revoking a device disconnects it
immediately.
</p>
</div>
{/* 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>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border border-border/60 px-4 py-3">
<div>
<p className="text-sm font-medium">Pair a new device</p>
<p className="text-muted-foreground text-xs">
Generate a QR code and scan it with the Orca mobile app to connect.
</p>
</div>
<Button
onClick={() => void generateQR()}
disabled={loading}
size="sm"
className="gap-1.5"
>
<RefreshCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
Generate QR Code
</Button>
</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()}
disabled={loading || !selectedAddress}
size="sm"
className="gap-1.5"
>
<RefreshCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
{qrDataUrl ? 'Regenerate' : 'Generate QR Code'}
</Button>
</div>
</div>
<div>
<h3 className="mb-2 text-sm font-medium">Paired Devices</h3>
{devices.length === 0 ? (
<p className="text-muted-foreground text-sm">No devices paired yet.</p>
) : (
<div className="space-y-2">
{devices.map((device) => (
<div
key={device.deviceId}
className="flex items-center justify-between rounded-lg border border-border/60 px-3 py-2"
>
<div>
<div className="text-sm font-medium">{device.name}</div>
<div className="text-muted-foreground text-xs">
Paired {new Date(device.pairedAt).toLocaleDateString()}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => void revokeDevice(device.deviceId)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
{/* QR code display */}
{qrDataUrl && (
<div className="flex flex-col items-center gap-3 rounded-lg border border-border/60 py-6">
<button
type="button"
onClick={() => setQrEnlarged(true)}
className="group relative cursor-pointer rounded-lg border border-border/60 bg-white p-3"
>
<img src={qrDataUrl} alt="QR Code for mobile pairing" className="size-48" />
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
{endpoint && <span className="text-muted-foreground font-mono text-xs">{endpoint}</span>}
<p className="text-muted-foreground max-w-xs text-center text-xs">
Scan this code with the Orca mobile app. Each code creates a unique device token.
</p>
</div>
)}
{/* Paired devices */}
<div>
<h3 className="mb-2 text-sm font-medium">Paired Devices</h3>
{devices.length === 0 ? (
<p className="text-muted-foreground text-sm">
{qrDataUrl
? 'No devices paired yet. Scan the QR code with the Orca mobile app.'
: 'No devices paired yet.'}
</p>
) : (
<div className="space-y-2">
{devices.map((device) => (
<div
key={device.deviceId}
className="flex items-center justify-between rounded-lg border border-border/60 px-3 py-2"
>
<div>
<div className="text-sm font-medium">{device.name}</div>
<div className="text-muted-foreground text-xs">
Paired {new Date(device.pairedAt).toLocaleDateString()}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => void revokeDevice(device.deviceId)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
)}
{devices.length > 0 && (
<p className="text-muted-foreground mt-3 text-xs">
Revoking a device disconnects it immediately.
</p>
)}
</div>
{/* Enlarged QR dialog */}
<Dialog open={qrEnlarged} onOpenChange={setQrEnlarged}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>