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; connect(candidateId: string): Promise; 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(); let expiresAt = 0; let active = false; function clear(): void { snapshot = new Map(); expiresAt = 0; adapter.clear(); } async function runExclusive(timeoutMs: number, timeoutCode: 'scan_failed' | 'connect_failed', work: (signal: AbortSignal) => Promise): Promise { if (active) throw new RobotHotspotError('busy'); active = true; const controller = new AbortController(); let timer: ReturnType | undefined; const operation = work(controller.signal).finally(() => { if (timer) clearTimeout(timer); active = false; }); const timeout = new Promise((_, 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(); 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(); 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 | undefined; async function loadDefaultModule(): Promise { 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;