Files
orca/src/main/ipc/mobile.ts
T
OrcaWinandOrcaWin c3939ebf0e fix(mobile): allow reachable Hyper-V pairing addresses (#13107)
* fix(mobile): allow reachable Hyper-V pairing addresses

* fix(mobile): keep host-local Hyper-V addresses filtered

* fix(mobile): preserve explicit address on empty refresh

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-07 20:37:47 -07:00

304 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants'
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address'
import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach'
import type { DeviceEntry } from '../runtime/device-registry'
import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance'
import {
getDefaultPairingAddress,
getPairingNetworkInterfaces,
type DefaultRouteInterfaceLookup,
type NetworkInterface
} from '../runtime/pairing-network-interfaces'
import { resolveAdvertisedPairingHostname } from '../runtime/pairing-endpoint'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker'
import { encodeMobilePairingQr, type MobilePairingQrResult } from '../runtime/mobile-pairing-qr'
import { getWindowsDefaultRouteInterfaceNames } from '../runtime/windows-default-route-interfaces'
import {
getWebSocketPort,
inspectWindowsMobileFirewall,
repairWindowsMobileFirewall,
type WindowsMobileFirewallEnvironment
} from '../runtime/windows-mobile-firewall'
// Why: only an explicit "This computer only" pick skips the one-way widen, and only when the address it
// advertises really is loopback — a mismatch (a LAN address under a this-computer reach) would otherwise
// mint a link with no listener behind it. Every other reach, including a loopback-looking Custom address
// that fronts an SSH tunnel or reverse proxy, still opts in.
function servesThisComputerOnly(reach: RuntimePairingReach | undefined, address: string): boolean {
if (reach !== 'this-computer') {
return false
}
const hostname = resolveAdvertisedPairingHostname(address)
return hostname !== null && classifyRemotePairingHostname(hostname) === 'loopback'
}
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
encodePairingQr?: (pairingUrl: string) => Promise<MobilePairingQrResult>
getDefaultRouteInterfaceNames?: DefaultRouteInterfaceLookup
}
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
}
const getDefaultRouteInterfaceNames =
dependencies.getDefaultRouteInterfaceNames ?? getWindowsDefaultRouteInterfaceNames
ipcMain.handle(
'mobile:listNetworkInterfaces',
async (): Promise<{ interfaces: NetworkInterface[] }> => ({
interfaces: await getPairingNetworkInterfaces(getDefaultRouteInterfaceNames)
})
)
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 ?? (await getDefaultPairingAddress(getDefaultRouteInterfaceNames))
// Why: the local address is optional under Relay — the QR carries the relay invite, so a host
// with nothing auto-advertisable (only container bridges, or no interface at all) still pairs.
// The offer's endpoint then falls back to loopback, which is the phone's own device: the direct
// candidate loses the race by construction. LAN-only has no relay to fall back on, so it fails closed.
if (!ip && args?.connectionMode === 'local-only') {
return {
available: false as const,
reason: 'invalid_advertised_endpoint',
guidance:
'No reachable network address is available for pairing. Connect to Wi‑Fi or Tailscale, or pick an address manually.'
}
}
// 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) {
// Why: surface Relay mint failures (and other pairing unavailability)
// so the UI can refuse a silent LAN QR under the Relay label.
return {
available: false as const,
reason: offer.reason,
guidance: offer.guidance,
...(offer.relayFailure ? { relayFailure: offer.relayFailure } : {})
}
}
const qr = await (dependencies.encodePairingQr ?? encodeMobilePairingQr)(offer.pairingUrl)
return {
available: true as const,
qrDataUrl: qr.ok ? qr.qrDataUrl : null,
...(!qr.ok ? { qrError: qr.reason } : {}),
pairingUrl: offer.pairingUrl,
// Why: with nothing advertised the offer's endpoint is the loopback fallback, which points at
// whichever device scans the QR — never this host. Report no endpoint so the UI omits it
// instead of printing an address the phone can't reach.
endpoint: ip ? offer.endpoint : null,
deviceId: offer.deviceId,
connectionMode: offer.connectionMode
}
}
)
ipcMain.handle(
'mobile:getRuntimePairingUrl',
async (_event, args?: { address?: string; rotate?: boolean; reach?: RuntimePairingReach }) => {
const ip = args?.address ?? (await getDefaultPairingAddress(getDefaultRouteInterfaceNames))
if (!ip) {
return { available: false as const }
}
// Why: STA-2370 — generating a runtime pairing offer is the user's explicit opt-in to remote
// reach, so widen the loopback listener before advertising its LAN endpoint. If the widen fails the
// listener stays on loopback, so report unavailable rather than advertise a dead LAN endpoint.
// "This computer only" is the opposite opt-in: the loopback listener already serves it, and the widen
// never narrows back, so that pick alone must not expose the runtime off-host.
const thisComputerOnly = servesThisComputerOnly(args?.reach, ip)
if (!thisComputerOnly) {
try {
await rpcServer.ensureNetworkExposure()
} catch (error) {
console.error(
'[mobile] Network exposure failed while creating a runtime pairing offer:',
error
)
// Why: STA-2370 — carry the specific reason/guidance to the renderer (mirrors the mobile-QR path) so
// a widen failure is distinguishable from a missing address, not collapsed into a bare unavailable.
return {
available: false as const,
reason: 'network_exposure_failed' as const,
guidance: NETWORK_EXPOSURE_FAILED_GUIDANCE
}
}
}
// 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',
// Why: a grant that only ever pointed at loopback must not make the next launch bind every
// interface when its local client reconnects (that would restore the exposure one restart later).
reach: thisComputerOnly ? 'this-computer' : 'network'
})
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'
}