367 lines
16 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|