diff --git a/app-shell/src/system-info/__tests__/usb-devices.test.ts b/app-shell/src/system-info/__tests__/usb-devices.test.ts index eb22440b7c8..101da496671 100644 --- a/app-shell/src/system-info/__tests__/usb-devices.test.ts +++ b/app-shell/src/system-info/__tests__/usb-devices.test.ts @@ -25,7 +25,14 @@ const usbOn = usb.on as jest.MockedFunction const execaCommand = execa.command as jest.MockedFunction +const mockFixtureDevice = { + ...Fixtures.mockUsbDevice, + identifier: 'ec2c23ab245e0424059c3ad99e626cdb', +} + const mockDescriptor = { + busNumber: 3, + deviceAddress: 10, deviceDescriptor: { idVendor: Fixtures.mockUsbDevice.vendorId, idProduct: Fixtures.mockUsbDevice.productId, @@ -94,19 +101,19 @@ describe('app-shell::system-info::usb-devices', () => { expect(devices).toEqual([ { - ...Fixtures.mockUsbDevice, + ...mockFixtureDevice, manufacturerName: 'mfr1', serialNumber: 'sn1', productName: 'pr1', }, { - ...Fixtures.mockUsbDevice, + ...mockFixtureDevice, manufacturerName: 'mfr2', serialNumber: 'sn2', productName: 'pr2', }, { - ...Fixtures.mockUsbDevice, + ...mockFixtureDevice, manufacturerName: 'mfr3', serialNumber: 'sn3', productName: 'pr3', @@ -120,7 +127,7 @@ describe('app-shell::system-info::usb-devices', () => { onDeviceAdd.mockImplementation(device => { try { expect(device).toEqual({ - ...Fixtures.mockUsbDevice, + ...mockFixtureDevice, manufacturerName: 'mfr1', serialNumber: 'sn1', productName: 'pn1', @@ -157,6 +164,11 @@ describe('app-shell::system-info::usb-devices', () => { expect(device).toEqual({ vendorId: mockDevice.vendorId, productId: mockDevice.productId, + identifier: 'ec2c23ab245e0424059c3ad99e626cdb', + manufacturerName: undefined, + productName: undefined, + serialNumber: undefined, + systemIdentifier: undefined, }) resolve() } catch (error) { diff --git a/app-shell/src/system-info/usb-devices.ts b/app-shell/src/system-info/usb-devices.ts index 7fd3ff11a7c..568e39a4497 100644 --- a/app-shell/src/system-info/usb-devices.ts +++ b/app-shell/src/system-info/usb-devices.ts @@ -19,23 +19,30 @@ export interface UsbDeviceMonitor { const log = createLogger('usb-devices') +const decToHex = (number: number): string => + number.toString(16).toUpperCase().padStart(4, '0') +const idVendor = (device: usb.Device): string => + decToHex(device.deviceDescriptor.idVendor) +const idProduct = (device: usb.Device): string => + decToHex(device.deviceDescriptor.idProduct) + const descriptorToDevice = ( descriptors: usb.Device, manufacturerName?: string, serialNumber?: string, - productName?: string + productName?: string, + systemIdentifier?: string ): UsbDevice => ({ vendorId: descriptors.deviceDescriptor.idVendor, productId: descriptors.deviceDescriptor.idProduct, - // yes this arbitrary string could be something other than an hmac but then - // it might be different lengths. it was this or install leftPad, I tell you identifier: createHmac('md5', '') - .update(descriptors.busNumber.toString(16)) - .update(descriptors.deviceAddress.toString(16)) + .update(decToHex(descriptors.busNumber)) + .update(decToHex(descriptors.deviceAddress)) .digest('hex'), serialNumber, manufacturerName, productName, + systemIdentifier, }) const getStringDescriptorPromise = ( @@ -52,11 +59,6 @@ const getStringDescriptorPromise = ( }) }) -const idVendor = (device: usb.Device): string => - device.deviceDescriptor.idVendor.toString(16) -const idProduct = (device: usb.Device): string => - device.deviceDescriptor.idProduct.toString(16) - const orDefault = ( promise: Promise, defaulter: (err: any) => U @@ -70,7 +72,100 @@ const orDefault = ( }) ) -function upstreamDeviceFromUsbDevice(device: usb.Device): Promise { +const doUpstreamDeviceFromUsbDevice = ( + device: usb.Device +): Promise => + isWindows() + ? upstreamDeviceFromUsbDeviceWinAPI(device) + : upstreamDeviceFromUsbDeviceLibUSB(device) + +function upstreamDeviceFromUsbDevice(device: usb.Device): Promise { + return doUpstreamDeviceFromUsbDevice(device).catch(err => { + log.error( + `Failed to get device information for vid=${idVendor( + device + )} pid=${idProduct(device)}: ${err}: friendly names unavailable` + ) + return [descriptorToDevice(device)] + }) +} + +interface WmiObject { + Present: boolean + Manufacturer: string + Name: string + DeviceID: string +} + +function upstreamDeviceFromUsbDeviceWinAPI( + device: usb.Device +): Promise { + // Here begins an annotated series of interesting powershell interactions! + // We don't know the device ID of the device. For USB devices it's typically composed of + // the VID, the PID, and the serial, and we don't know the serial. (Also if there's two devices + // with the same vid+pid+serial, as with devices that hardcode serial to 1, then you get some + // random something-or-other in there so even if we had the serial we couldn't rely on it.) + + // We also essentially have no way of linking this uniquely identifying information to that + // provided by libusb. Libusb provides usb-oriented identifiers like the bus address; windows + // provides identifiers about hubs and ports. + + // This is basically why we have everything returning lists of devices - this function needs + // to tell people that it found multiple devices and it doesn't know which is which. + + // We can get a json-formatted dump of information about all devices with the specified vid and + // pid + return execa + .command( + `Get-WmiObject Win32_PnpEntity -Filter "DeviceId like '%\\VID_${idVendor( + device + )}&PID_${idProduct( + device + )}%'" | Select-Object -Property * | ConvertTo-JSON -Compress`, + { shell: 'PowerShell.exe' } + ) + .then(dump => { + // powershell helpfully will dump a json object when there's exactly one result and a json + // array when there's more than one result. isn't that really cool? this is actually fixed + // in any at-all modern powershell version, where ConvertTo-JSON has a flag -AsArray that + // forces array output, but you absolutely cannot rely on anything past like powershell + // 5.1 being present + const parsePoshJsonOutputToWmiObjectArray = ( + dump: string + ): WmiObject[] => { + if (dump[0] === '[') { + return JSON.parse(dump) as WmiObject[] + } else { + return [JSON.parse(dump) as WmiObject] + } + } + if (dump.stderr !== '') { + return Promise.reject(new Error(`Command failed: ${dump.stderr}`)) + } + const getObjsWithCorrectPresence = (wmiDump: WmiObject[]): WmiObject[] => + wmiDump.filter(obj => obj.Present) + + const objsToQuery = getObjsWithCorrectPresence( + parsePoshJsonOutputToWmiObjectArray(dump.stdout.trim()) + ) + return objsToQuery.map(wmiObj => + descriptorToDevice( + device, + wmiObj.Manufacturer, + // the serial number, or something kind of like a serial number in the case of devices + // with duplicate serial numbers, is the third element of the device id which is formed + // by concatenating stuff with \\ as a separator (and of course each \ must be escaped) + wmiObj.DeviceID.match(/.*\\\\.*\\\\(.*)/)?.at(1) ?? undefined, + wmiObj.Name, + wmiObj.DeviceID + ) + ) + }) +} + +function upstreamDeviceFromUsbDeviceLibUSB( + device: usb.Device +): Promise { return new Promise((resolve, reject) => { try { device.open(false) @@ -128,18 +223,11 @@ function upstreamDeviceFromUsbDevice(device: usb.Device): Promise { ]) ) .then(([manufacturer, serialNumber, productName]) => { - try { - device.close() - } catch (err) { - log.warn( - `Failed to close vid=${idVendor(device)}, pid=${idProduct( - device - )}: ${err}` - ) - } - return descriptorToDevice(device, manufacturer, serialNumber, productName) + return [ + descriptorToDevice(device, manufacturer, serialNumber, productName), + ] }) - .catch(err => { + .finally(() => { try { device.close() } catch (err) { @@ -149,12 +237,6 @@ function upstreamDeviceFromUsbDevice(device: usb.Device): Promise { )} in err handler: ${err}` ) } - log.error( - `Could not fetch descriptors for vid=${idVendor( - device - )}, pid=${idProduct(device)}: ${err}, string descriptors unavailable` - ) - return descriptorToDevice(device) }) } @@ -173,29 +255,27 @@ export function createUsbDeviceMonitor( } if (typeof onDeviceAdd === 'function') { usb.on('attach', device => { - upstreamDeviceFromUsbDevice(device) - .then(onDeviceAdd) - .catch(err => { - log.error( - `Failed to format added device vid=${idVendor( - device - )} pid=${idProduct(device)} for upstream: ${err}` - ) - }) + upstreamDeviceFromUsbDevice(device).then(devices => + devices.forEach(onDeviceAdd) + ) }) } if (typeof onDeviceRemove === 'function') { - usb.on('detach', device => onDeviceRemove(descriptorToDevice(device))) + usb.on('detach', device => { + onDeviceRemove(descriptorToDevice(device)) + }) } return { getAllDevices: () => new Promise((resolve, reject) => { resolve(usb.getDeviceList()) - }).then(deviceList => - Promise.all(deviceList.map(upstreamDeviceFromUsbDevice)) - ), + }) + .then(deviceList => + Promise.all(deviceList.map(upstreamDeviceFromUsbDevice)) + ) + .then(upstreamDevices => upstreamDevices.flat()), stop: () => { if (typeof onDeviceAdd === 'function') { usb.removeAllListeners('attach') @@ -209,29 +289,39 @@ export function createUsbDeviceMonitor( } } -const decToHex = (number: number): string => - number.toString(16).toUpperCase().padStart(4, '0') - -export function getWindowsDriverVersion( - device: UsbDevice -): Promise { - console.log('getWindowsDriverVersion', device) - const { vendorId: vidDecimal, productId: pidDecimal, serialNumber } = device +const deviceIdFromDetails = (device: UsbDevice): string | null => { + const { + vendorId: vidDecimal, + productId: pidDecimal, + serialNumber, + systemIdentifier, + } = device + if (systemIdentifier !== undefined) { + return systemIdentifier + } const [vid, pid] = [decToHex(vidDecimal), decToHex(pidDecimal)] // USBDevice serialNumber is string | undefined if (serialNumber == null) { - return Promise.resolve(null) + return null } + return `USB\\VID_${vid}&PID_${pid}\\${serialNumber}` +} +export function getWindowsDriverVersion( + device: UsbDevice +): Promise { + console.log('getWindowsDriverVersion', device) assert( isWindows() || process.env.NODE_ENV === 'test', `getWindowsDriverVersion cannot be called on ${process.platform}` ) + const deviceId = deviceIdFromDetails(device) + return execa .command( - `Get-PnpDeviceProperty -InstanceID "USB\\VID_${vid}&PID_${pid}\\${serialNumber}" -KeyName "DEVPKEY_Device_DriverVersion" | % { $_.Data }`, + `Get-PnpDeviceProperty -InstanceID "${deviceId}" -KeyName "DEVPKEY_Device_DriverVersion" | % { $_.Data }`, { shell: 'PowerShell.exe' } ) .then(result => result.stdout.trim()) diff --git a/app/src/redux/system-info/types.ts b/app/src/redux/system-info/types.ts index 946012b7469..febce9230b9 100644 --- a/app/src/redux/system-info/types.ts +++ b/app/src/redux/system-info/types.ts @@ -19,6 +19,7 @@ export interface UsbDevice { manufacturerName?: string serialNumber?: string windowsDriverVersion?: string | null + systemIdentifier?: string } // based on built-in type os$NetIFAddr