feat(robot): connect provisioning hotspots in app
This commit is contained in:
1 parent
abecd5f344
commit
c1326a2980
17 files changed
+1950
-11
No files matched your search
@@ -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