204 lines
10 KiB
TypeScript
204 lines
10 KiB
TypeScript
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; },
|
|
};
|
|
}
|