194 lines
7.1 KiB
TypeScript
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;
|