Files

367 lines
16 KiB
TypeScript

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;
const SCAN_RETRY_DELAY_MS = 250;
type StableReason = 'unsupported' | 'permission_denied' | 'scan_failed' | 'connect_failed';
type ObjcId = unknown;
interface WorkerCandidate {
ssid: string;
signalPercent: number;
connected: boolean;
open: boolean;
}
export interface MacosScanNetwork {
ssid: string | null;
rssi: number;
open: boolean;
}
export function scanMacosCandidatesWithRetry(
readNetworks: () => MacosScanNetwork[],
currentSsid: string | null,
waitBeforeRetry: () => void,
): WorkerCandidate[] {
let networks = readNetworks();
if (networks.length === 0 || networks.every((network) => !network.ssid)) {
waitBeforeRetry();
networks = readNetworks();
}
if (networks.length > 0 && networks.every((network) => !network.ssid)) {
throw new RobotHotspotAdapterError('permission_denied');
}
return networks.flatMap((network): WorkerCandidate[] => {
if (!network.open || !network.ssid) return [];
return [{
ssid: network.ssid,
signalPercent: Math.max(0, Math.min(100, Math.round((network.rssi + 100) * 2))),
connected: currentSsid === network.ssid,
open: true,
}];
});
}
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));
const readNetworks = (): MacosScanNetwork[] => scanNetworks('scan').map((network) => ({
ssid: stringValue(sendId(network, selectors.ssid)),
rssi: Number(sendLong(network, selectors.rssiValue)),
open: sendSupportsSecurity(network, selectors.supportsSecurity, 0),
}));
return scanMacosCandidatesWithRetry(
readNetworks,
current,
() => { Atomics.wait(pollSleep, 0, 0, SCAN_RETRY_DELAY_MS); },
);
});
}
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();
},
};
}