feat(robot): connect provisioning hotspots in app

This commit is contained in:
2026-08-16 20:18:28 +08:00
parent abecd5f344
commit c1326a2980
17 changed files with 1950 additions and 11 deletions

View File

@@ -6,6 +6,7 @@ import { sendJson } from '../route-utils';
import { WORKS_SQUARE_CONFIG } from '../works-config';
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import type { RobotHotspotModule } from '../../robot-hotspot';
const MAX_REQUEST_BYTES = 64 * 1024;
const MAX_RESPONSE_BYTES = 256 * 1024;
@@ -16,6 +17,7 @@ const REVISION_ETAG = /^(?:W\/)?"(0|[1-9]\d*)"$/;
const LOCAL_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,35}$/;
const CATALOG_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,254}$/;
const OPERATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const HOTSPOT_CANDIDATE_ID = /^[A-Za-z0-9_-]{1,128}$/;
const MAX_RETRY_AFTER_SECONDS = 2;
const PROVISIONING_PORTAL_URL = 'http://192.168.4.1/';
const GUIDED_HOTSPOT_BINDING_ENABLED = process.env.NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING !== '0';
@@ -30,6 +32,7 @@ export type AiHardwareRouteDependencies = {
timeoutMs?: number;
guidedHotspotBinding?: boolean;
openExternal?: (url: string) => Promise<void>;
robotHotspot?: RobotHotspotModule;
};
class SafeRouteError extends Error {
@@ -291,6 +294,52 @@ function requireRevision(body: Record<string, unknown>): number {
return revision;
}
function requireHotspotCandidateId(value: unknown): string {
if (typeof value !== 'string' || !HOTSPOT_CANDIDATE_ID.test(value)) {
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
}
return value;
}
const HOTSPOT_ERRORS: Record<string, { status: number; code: string; error: string }> = {
unsupported: {
status: 501,
code: 'AI_HARDWARE_HOTSPOT_UNSUPPORTED',
error: 'Robot hotspot connection is not supported on this platform',
},
permission_denied: {
status: 403,
code: 'AI_HARDWARE_HOTSPOT_PERMISSION_DENIED',
error: 'Permission to access nearby Robot hotspots was denied',
},
busy: {
status: 409,
code: 'AI_HARDWARE_HOTSPOT_BUSY',
error: 'Another Robot hotspot operation is in progress',
},
candidate_expired: {
status: 409,
code: 'AI_HARDWARE_HOTSPOT_CANDIDATE_EXPIRED',
error: 'The Robot hotspot selection expired; scan again',
},
scan_failed: {
status: 502,
code: 'AI_HARDWARE_HOTSPOT_SCAN_FAILED',
error: 'Robot hotspots could not be scanned',
},
connect_failed: {
status: 502,
code: 'AI_HARDWARE_HOTSPOT_CONNECT_FAILED',
error: 'The Robot hotspot could not be connected',
},
};
function asHotspotRouteError(error: unknown, fallback: 'scan_failed' | 'connect_failed'): SafeRouteError {
const nativeCode = isRecord(error) && typeof error.code === 'string' ? error.code : fallback;
const safe = HOTSPOT_ERRORS[nativeCode] ?? HOTSPOT_ERRORS[fallback];
return new SafeRouteError(safe.status, safe.code, safe.error);
}
function takeOperationId(body: Record<string, unknown>, createUuid: () => string): string {
const candidate = body.client_operation_id ?? createUuid();
if (typeof candidate !== 'string' || !OPERATION_ID.test(candidate)) {
@@ -442,6 +491,9 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend
const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const guidedHotspotBinding = dependencies.guidedHotspotBinding ?? GUIDED_HOTSPOT_BINDING_ENABLED;
const openExternal = dependencies.openExternal ?? ((url: string) => shell.openExternal(url));
const getRobotHotspot = dependencies.robotHotspot
? async () => dependencies.robotHotspot as RobotHotspotModule
: async () => (await import('../../robot-hotspot')).robotHotspotModule;
return async function handleAiHardwareRoutes(
req: IncomingMessage,
@@ -493,6 +545,67 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend
sendJson(res, 200, { success: true, data: { opened: true } });
return true;
}
if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/provisioning-hotspots/scan`) {
if (url.search) {
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
}
const input = await readBoundedJson(req);
ensureExactKeys(input, new Set());
if (!guidedHotspotBinding) {
throw new SafeRouteError(
403,
'AI_HARDWARE_PROVISIONING_DISABLED',
'AI hardware guided provisioning is not enabled',
);
}
try {
const result = await (await getRobotHotspot()).scan();
sendJson(res, 200, {
success: true,
data: {
platform: result.platform,
hotspots: result.hotspots.map((hotspot) => ({
candidate_id: hotspot.candidateId,
ssid: hotspot.ssid,
signal_percent: hotspot.signalPercent,
connected: hotspot.connected,
})),
},
});
} catch (error) {
throw asHotspotRouteError(error, 'scan_failed');
}
return true;
}
if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/provisioning-hotspots/connect`) {
if (url.search) {
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
}
const input = await readBoundedJson(req);
ensureExactKeys(input, new Set(['candidate_id']));
const candidateId = requireHotspotCandidateId(input.candidate_id);
if (!guidedHotspotBinding) {
throw new SafeRouteError(
403,
'AI_HARDWARE_PROVISIONING_DISABLED',
'AI hardware guided provisioning is not enabled',
);
}
try {
const result = await (await getRobotHotspot()).connect(candidateId);
sendJson(res, 200, {
success: true,
data: {
connected: result.connected,
candidate_id: result.candidateId,
ssid: result.ssid,
},
});
} catch (error) {
throw asHotspotRouteError(error, 'connect_failed');
}
return true;
}
let upstreamPath: string;
let body: Record<string, unknown> | undefined;

View File

@@ -0,0 +1,36 @@
export interface RobotHotspotAdapterCandidate {
ssid: string;
signalPercent: number;
connected: boolean;
open: boolean;
nativeKey: unknown;
}
export interface RobotHotspotAdapter {
scan(signal: AbortSignal): Promise<RobotHotspotAdapterCandidate[]>;
connect(candidate: RobotHotspotAdapterCandidate, signal: AbortSignal): Promise<void>;
currentSsid(signal: AbortSignal): Promise<string | null>;
clear(): void;
}
export class RobotHotspotAdapterError extends Error {
constructor(public readonly reason: 'unsupported' | 'permission_denied' | 'scan_failed' | 'connect_failed') {
super(reason);
this.name = 'RobotHotspotAdapterError';
}
}
export function throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) throw signal.reason;
}
export function abortableDelay(milliseconds: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, milliseconds);
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(signal.reason);
}, { once: true });
});
}

View File

@@ -0,0 +1,193 @@
import { randomUUID } from 'node:crypto';
import type { RobotHotspotAdapter, RobotHotspotAdapterCandidate } from './adapter';
import { RobotHotspotAdapterError, abortableDelay, throwIfAborted } from './adapter';
export type RobotHotspotPlatform = 'windows' | 'macos';
export interface RobotHotspotCandidate {
candidateId: string;
ssid: string;
signalPercent: number;
connected: boolean;
}
export interface RobotHotspotScanResult {
platform: RobotHotspotPlatform;
hotspots: RobotHotspotCandidate[];
}
export interface RobotHotspotConnection {
connected: true;
candidateId: string;
ssid: string;
}
export interface RobotHotspotModule {
scan(): Promise<RobotHotspotScanResult>;
connect(candidateId: string): Promise<RobotHotspotConnection>;
clear(): void;
}
export type RobotHotspotErrorCode = 'unsupported' | 'permission_denied' | 'busy' | 'scan_failed' | 'candidate_expired' | 'connect_failed';
export class RobotHotspotError extends Error {
constructor(public readonly code: RobotHotspotErrorCode) {
super(code);
this.name = 'RobotHotspotError';
}
}
export interface RobotHotspotModuleOptions {
candidateTtlMs?: number;
scanTimeoutMs?: number;
connectTimeoutMs?: number;
verificationIntervalMs?: number;
now?: () => number;
createId?: () => string;
}
interface SnapshotEntry {
candidateId: string;
candidate: RobotHotspotAdapterCandidate;
}
const utf8 = new TextEncoder();
const MAX_PROJECTED_HOTSPOTS = 64;
const forbiddenSsidCharacters = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u;
function isAllowedSsid(ssid: string): boolean {
return ssid.startsWith('Xiaozhi-')
&& ssid.length > 'Xiaozhi-'.length
&& utf8.encode(ssid).byteLength <= 32
&& !forbiddenSsidCharacters.test(ssid)
&& !ssid.includes('\ufffd');
}
function stableError(error: unknown, fallback: 'scan_failed' | 'connect_failed'): RobotHotspotError {
if (error instanceof RobotHotspotError) return error;
if (error instanceof RobotHotspotAdapterError) return new RobotHotspotError(error.reason);
return new RobotHotspotError(fallback);
}
export function createRobotHotspotModule(platform: RobotHotspotPlatform, adapter: RobotHotspotAdapter, options: RobotHotspotModuleOptions = {}): RobotHotspotModule {
const candidateTtlMs = options.candidateTtlMs ?? 60_000;
const scanTimeoutMs = options.scanTimeoutMs ?? 8_000;
const connectTimeoutMs = options.connectTimeoutMs ?? 12_000;
const verificationIntervalMs = options.verificationIntervalMs ?? 250;
const now = options.now ?? Date.now;
const createId = options.createId ?? randomUUID;
let snapshot = new Map<string, SnapshotEntry>();
let expiresAt = 0;
let active = false;
function clear(): void {
snapshot = new Map();
expiresAt = 0;
adapter.clear();
}
async function runExclusive<T>(timeoutMs: number, timeoutCode: 'scan_failed' | 'connect_failed', work: (signal: AbortSignal) => Promise<T>): Promise<T> {
if (active) throw new RobotHotspotError('busy');
active = true;
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const operation = work(controller.signal).finally(() => {
if (timer) clearTimeout(timer);
active = false;
});
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
const error = new RobotHotspotError(timeoutCode);
controller.abort(error);
reject(error);
}, timeoutMs);
});
return Promise.race([operation, timeout]);
}
return {
async scan() {
if (active) throw new RobotHotspotError('busy');
clear();
try {
return await runExclusive(scanTimeoutMs, 'scan_failed', async (signal) => {
const discovered = await adapter.scan(signal);
throwIfAborted(signal);
const strongest = new Map<string, RobotHotspotAdapterCandidate>();
for (const candidate of discovered) {
if (!candidate.open || !isAllowedSsid(candidate.ssid)) continue;
const normalized = { ...candidate, signalPercent: Math.max(0, Math.min(100, Math.round(candidate.signalPercent))) };
const previous = strongest.get(candidate.ssid);
if (!previous || normalized.signalPercent > previous.signalPercent) strongest.set(candidate.ssid, normalized);
}
const next = new Map<string, SnapshotEntry>();
const hotspots = [...strongest.values()]
.sort((left, right) => Number(right.connected) - Number(left.connected) || right.signalPercent - left.signalPercent || left.ssid.localeCompare(right.ssid))
.slice(0, MAX_PROJECTED_HOTSPOTS)
.map((candidate) => {
const candidateId = createId();
next.set(candidateId, { candidateId, candidate });
return { candidateId, ssid: candidate.ssid, signalPercent: candidate.signalPercent, connected: candidate.connected };
});
snapshot = next;
expiresAt = now() + candidateTtlMs;
return { platform, hotspots };
});
} catch (error) {
throw stableError(error, 'scan_failed');
}
},
async connect(candidateId) {
if (active) throw new RobotHotspotError('busy');
const entry = snapshot.get(candidateId);
if (!entry || now() >= expiresAt) {
clear();
throw new RobotHotspotError('candidate_expired');
}
try {
return await runExclusive(connectTimeoutMs, 'connect_failed', async (signal) => {
await adapter.connect(entry.candidate, signal);
for (;;) {
throwIfAborted(signal);
if (await adapter.currentSsid(signal) === entry.candidate.ssid) {
return { connected: true as const, candidateId: entry.candidateId, ssid: entry.candidate.ssid };
}
await abortableDelay(verificationIntervalMs, signal);
}
});
} catch (error) {
throw stableError(error, 'connect_failed');
}
},
clear,
};
}
let defaultModule: Promise<RobotHotspotModule> | undefined;
async function loadDefaultModule(): Promise<RobotHotspotModule> {
const platform = process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : null;
if (!platform) throw new RobotHotspotError('unsupported');
if (!defaultModule) {
defaultModule = (platform === 'windows'
? import('./windows').then(({ createWindowsRobotHotspotAdapter }) => createWindowsRobotHotspotAdapter())
: import('./macos').then(({ createMacosRobotHotspotAdapter }) => createMacosRobotHotspotAdapter()))
.then((adapter) => createRobotHotspotModule(platform, adapter));
}
return defaultModule;
}
export const robotHotspotModule: RobotHotspotModule = {
async scan() {
try { return await (await loadDefaultModule()).scan(); }
catch (error) { throw stableError(error, 'scan_failed'); }
},
async connect(candidateId) {
try { return await (await loadDefaultModule()).connect(candidateId); }
catch (error) { throw stableError(error, 'connect_failed'); }
},
clear() { void defaultModule?.then((module) => module.clear()); },
};
export default robotHotspotModule;

View File

@@ -0,0 +1,332 @@
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
import type { RobotHotspotAdapter, RobotHotspotAdapterCandidate } from './adapter';
import { RobotHotspotAdapterError, abortableDelay, throwIfAborted } from './adapter';
const WORKER_KIND = 'makelore-robot-hotspot-macos';
const LOCATION_NOT_DETERMINED = 0;
const LOCATION_RESTRICTED = 1;
const LOCATION_DENIED = 2;
const LOCATION_AUTHORIZED_ALWAYS = 3;
const LOCATION_AUTHORIZED_WHEN_IN_USE = 4;
const CORE_WLAN_PERMISSION_DENIED = -3930;
const CORE_WLAN_UNSUPPORTED = -3903;
type StableReason = 'unsupported' | 'permission_denied' | 'scan_failed' | 'connect_failed';
type ObjcId = unknown;
interface WorkerCandidate {
ssid: string;
signalPercent: number;
connected: boolean;
open: boolean;
}
type WorkerRequest = { id: number; operation: 'scan' } | { id: number; operation: 'connect'; ssid: string };
type WorkerResponse = { id: number; ok: true; result: WorkerCandidate[] | { ssid: string } } | { id: number; ok: false; reason: StableReason };
export interface MacosWorkerLike {
postMessage(value: WorkerRequest): void;
once(event: 'message', listener: (value: WorkerResponse) => void): this;
once(event: 'error', listener: (error: Error) => void): this;
once(event: 'exit', listener: (code: number) => void): this;
off(event: 'message', listener: (value: WorkerResponse) => void): this;
off(event: 'error', listener: (error: Error) => void): this;
off(event: 'exit', listener: (code: number) => void): this;
terminate(): Promise<number>;
}
interface WorkerLifecycle {
terminate?: () => Promise<number>;
exited?: () => void;
}
export function requestMacosWorker<T>(worker: MacosWorkerLike, request: WorkerRequest, signal: AbortSignal, lifecycle: WorkerLifecycle = {}): Promise<T> {
const terminate = lifecycle.terminate ?? (() => worker.terminate());
if (signal.aborted) {
void terminate();
return Promise.reject(signal.reason);
}
return new Promise<T>((resolve, reject) => {
const cleanup = () => {
worker.off('message', onMessage);
worker.off('error', onError);
worker.off('exit', onExit);
signal.removeEventListener('abort', onAbort);
};
const onMessage = (response: WorkerResponse) => {
if (response.id !== request.id) return;
cleanup();
if (response.ok) resolve(response.result as T);
else reject(new RobotHotspotAdapterError(response.reason));
};
const onError = () => {
cleanup();
void terminate();
reject(new RobotHotspotAdapterError(request.operation === 'scan' ? 'scan_failed' : 'connect_failed'));
};
const onExit = () => {
cleanup();
lifecycle.exited?.();
reject(new RobotHotspotAdapterError(request.operation === 'scan' ? 'scan_failed' : 'connect_failed'));
};
const onAbort = () => {
cleanup();
void terminate();
reject(signal.reason);
};
worker.once('message', onMessage);
worker.once('error', onError);
worker.once('exit', onExit);
signal.addEventListener('abort', onAbort, { once: true });
try {
worker.postMessage(request);
} catch {
cleanup();
void terminate();
reject(new RobotHotspotAdapterError(request.operation === 'scan' ? 'scan_failed' : 'connect_failed'));
}
});
}
type WorkerRequestInput = { operation: 'scan' } | { operation: 'connect'; ssid: string };
export class MacosWorkerController {
private worker: MacosWorkerLike | undefined;
private requestId = 0;
private terminationBarrier: Promise<void> = Promise.resolve();
private readonly terminations = new WeakMap<object, Promise<number>>();
constructor(private readonly createWorker: () => MacosWorkerLike) {}
private retire(target: MacosWorkerLike): Promise<number> {
let termination = this.terminations.get(target as object);
if (!termination) {
termination = target.terminate();
this.terminations.set(target as object, termination);
const previousBarrier = this.terminationBarrier;
const settled = termination.then(() => undefined, () => undefined);
this.terminationBarrier = Promise.all([previousBarrier, settled]).then(() => undefined);
}
if (this.worker === target) this.worker = undefined;
return termination;
}
private markExited(target: MacosWorkerLike): void {
if (this.worker === target) this.worker = undefined;
}
private getWorker(): MacosWorkerLike {
if (this.worker) return this.worker;
const target = this.createWorker();
target.once('error', () => { void this.retire(target); });
target.once('exit', () => { this.markExited(target); });
this.worker = target;
return target;
}
async request<T>(value: WorkerRequestInput, signal: AbortSignal): Promise<T> {
await this.terminationBarrier;
throwIfAborted(signal);
const target = this.getWorker();
return requestMacosWorker<T>(target, { ...value, id: ++this.requestId } as WorkerRequest, signal, {
terminate: () => this.retire(target),
exited: () => this.markExited(target),
});
}
async ready(signal: AbortSignal): Promise<void> {
await this.terminationBarrier;
throwIfAborted(signal);
}
clear(): void {
if (this.worker) void this.retire(this.worker);
}
}
async function runCoreWlanWorker(): Promise<void> {
if (!parentPort) return;
const koffiModule = await import('koffi');
const koffi = (koffiModule as typeof koffiModule & { default: typeof koffiModule }).default;
const objc = koffi.load('/usr/lib/libobjc.A.dylib');
koffi.load('/System/Library/Frameworks/Foundation.framework/Foundation', { global: true });
koffi.load('/System/Library/Frameworks/CoreWLAN.framework/CoreWLAN', { global: true });
const objcGetClass = objc.func('objc_getClass', 'void *', ['str']);
const selRegisterName = objc.func('sel_registerName', 'void *', ['str']);
const sendId = objc.func('objc_msgSend', 'void *', ['void *', 'void *']);
const sendBool = objc.func('objc_msgSend', 'bool', ['void *', 'void *']);
const sendLong = objc.func('objc_msgSend', 'long', ['void *', 'void *']);
const sendVoid = objc.func('objc_msgSend', 'void', ['void *', 'void *']);
const sendObjectAtIndex = objc.func('objc_msgSend', 'void *', ['void *', 'void *', 'size_t']);
const sendSupportsSecurity = objc.func('objc_msgSend', 'bool', ['void *', 'void *', 'long']);
const sendObjectError = objc.func('objc_msgSend', 'void *', ['void *', 'void *', 'void *', koffi.out(koffi.pointer('void *'))]);
const sendAssociate = objc.func('objc_msgSend', 'bool', ['void *', 'void *', 'void *', 'void *', koffi.out(koffi.pointer('void *'))]);
const sendUtf8 = objc.func('objc_msgSend', 'str', ['void *', 'void *']);
const selector = (name: string): ObjcId => selRegisterName(name);
const cls = (name: string): ObjcId => objcGetClass(name);
const selectors = {
alloc: selector('alloc'), init: selector('init'), drain: selector('drain'),
sharedWiFiClient: selector('sharedWiFiClient'), interface: selector('interface'),
powerOn: selector('powerOn'), serviceActive: selector('serviceActive'),
scan: selector('scanForNetworksWithName:error:'), associate: selector('associateToNetwork:password:error:'),
ssid: selector('ssid'), rssiValue: selector('rssiValue'), supportsSecurity: selector('supportsSecurity:'),
allObjects: selector('allObjects'), count: selector('count'), objectAtIndex: selector('objectAtIndex:'),
UTF8String: selector('UTF8String'), code: selector('code'),
};
const pollSleep = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
function withPool<T>(body: () => T): T {
const pool = sendId(sendId(cls('NSAutoreleasePool'), selectors.alloc), selectors.init);
try { return body(); }
finally { if (pool) sendVoid(pool, selectors.drain); }
}
function fail(error: ObjcId, operation: 'scan' | 'connect'): never {
const code = error ? Number(sendLong(error, selectors.code)) : null;
if (code === CORE_WLAN_PERMISSION_DENIED) throw new RobotHotspotAdapterError('permission_denied');
if (code === CORE_WLAN_UNSUPPORTED) throw new RobotHotspotAdapterError('unsupported');
throw new RobotHotspotAdapterError(operation === 'scan' ? 'scan_failed' : 'connect_failed');
}
function stringValue(value: ObjcId): string | null {
if (!value) return null;
const result = sendUtf8(value, selectors.UTF8String);
return typeof result === 'string' ? result : null;
}
function wifiInterface(operation: 'scan' | 'connect'): ObjcId {
const client = sendId(cls('CWWiFiClient'), selectors.sharedWiFiClient);
const wifi = client && sendId(client, selectors.interface);
if (!wifi) throw new RobotHotspotAdapterError('unsupported');
if (!sendBool(wifi, selectors.powerOn) || !sendBool(wifi, selectors.serviceActive)) {
throw new RobotHotspotAdapterError(operation === 'scan' ? 'scan_failed' : 'connect_failed');
}
return wifi;
}
function scanNetworks(operation: 'scan' | 'connect'): ObjcId[] {
const wifi = wifiInterface(operation);
const error = [null] as unknown[];
const set = sendObjectError(wifi, selectors.scan, null, error);
if (!set) fail(error[0], operation);
const array = sendId(set, selectors.allObjects);
const count = Math.min(Number(sendLong(array, selectors.count)), 512);
return Array.from({ length: count }, (_, index) => sendObjectAtIndex(array, selectors.objectAtIndex, index));
}
function scan(): WorkerCandidate[] {
return withPool(() => {
const wifi = wifiInterface('scan');
const current = stringValue(sendId(wifi, selectors.ssid));
return scanNetworks('scan').flatMap((network): WorkerCandidate[] => {
if (!sendSupportsSecurity(network, selectors.supportsSecurity, 0)) return [];
const ssid = stringValue(sendId(network, selectors.ssid));
if (!ssid) return [];
const rssi = Number(sendLong(network, selectors.rssiValue));
return [{ ssid, signalPercent: Math.max(0, Math.min(100, Math.round((rssi + 100) * 2))), connected: current === ssid, open: true }];
});
});
}
function connect(ssid: string): { ssid: string } {
return withPool(() => {
const wifi = wifiInterface('connect');
const network = scanNetworks('connect').find((item) => stringValue(sendId(item, selectors.ssid)) === ssid && sendSupportsSecurity(item, selectors.supportsSecurity, 0));
if (!network) throw new RobotHotspotAdapterError('connect_failed');
const error = [null] as unknown[];
if (!sendAssociate(wifi, selectors.associate, network, null, error)) fail(error[0], 'connect');
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
if (stringValue(sendId(wifi, selectors.ssid)) === ssid) return { ssid };
Atomics.wait(pollSleep, 0, 0, 150);
}
throw new RobotHotspotAdapterError('connect_failed');
});
}
parentPort.on('message', (request: WorkerRequest) => {
try {
const result = request.operation === 'scan' ? scan() : connect(request.ssid);
parentPort.postMessage({ id: request.id, ok: true, result } satisfies WorkerResponse);
} catch (error) {
const reason = error instanceof RobotHotspotAdapterError ? error.reason : request.operation === 'scan' ? 'scan_failed' : 'connect_failed';
parentPort.postMessage({ id: request.id, ok: false, reason } satisfies WorkerResponse);
}
});
}
if (!isMainThread && (workerData as { kind?: string } | null)?.kind === WORKER_KIND) {
void runCoreWlanWorker().catch(() => { process.exitCode = 1; });
}
export async function createMacosRobotHotspotAdapter(): Promise<RobotHotspotAdapter> {
if (process.platform !== 'darwin') throw new RobotHotspotAdapterError('unsupported');
const koffiModule = await import('koffi');
const koffi = (koffiModule as typeof koffiModule & { default: typeof koffiModule }).default;
const objc = koffi.load('/usr/lib/libobjc.A.dylib');
koffi.load('/System/Library/Frameworks/Foundation.framework/Foundation', { global: true });
koffi.load('/System/Library/Frameworks/CoreLocation.framework/CoreLocation', { global: true });
const objcGetClass = objc.func('objc_getClass', 'void *', ['str']);
const selRegisterName = objc.func('sel_registerName', 'void *', ['str']);
const sendId = objc.func('objc_msgSend', 'void *', ['void *', 'void *']);
const sendLong = objc.func('objc_msgSend', 'long', ['void *', 'void *']);
const sendVoid = objc.func('objc_msgSend', 'void', ['void *', 'void *']);
const selector = (name: string): ObjcId => selRegisterName(name);
const cls = (name: string): ObjcId => objcGetClass(name);
const selectors = {
alloc: selector('alloc'), init: selector('init'), release: selector('release'),
authorizationStatus: selector('authorizationStatus'),
requestWhenInUseAuthorization: selector('requestWhenInUseAuthorization'),
};
let verifiedSsid: string | null = null;
const workerController = new MacosWorkerController(() => new Worker(__filename, { workerData: { kind: WORKER_KIND } }));
async function ensureLocationAuthorization(signal: AbortSignal): Promise<void> {
const manager = sendId(sendId(cls('CLLocationManager'), selectors.alloc), selectors.init);
if (!manager) throw new RobotHotspotAdapterError('permission_denied');
try {
let status = Number(sendLong(manager, selectors.authorizationStatus));
if (status === LOCATION_NOT_DETERMINED) {
sendVoid(manager, selectors.requestWhenInUseAuthorization);
const deadline = Date.now() + 8_000;
while (status === LOCATION_NOT_DETERMINED && Date.now() < deadline) {
await abortableDelay(100, signal);
status = Number(sendLong(manager, selectors.authorizationStatus));
}
}
if (status === LOCATION_RESTRICTED || status === LOCATION_DENIED || status === LOCATION_NOT_DETERMINED) {
throw new RobotHotspotAdapterError('permission_denied');
}
if (status !== LOCATION_AUTHORIZED_ALWAYS && status !== LOCATION_AUTHORIZED_WHEN_IN_USE) {
throw new RobotHotspotAdapterError('permission_denied');
}
} finally { sendVoid(manager, selectors.release); }
}
return {
async scan(signal) {
await workerController.ready(signal);
await ensureLocationAuthorization(signal);
throwIfAborted(signal);
verifiedSsid = null;
const candidates = await workerController.request<WorkerCandidate[]>({ operation: 'scan' }, signal);
return candidates.map((candidate): RobotHotspotAdapterCandidate => ({ ...candidate, nativeKey: candidate.ssid }));
},
async connect(candidate, signal) {
await workerController.ready(signal);
await ensureLocationAuthorization(signal);
throwIfAborted(signal);
const result = await workerController.request<{ ssid: string }>({ operation: 'connect', ssid: candidate.ssid }, signal);
verifiedSsid = result.ssid;
},
async currentSsid(signal) {
throwIfAborted(signal);
return verifiedSsid;
},
clear() {
verifiedSsid = null;
workerController.clear();
},
};
}

View File

@@ -0,0 +1,203 @@
import type { RobotHotspotAdapter, RobotHotspotAdapterCandidate } from './adapter';
import { RobotHotspotAdapterError, abortableDelay, throwIfAborted } from './adapter';
const ERROR_SUCCESS = 0;
const ERROR_ACCESS_DENIED = 5;
const WLAN_CONNECTION_MODE_DISCOVERY_UNSECURE = 3;
const DOT11_BSS_TYPE_INFRASTRUCTURE = 1;
const WLAN_INTERFACE_STATE_CONNECTED = 1;
const WLAN_INTF_OPCODE_CURRENT_CONNECTION = 7;
interface WindowsNativeKey {
interfaceGuid: Record<string, unknown>;
ssid: { uSSIDLength: number; ucSSID: Uint8Array | number[] };
}
function nativeFailure(result: number, operation: 'scan' | 'connect'): never {
if (result === ERROR_ACCESS_DENIED) throw new RobotHotspotAdapterError('permission_denied');
throw new RobotHotspotAdapterError(operation === 'scan' ? 'scan_failed' : 'connect_failed');
}
function bytesToSsid(value: { uSSIDLength: number; ucSSID: Uint8Array | number[] }): string {
return Buffer.from(value.ucSSID).subarray(0, value.uSSIDLength).toString('utf8');
}
function guidKey(value: Record<string, unknown>): string {
return JSON.stringify(value);
}
export async function createWindowsRobotHotspotAdapter(): Promise<RobotHotspotAdapter> {
if (process.platform !== 'win32') throw new RobotHotspotAdapterError('scan_failed');
const koffiModule = await import('koffi');
const koffi = (koffiModule as typeof koffiModule & { default: typeof koffiModule }).default;
const wlan = koffi.load('wlanapi.dll');
const GUID = koffi.struct({
Data1: 'uint32_t', Data2: 'uint16_t', Data3: 'uint16_t', Data4: koffi.array('uint8_t', 8),
});
const DOT11_SSID = koffi.struct({ uSSIDLength: 'uint32_t', ucSSID: koffi.array('uint8_t', 32) });
const WLAN_INTERFACE_INFO = koffi.struct({
InterfaceGuid: GUID,
strInterfaceDescription: koffi.array('char16_t', 256, 'String'),
isState: 'int32_t',
});
const WLAN_AVAILABLE_NETWORK = koffi.struct({
strProfileName: koffi.array('char16_t', 256, 'String'),
dot11Ssid: DOT11_SSID,
dot11BssType: 'int32_t',
uNumberOfBssids: 'uint32_t',
bNetworkConnectable: 'int32_t',
wlanNotConnectableReason: 'uint32_t',
uNumberOfPhyTypes: 'uint32_t',
dot11PhyTypes: koffi.array('int32_t', 8),
bMorePhyTypes: 'int32_t',
wlanSignalQuality: 'uint32_t',
bSecurityEnabled: 'int32_t',
dot11DefaultAuthAlgorithm: 'uint32_t',
dot11DefaultCipherAlgorithm: 'uint32_t',
dwFlags: 'uint32_t',
dwReserved: 'uint32_t',
});
const WLAN_ASSOCIATION_ATTRIBUTES = koffi.struct({
dot11Ssid: DOT11_SSID,
dot11BssType: 'int32_t',
dot11Bssid: koffi.array('uint8_t', 6),
dot11PhyType: 'int32_t',
uDot11PhyIndex: 'uint32_t',
wlanSignalQuality: 'uint32_t',
ulRxRate: 'uint32_t',
ulTxRate: 'uint32_t',
});
const WLAN_SECURITY_ATTRIBUTES = koffi.struct({
bSecurityEnabled: 'int32_t', bOneXEnabled: 'int32_t',
dot11AuthAlgorithm: 'uint32_t', dot11CipherAlgorithm: 'uint32_t',
});
const WLAN_CONNECTION_ATTRIBUTES = koffi.struct({
isState: 'int32_t', wlanConnectionMode: 'int32_t',
strProfileName: koffi.array('char16_t', 256, 'String'),
wlanAssociationAttributes: WLAN_ASSOCIATION_ATTRIBUTES,
wlanSecurityAttributes: WLAN_SECURITY_ATTRIBUTES,
});
const WLAN_CONNECTION_PARAMETERS = koffi.struct({
wlanConnectionMode: 'int32_t', strProfile: 'str16',
pDot11Ssid: koffi.pointer(DOT11_SSID), pDesiredBssidList: 'void *',
dot11BssType: 'int32_t', dwFlags: 'uint32_t',
});
const WlanOpenHandle = wlan.func('WlanOpenHandle', 'uint32_t', ['uint32_t', 'void *', koffi.out(koffi.pointer('uint32_t')), koffi.out(koffi.pointer('void *'))]);
const WlanCloseHandle = wlan.func('WlanCloseHandle', 'uint32_t', ['void *', 'void *']);
const WlanEnumInterfaces = wlan.func('WlanEnumInterfaces', 'uint32_t', ['void *', 'void *', koffi.out(koffi.pointer('void *'))]);
const WlanScan = wlan.func('WlanScan', 'uint32_t', ['void *', koffi.pointer(GUID), 'void *', 'void *', 'void *']);
const WlanGetAvailableNetworkList = wlan.func('WlanGetAvailableNetworkList', 'uint32_t', ['void *', koffi.pointer(GUID), 'uint32_t', 'void *', koffi.out(koffi.pointer('void *'))]);
const WlanConnect = wlan.func('WlanConnect', 'uint32_t', ['void *', koffi.pointer(GUID), koffi.pointer(WLAN_CONNECTION_PARAMETERS), 'void *']);
const WlanQueryInterface = wlan.func('WlanQueryInterface', 'uint32_t', ['void *', koffi.pointer(GUID), 'int32_t', 'void *', koffi.out(koffi.pointer('uint32_t')), koffi.out(koffi.pointer('void *')), koffi.out(koffi.pointer('int32_t'))]);
const WlanFreeMemory = wlan.func('WlanFreeMemory', 'void', ['void *']);
let connectingInterfaceGuid: Record<string, unknown> | null = null;
async function withHandle<T>(operation: 'scan' | 'connect', body: (handle: unknown) => Promise<T>): Promise<T> {
const negotiated = [0];
const handle = [null] as unknown[];
const result = WlanOpenHandle(2, null, negotiated, handle) as number;
if (result !== ERROR_SUCCESS || !handle[0]) nativeFailure(result, operation);
try { return await body(handle[0]); }
finally { WlanCloseHandle(handle[0], null); }
}
function interfaces(handle: unknown, operation: 'scan' | 'connect'): Array<Record<string, unknown>> {
const output = [null] as unknown[];
const result = WlanEnumInterfaces(handle, null, output) as number;
if (result !== ERROR_SUCCESS || !output[0]) nativeFailure(result, operation);
try {
const count = koffi.decode(output[0], 'uint32_t') as number;
const offset = 8;
return Array.from({ length: Math.min(count, 64) }, (_, index) => koffi.decode(output[0], offset + index * koffi.sizeof(WLAN_INTERFACE_INFO), WLAN_INTERFACE_INFO) as Record<string, unknown>);
} finally { WlanFreeMemory(output[0]); }
}
function currentOnInterface(handle: unknown, interfaceGuid: Record<string, unknown>, operation: 'scan' | 'connect'): string | null {
const size = [0];
const data = [null] as unknown[];
const valueType = [0];
const result = WlanQueryInterface(handle, interfaceGuid, WLAN_INTF_OPCODE_CURRENT_CONNECTION, null, size, data, valueType) as number;
if (result !== ERROR_SUCCESS || !data[0]) {
if (result === ERROR_ACCESS_DENIED) nativeFailure(result, operation);
return null;
}
try {
const connection = koffi.decode(data[0], WLAN_CONNECTION_ATTRIBUTES) as { isState: number; wlanAssociationAttributes: { dot11Ssid: WindowsNativeKey['ssid'] } };
return connection.isState === WLAN_INTERFACE_STATE_CONNECTED ? bytesToSsid(connection.wlanAssociationAttributes.dot11Ssid) : null;
} finally { WlanFreeMemory(data[0]); }
}
return {
async scan(signal) {
return withHandle('scan', async (handle) => {
const foundInterfaces = interfaces(handle, 'scan');
for (const item of foundInterfaces) {
throwIfAborted(signal);
const result = WlanScan(handle, item.InterfaceGuid, null, null, null) as number;
if (result !== ERROR_SUCCESS) nativeFailure(result, 'scan');
}
// WlanScan is asynchronous and usually needs about four seconds before the
// available-network cache represents the requested scan.
await abortableDelay(4_000, signal);
const candidates: RobotHotspotAdapterCandidate[] = [];
for (const item of foundInterfaces) {
throwIfAborted(signal);
const output = [null] as unknown[];
const result = WlanGetAvailableNetworkList(handle, item.InterfaceGuid, 0, null, output) as number;
if (result !== ERROR_SUCCESS || !output[0]) nativeFailure(result, 'scan');
try {
const count = koffi.decode(output[0], 'uint32_t') as number;
const connectedSsid = currentOnInterface(handle, item.InterfaceGuid as Record<string, unknown>, 'scan');
for (let index = 0; index < Math.min(count, 512); index += 1) {
const network = koffi.decode(output[0], 8 + index * koffi.sizeof(WLAN_AVAILABLE_NETWORK), WLAN_AVAILABLE_NETWORK) as {
dot11Ssid: WindowsNativeKey['ssid']; dot11BssType: number; wlanSignalQuality: number; bSecurityEnabled: number; bNetworkConnectable: number;
};
if (network.dot11BssType !== DOT11_BSS_TYPE_INFRASTRUCTURE || network.bSecurityEnabled || !network.bNetworkConnectable) continue;
const ssid = bytesToSsid(network.dot11Ssid);
candidates.push({
ssid, signalPercent: network.wlanSignalQuality, connected: connectedSsid === ssid, open: true,
nativeKey: { interfaceGuid: item.InterfaceGuid, ssid: network.dot11Ssid } satisfies WindowsNativeKey,
});
}
} finally { WlanFreeMemory(output[0]); }
}
return candidates;
});
},
async connect(candidate, signal) {
throwIfAborted(signal);
const key = candidate.nativeKey as WindowsNativeKey;
await withHandle('connect', async (handle) => {
const match = interfaces(handle, 'connect').find((item) => guidKey(item.InterfaceGuid as Record<string, unknown>) === guidKey(key.interfaceGuid));
if (!match) throw new RobotHotspotAdapterError('connect_failed');
const parameters = {
wlanConnectionMode: WLAN_CONNECTION_MODE_DISCOVERY_UNSECURE,
strProfile: null,
pDot11Ssid: key.ssid,
pDesiredBssidList: null,
dot11BssType: DOT11_BSS_TYPE_INFRASTRUCTURE,
dwFlags: 0,
};
const result = WlanConnect(handle, match.InterfaceGuid, parameters, null) as number;
if (result !== ERROR_SUCCESS) nativeFailure(result, 'connect');
connectingInterfaceGuid = match.InterfaceGuid as Record<string, unknown>;
});
},
async currentSsid(signal) {
throwIfAborted(signal);
return withHandle('connect', async (handle) => {
const foundInterfaces = interfaces(handle, 'connect');
const orderedInterfaces = connectingInterfaceGuid
? [...foundInterfaces].sort((left, right) => Number(guidKey(right.InterfaceGuid as Record<string, unknown>) === guidKey(connectingInterfaceGuid as Record<string, unknown>)) - Number(guidKey(left.InterfaceGuid as Record<string, unknown>) === guidKey(connectingInterfaceGuid as Record<string, unknown>)))
: foundInterfaces;
for (const item of orderedInterfaces) {
const ssid = currentOnInterface(handle, item.InterfaceGuid as Record<string, unknown>, 'connect');
if (ssid) return ssid;
}
return null;
});
},
clear() { connectingInterfaceGuid = null; },
};
}