feat(robot): connect provisioning hotspots in app
This commit is contained in:
1 parent
abecd5f344
commit
c1326a2980
17 files changed
+1950
-11
No files matched your search
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user