feat(robot): connect provisioning hotspots in app
This commit is contained in:
@@ -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;
|
||||
|
||||
36
electron/robot-hotspot/adapter.ts
Normal file
36
electron/robot-hotspot/adapter.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
193
electron/robot-hotspot/index.ts
Normal file
193
electron/robot-hotspot/index.ts
Normal 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;
|
||||
332
electron/robot-hotspot/macos.ts
Normal file
332
electron/robot-hotspot/macos.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
203
electron/robot-hotspot/windows.ts
Normal file
203
electron/robot-hotspot/windows.ts
Normal 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; },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user