Files
clawX/electron/utils/control-ui-device-pairing.ts
2026-05-27 12:57:09 +08:00

297 lines
9.3 KiB
TypeScript

import { app, utilityProcess } from 'electron';
import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import { join } from 'path';
import { PORTS } from './config';
import { prependPathEntry } from './env-path';
import { logger } from './logger';
import { getOpenClawConfigDir, getOpenClawDir, getOpenClawEntryPath } from './paths';
import { getSetting } from './store';
import { getUvMirrorEnv } from './uv-env';
/** Browser Control UI client id used in OpenClaw 2026.5.x connect frames. */
export const CONTROL_UI_BROWSER_CLIENT_ID = 'openclaw-control-ui';
export type PendingDevicePairingRequest = {
requestId?: string;
clientId?: string;
clientMode?: string;
role?: string;
roles?: string[];
scopes?: string[];
platform?: string;
};
export type DevicePairingList = {
pending?: PendingDevicePairingRequest[];
paired?: unknown[];
};
export type GatewayPairingRpcClient = {
isConnected: () => boolean;
getStatus?: () => { port?: number };
rpc: <T>(method: string, params?: unknown, timeoutMs?: number) => Promise<T>;
};
const DEFAULT_POLL_INTERVAL_MS = 800;
const DEFAULT_WATCH_TIMEOUT_MS = 90_000;
const LIST_RPC_TIMEOUT_MS = 10_000;
const APPROVE_RPC_TIMEOUT_MS = 15_000;
const CLI_APPROVE_TIMEOUT_MS = 20_000;
let activeWatcher: { cancel: () => void } | null = null;
export function isControlUiBrowserPairingRequest(request: PendingDevicePairingRequest): boolean {
const clientId = typeof request.clientId === 'string' ? request.clientId.trim() : '';
return clientId === CONTROL_UI_BROWSER_CLIENT_ID;
}
function parseDevicePairingList(value: unknown): DevicePairingList {
const record = typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
return {
pending: Array.isArray(record.pending)
? (record.pending as PendingDevicePairingRequest[])
: [],
paired: Array.isArray(record.paired) ? record.paired : [],
};
}
function resolveGatewayPort(gateway: GatewayPairingRpcClient): number {
return gateway.getStatus?.()?.port ?? PORTS.OPENCLAW_GATEWAY;
}
function resolveWatchTimeoutMs(explicit?: number): number {
return typeof explicit === 'number' ? explicit : DEFAULT_WATCH_TIMEOUT_MS;
}
/** Read ~/.openclaw/devices/pending.json (same store the Gateway uses on loopback). */
export async function readLocalPendingPairingRequests(): Promise<PendingDevicePairingRequest[]> {
const pendingPath = join(getOpenClawConfigDir(), 'devices', 'pending.json');
try {
const raw = await readFile(pendingPath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, PendingDevicePairingRequest>;
if (!parsed || typeof parsed !== 'object') return [];
return Object.values(parsed).filter((entry) => entry && typeof entry === 'object');
} catch {
return [];
}
}
async function listPendingPairingRequests(gateway: GatewayPairingRpcClient): Promise<PendingDevicePairingRequest[]> {
const merged = new Map<string, PendingDevicePairingRequest>();
for (const request of await readLocalPendingPairingRequests()) {
const requestId = typeof request.requestId === 'string' ? request.requestId.trim() : '';
if (requestId) merged.set(requestId, request);
}
if (gateway.isConnected()) {
try {
const list = parseDevicePairingList(
await gateway.rpc<unknown>('device.pair.list', {}, LIST_RPC_TIMEOUT_MS),
);
for (const request of list.pending ?? []) {
const requestId = typeof request.requestId === 'string' ? request.requestId.trim() : '';
if (requestId) merged.set(requestId, request);
}
} catch (error) {
logger.debug(`[control-ui] device.pair.list RPC failed, using local pending file: ${String(error)}`);
}
}
return [...merged.values()];
}
function getBundledBinPath(): string {
const target = `${process.platform}-${process.arch}`;
return app.isPackaged
? join(process.resourcesPath, 'bin')
: join(process.cwd(), 'resources', 'bin', target);
}
/**
* Run `openclaw devices approve` in-process (not shown to the user).
* OpenClaw falls back to local pending.json on loopback when RPC is unavailable.
*/
async function approveViaOpenClawCli(requestId: string, _port: number): Promise<boolean> {
const entryScript = getOpenClawEntryPath();
const openclawDir = getOpenClawDir();
if (!existsSync(entryScript)) {
logger.warn('[control-ui] Cannot run devices approve: OpenClaw entry missing');
return false;
}
const token = await getSetting('gatewayToken');
const args = ['devices', 'approve', requestId, '--token', token, '--timeout', String(CLI_APPROVE_TIMEOUT_MS)];
const binPath = getBundledBinPath();
const binPathExists = existsSync(binPath);
const baseEnv = (binPathExists
? prependPathEntry(process.env as Record<string, string | undefined>, binPath).env
: process.env) as Record<string, string | undefined>;
const uvEnv = await getUvMirrorEnv();
return await new Promise<boolean>((resolve) => {
const child = utilityProcess.fork(entryScript, args, {
cwd: openclawDir,
stdio: 'pipe',
env: {
...baseEnv,
...uvEnv,
OPENCLAW_NO_RESPAWN: '1',
OPENCLAW_EMBEDDED_IN: 'ClawX',
} as NodeJS.ProcessEnv,
});
let settled = false;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
resolve(ok);
};
const timeout = setTimeout(() => {
logger.warn(`[control-ui] devices approve timed out for ${requestId}`);
try {
child.kill();
} catch {
// ignore
}
finish(false);
}, CLI_APPROVE_TIMEOUT_MS + 5_000);
child.on('error', (error) => {
clearTimeout(timeout);
logger.warn(`[control-ui] devices approve spawn failed: ${String(error)}`);
finish(false);
});
child.on('exit', (code: number) => {
clearTimeout(timeout);
finish(code === 0);
});
});
}
async function approvePairingRequest(
gateway: GatewayPairingRpcClient,
requestId: string,
port: number,
): Promise<boolean> {
if (gateway.isConnected()) {
try {
await gateway.rpc('device.pair.approve', { requestId }, APPROVE_RPC_TIMEOUT_MS);
return true;
} catch (error) {
logger.debug(
`[control-ui] device.pair.approve RPC failed for ${requestId}, trying CLI fallback: ${String(error)}`,
);
}
}
return approveViaOpenClawCli(requestId, port);
}
function sleep(ms: number, signal: { cancelled: boolean }): Promise<void> {
return new Promise((resolve) => {
if (signal.cancelled) {
resolve();
return;
}
const timer = setTimeout(() => {
clearTimeout(timer);
resolve();
}, ms);
});
}
/**
* Approve pending Control UI browser pairing requests.
* Uses Gateway RPC when available; falls back to local pending.json + embedded CLI on Windows packaged builds.
*/
export async function approvePendingControlUiPairingRequests(
gateway: GatewayPairingRpcClient,
options?: { approvedRequestIds?: Set<string> },
): Promise<string[]> {
const port = resolveGatewayPort(gateway);
const approvedRequestIds = options?.approvedRequestIds ?? new Set<string>();
const pending = await listPendingPairingRequests(gateway);
const approved: string[] = [];
for (const request of pending) {
if (!isControlUiBrowserPairingRequest(request)) continue;
const requestId = typeof request.requestId === 'string' ? request.requestId.trim() : '';
if (!requestId || approvedRequestIds.has(requestId)) continue;
try {
const ok = await approvePairingRequest(gateway, requestId, port);
if (!ok) continue;
approvedRequestIds.add(requestId);
approved.push(requestId);
logger.info(
`[control-ui] Auto-approved browser device pairing (requestId=${requestId}, mode=${request.clientMode ?? 'unknown'})`,
);
} catch (error) {
logger.warn(
`[control-ui] Failed to auto-approve pairing request ${requestId}: ${String(error)}`,
);
}
}
return approved;
}
async function watchControlUiPairingApprovals(
gateway: GatewayPairingRpcClient,
signal: { cancelled: boolean },
timeoutMs: number,
pollIntervalMs: number,
): Promise<void> {
const approvedRequestIds = new Set<string>();
const deadline = Date.now() + timeoutMs;
while (!signal.cancelled && Date.now() < deadline) {
try {
await approvePendingControlUiPairingRequests(gateway, { approvedRequestIds });
} catch (error) {
logger.debug(`[control-ui] Pairing poll error: ${String(error)}`);
}
await sleep(pollIntervalMs, signal);
}
}
/**
* Poll for Control UI browser pairing requests and approve them locally.
* Safe to call repeatedly; only one watcher runs at a time.
*/
export function scheduleControlUiDeviceAutoApproval(
gateway: GatewayPairingRpcClient,
options?: {
timeoutMs?: number;
pollIntervalMs?: number;
},
): void {
activeWatcher?.cancel();
const signal = { cancelled: false };
const cancel = () => {
signal.cancelled = true;
};
activeWatcher = { cancel };
const timeoutMs = resolveWatchTimeoutMs(options?.timeoutMs);
const pollIntervalMs = options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
void watchControlUiPairingApprovals(gateway, signal, timeoutMs, pollIntervalMs)
.catch((error) => {
logger.warn(`[control-ui] Auto-approval watcher failed: ${String(error)}`);
})
.finally(() => {
if (activeWatcher?.cancel === cancel) {
activeWatcher = null;
}
});
}