Files

194 lines
7.1 KiB
TypeScript

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;