Skip to content

Commit

Permalink
implement windows using powershell
Browse files Browse the repository at this point in the history
Oh boy
  • Loading branch information
sfoster1 committed Feb 14, 2024
1 parent 4b5a685 commit 749bd52
Show file tree
Hide file tree
Showing 3 changed files with 158 additions and 55 deletions.
20 changes: 16 additions & 4 deletions app-shell/src/system-info/__tests__/usb-devices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ const usbOn = usb.on as jest.MockedFunction<typeof usb.on>

const execaCommand = execa.command as jest.MockedFunction<typeof execa.command>

const mockFixtureDevice = {
...Fixtures.mockUsbDevice,
identifier: 'ec2c23ab245e0424059c3ad99e626cdb',
}

const mockDescriptor = {
busNumber: 3,
deviceAddress: 10,
deviceDescriptor: {
idVendor: Fixtures.mockUsbDevice.vendorId,
idProduct: Fixtures.mockUsbDevice.productId,
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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) {
Expand Down
192 changes: 141 additions & 51 deletions app-shell/src/system-info/usb-devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Check warning on line 25 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L25

Added line #L25 was not covered by tests
const idProduct = (device: usb.Device): string =>
decToHex(device.deviceDescriptor.idProduct)

Check warning on line 27 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L27

Added line #L27 was not covered by tests

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 = (
Expand All @@ -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 = <T, U>(
promise: Promise<T>,
defaulter: (err: any) => U
Expand All @@ -70,7 +72,100 @@ const orDefault = <T, U>(
})
)

function upstreamDeviceFromUsbDevice(device: usb.Device): Promise<UsbDevice> {
const doUpstreamDeviceFromUsbDevice = (
device: usb.Device
): Promise<UsbDevice[]> =>
isWindows()
? upstreamDeviceFromUsbDeviceWinAPI(device)
: upstreamDeviceFromUsbDeviceLibUSB(device)

function upstreamDeviceFromUsbDevice(device: usb.Device): Promise<UsbDevice[]> {
return doUpstreamDeviceFromUsbDevice(device).catch(err => {
log.error(

Check warning on line 84 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L84

Added line #L84 was not covered by tests
`Failed to get device information for vid=${idVendor(
device
)} pid=${idProduct(device)}: ${err}: friendly names unavailable`
)
return [descriptorToDevice(device)]

Check warning on line 89 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L89

Added line #L89 was not covered by tests
})
}

interface WmiObject {
Present: boolean
Manufacturer: string
Name: string
DeviceID: string
}

function upstreamDeviceFromUsbDeviceWinAPI(
device: usb.Device
): Promise<UsbDevice[]> {
// 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

Check warning on line 118 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L118

Added line #L118 was not covered by tests
.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 = (

Check warning on line 133 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L133

Added line #L133 was not covered by tests
dump: string
): WmiObject[] => {
if (dump[0] === '[') {
return JSON.parse(dump) as WmiObject[]

Check warning on line 137 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L137

Added line #L137 was not covered by tests
} else {
return [JSON.parse(dump) as WmiObject]

Check warning on line 139 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L139

Added line #L139 was not covered by tests
}
}
if (dump.stderr !== '') {
return Promise.reject(new Error(`Command failed: ${dump.stderr}`))

Check warning on line 143 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L143

Added line #L143 was not covered by tests
}
const getObjsWithCorrectPresence = (wmiDump: WmiObject[]): WmiObject[] =>
wmiDump.filter(obj => obj.Present)

Check warning on line 146 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L145-L146

Added lines #L145 - L146 were not covered by tests

const objsToQuery = getObjsWithCorrectPresence(

Check warning on line 148 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L148

Added line #L148 was not covered by tests
parsePoshJsonOutputToWmiObjectArray(dump.stdout.trim())
)
return objsToQuery.map(wmiObj =>
descriptorToDevice(

Check warning on line 152 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L151-L152

Added lines #L151 - L152 were not covered by tests
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<UsbDevice[]> {
return new Promise<usb.Device>((resolve, reject) => {
try {
device.open(false)
Expand Down Expand Up @@ -128,18 +223,11 @@ function upstreamDeviceFromUsbDevice(device: usb.Device): Promise<UsbDevice> {
])
)
.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) {
Expand All @@ -149,12 +237,6 @@ function upstreamDeviceFromUsbDevice(device: usb.Device): Promise<UsbDevice> {
)} in err handler: ${err}`
)
}
log.error(
`Could not fetch descriptors for vid=${idVendor(
device
)}, pid=${idProduct(device)}: ${err}, string descriptors unavailable`
)
return descriptorToDevice(device)
})
}

Expand All @@ -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<usb.Device[]>((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')
Expand All @@ -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<string | null> {
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

Check warning on line 300 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L300

Added line #L300 was not covered by tests
}
const [vid, pid] = [decToHex(vidDecimal), decToHex(pidDecimal)]

// USBDevice serialNumber is string | undefined
if (serialNumber == null) {
return Promise.resolve(null)
return null

Check warning on line 306 in app-shell/src/system-info/usb-devices.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/system-info/usb-devices.ts#L306

Added line #L306 was not covered by tests
}
return `USB\\VID_${vid}&PID_${pid}\\${serialNumber}`
}

export function getWindowsDriverVersion(
device: UsbDevice
): Promise<string | null> {
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())
Expand Down
1 change: 1 addition & 0 deletions app/src/redux/system-info/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface UsbDevice {
manufacturerName?: string
serialNumber?: string
windowsDriverVersion?: string | null
systemIdentifier?: string
}

// based on built-in type os$NetIFAddr
Expand Down

0 comments on commit 749bd52

Please sign in to comment.