mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: PannenetsF <fanyunqian.1@bytedance.com>
290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
|
|
import { networkInterfaces } from 'node:os'
|
|
import QRCode from 'qrcode'
|
|
import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants'
|
|
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
|
|
import { isTailnetIPv4Address } from '../../shared/tailnet-address'
|
|
import type { DeviceEntry } from '../runtime/device-registry'
|
|
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
|
|
import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker'
|
|
import {
|
|
getWebSocketPort,
|
|
inspectWindowsMobileFirewall,
|
|
repairWindowsMobileFirewall,
|
|
type WindowsMobileFirewallEnvironment
|
|
} from '../runtime/windows-mobile-firewall'
|
|
|
|
export type NetworkInterface = {
|
|
name: string
|
|
address: string
|
|
}
|
|
|
|
// Why: link-local IPv6 addresses (fe80::/10) require a scope/zone id to be
|
|
// connectable and never work as a QR-advertised pairing host, so they are
|
|
// excluded from the pickable list. The regex covers the full /10 range
|
|
// (fe80: through febf:), not just the fe80: prefix the OS usually assigns.
|
|
function isUsableIPv6Address(address: string): boolean {
|
|
return !/^fe[89ab][0-9a-f]:/i.test(address)
|
|
}
|
|
|
|
// Why: the WebSocket transport advertises 0.0.0.0 as its endpoint, which isn't
|
|
// connectable from a mobile device. We enumerate all non-internal IPv4 and
|
|
// (non-link-local) IPv6 addresses so the user can choose which one to advertise
|
|
// in the QR code (e.g. LAN vs Tailscale). IPv6 must be included so pairing works
|
|
// on IPv6-only hosts (e.g. a headless `orca serve` reachable only over IPv6),
|
|
// where an IPv4-only scan returns nothing and the UI reports "no interfaces".
|
|
function getNetworkInterfaces(): NetworkInterface[] {
|
|
const result: NetworkInterface[] = []
|
|
const interfaces = networkInterfaces()
|
|
for (const [name, addrs] of Object.entries(interfaces)) {
|
|
if (!addrs) {
|
|
continue
|
|
}
|
|
for (const addr of addrs) {
|
|
if (addr.internal) {
|
|
continue
|
|
}
|
|
if (addr.family === 'IPv4') {
|
|
result.push({ name, address: addr.address })
|
|
} else if (addr.family === 'IPv6' && isUsableIPv6Address(addr.address)) {
|
|
result.push({ name, address: addr.address })
|
|
}
|
|
}
|
|
}
|
|
// Why: prefer tailnet IPv4 first (most portable across networks), then other
|
|
// IPv4, then IPv6 as a fallback for IPv6-only environments.
|
|
return result.sort((a, b) => rankAddress(a.address) - rankAddress(b.address))
|
|
}
|
|
|
|
function rankAddress(address: string): number {
|
|
if (isTailnetIPv4Address(address)) {
|
|
return 0
|
|
}
|
|
return address.includes(':') ? 2 : 1
|
|
}
|
|
|
|
function getDefaultPairingAddress(): string | null {
|
|
const ifaces = getNetworkInterfaces()
|
|
return ifaces.length > 0 ? ifaces[0]!.address : null
|
|
}
|
|
|
|
function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant {
|
|
return {
|
|
deviceId: device.deviceId,
|
|
name: device.name,
|
|
createdAt: device.pairedAt,
|
|
lastSeenAt: device.lastSeenAt > 0 ? device.lastSeenAt : null
|
|
}
|
|
}
|
|
|
|
// Why: the mobile IPC handlers provide the renderer with QR code pairing data,
|
|
// device management, and WebSocket readiness status. They depend on the
|
|
// OrcaRuntimeRpcServer because it owns the device registry and TLS state.
|
|
|
|
export type MobileHandlerDependencies = {
|
|
firewallEnvironment?: WindowsMobileFirewallEnvironment
|
|
openWindowsNetworkSettings?: () => Promise<void>
|
|
getRelayStatus?: () => RelayBrokerStatus
|
|
consumePendingUnpairedDeviceAuthFailure?: (webContentsId: number) => boolean
|
|
}
|
|
|
|
export function registerMobileHandlers(
|
|
rpcServer: OrcaRuntimeRpcServer,
|
|
dependencies: MobileHandlerDependencies = {}
|
|
): void {
|
|
const firewallEnvironment = dependencies.firewallEnvironment ?? {
|
|
platform: process.platform,
|
|
isPackaged: app.isPackaged,
|
|
executablePath: process.execPath,
|
|
systemRoot: process.env.SystemRoot
|
|
}
|
|
ipcMain.handle('mobile:listNetworkInterfaces', (): { interfaces: NetworkInterface[] } => ({
|
|
interfaces: getNetworkInterfaces()
|
|
}))
|
|
|
|
ipcMain.handle(
|
|
'mobile:getPairingQR',
|
|
async (
|
|
_event,
|
|
args?: {
|
|
address?: string
|
|
connectionMode?: MobilePairingConnectionMode
|
|
rotate?: boolean
|
|
}
|
|
) => {
|
|
// 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 ?? getDefaultPairingAddress()
|
|
if (!ip) {
|
|
return { available: false as const }
|
|
}
|
|
|
|
// Why: coalesce repeated QR regenerations onto a single never-scanned
|
|
// pending token so the copy-button flow doesn't accumulate orphaned
|
|
// device credentials forever. The token graduates to a real entry when
|
|
// a phone actually connects (lastSeenAt > 0). When the caller passes
|
|
// `rotate: true` (explicit "Regenerate" intent because the prior token
|
|
// may have been exposed), we discard any pending token and mint a fresh
|
|
// one so the new QR carries a different credential.
|
|
const offer = await rpcServer.createMobilePairingOffer({
|
|
address: ip,
|
|
connectionMode: args?.connectionMode,
|
|
rotate: args?.rotate,
|
|
name: `Mobile ${new Date().toLocaleDateString()}`
|
|
})
|
|
if (!offer.available) {
|
|
return { available: false as const }
|
|
}
|
|
|
|
const qrDataUrl = await QRCode.toDataURL(offer.pairingUrl, {
|
|
errorCorrectionLevel: 'M',
|
|
margin: 2,
|
|
width: 256
|
|
})
|
|
|
|
return {
|
|
available: true as const,
|
|
qrDataUrl,
|
|
pairingUrl: offer.pairingUrl,
|
|
endpoint: offer.endpoint,
|
|
deviceId: offer.deviceId,
|
|
// Why: an automatic request can degrade to a local-only offer when
|
|
// Relay provisioning fails; the UI needs the encoded mode to avoid
|
|
// labeling a LAN-only code as Relay.
|
|
connectionMode: offer.connectionMode
|
|
}
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'mobile:getRuntimePairingUrl',
|
|
async (_event, args?: { address?: string; rotate?: boolean }) => {
|
|
const ip = args?.address ?? getDefaultPairingAddress()
|
|
if (!ip) {
|
|
return { available: false as const }
|
|
}
|
|
|
|
// Why: web/desktop runtime clients need full runtime access, not the
|
|
// mobile allowlist used by phone QR pairing.
|
|
const offer = rpcServer.createPairingOffer({
|
|
address: ip,
|
|
rotate: args?.rotate,
|
|
name: `Runtime ${new Date().toLocaleDateString()}`,
|
|
scope: 'runtime'
|
|
})
|
|
if (!offer.available) {
|
|
return { available: false as const }
|
|
}
|
|
|
|
return {
|
|
available: true as const,
|
|
pairingUrl: offer.pairingUrl,
|
|
webClientUrl: offer.webClientUrl,
|
|
endpoint: offer.endpoint,
|
|
deviceId: offer.deviceId
|
|
}
|
|
}
|
|
)
|
|
|
|
ipcMain.handle('mobile:listDevices', () => {
|
|
const registry = rpcServer.getDeviceRegistry()
|
|
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()
|
|
.filter((d) => d.scope === 'mobile' && d.lastSeenAt > 0)
|
|
.map((d) => ({
|
|
deviceId: d.deviceId,
|
|
name: d.name,
|
|
pairedAt: d.pairedAt,
|
|
lastSeenAt: d.lastSeenAt
|
|
}))
|
|
}
|
|
})
|
|
|
|
ipcMain.handle('mobile:listRuntimeAccessGrants', () => {
|
|
const registry = rpcServer.getDeviceRegistry()
|
|
if (!registry) {
|
|
return { grants: [] }
|
|
}
|
|
// Why: generated web/runtime links are bearer credentials even before a
|
|
// client first connects, so pending runtime grants must stay revocable.
|
|
return {
|
|
grants: registry
|
|
.listDevices()
|
|
.filter((d) => d.scope === 'runtime')
|
|
.sort((a, b) => b.pairedAt - a.pairedAt)
|
|
.map(toRuntimeAccessGrant)
|
|
}
|
|
})
|
|
|
|
ipcMain.handle('mobile:revokeDevice', async (_event, args: { deviceId: string }) => {
|
|
const registry = rpcServer.getDeviceRegistry()
|
|
if (!registry) {
|
|
return { revoked: false }
|
|
}
|
|
return { revoked: await rpcServer.revokeMobileDevice(args.deviceId) }
|
|
})
|
|
|
|
ipcMain.handle('mobile:revokeRuntimeAccess', (_event, args: { deviceId: string }) => {
|
|
const registry = rpcServer.getDeviceRegistry()
|
|
if (!registry) {
|
|
return { revoked: false }
|
|
}
|
|
return { revoked: rpcServer.revokeRuntimeAccess(args.deviceId) }
|
|
})
|
|
|
|
ipcMain.handle('mobile:isWebSocketReady', () => {
|
|
return {
|
|
ready: rpcServer.getWebSocketEndpoint() !== null,
|
|
endpoint: rpcServer.getWebSocketEndpoint()
|
|
}
|
|
})
|
|
|
|
ipcMain.handle('mobile:getWindowsFirewallStatus', (_event, args?: { address?: string }) => {
|
|
const port = getWebSocketPort(rpcServer.getWebSocketEndpoint())
|
|
return inspectWindowsMobileFirewall(port, args?.address, firewallEnvironment)
|
|
})
|
|
|
|
ipcMain.handle('mobile:repairWindowsFirewall', (event: IpcMainInvokeEvent) => {
|
|
if (!isWindowRenderer(event)) {
|
|
return { ok: false as const, reason: 'unsupported' as const }
|
|
}
|
|
// Why: elevated inputs come from the running runtime, never the renderer.
|
|
const port = getWebSocketPort(rpcServer.getWebSocketEndpoint())
|
|
return repairWindowsMobileFirewall(port, firewallEnvironment)
|
|
})
|
|
|
|
ipcMain.handle('mobile:openWindowsNetworkSettings', async (event: IpcMainInvokeEvent) => {
|
|
if (!isWindowRenderer(event) || firewallEnvironment.platform !== 'win32') {
|
|
return false
|
|
}
|
|
const openSettings =
|
|
dependencies.openWindowsNetworkSettings ??
|
|
(() => shell.openExternal('ms-settings:network-status'))
|
|
await openSettings()
|
|
return true
|
|
})
|
|
|
|
ipcMain.handle('mobile:getRelayStatus', () => ({
|
|
status: dependencies.getRelayStatus?.() ?? 'offline'
|
|
}))
|
|
|
|
ipcMain.handle('mobile:consumePendingUnpairedDeviceAuthFailure', (event) => {
|
|
if (!isWindowRenderer(event)) {
|
|
return false
|
|
}
|
|
return dependencies.consumePendingUnpairedDeviceAuthFailure?.(event.sender.id) ?? false
|
|
})
|
|
}
|
|
|
|
function isWindowRenderer(event: IpcMainInvokeEvent): boolean {
|
|
return !event.sender.isDestroyed() && event.sender.getType() === 'window'
|
|
}
|