'use strict'; // This polyfill library implements the WebUSB Test API as specified here: // https://wicg.github.io/webusb/test/ (() => { // These variables are logically members of the USBTest class but are defined // here to hide them from being visible as fields of navigator.usb.test. let internal = { intialized: false, webUsbService: null, webUsbServiceInterceptor: null, messagePort: null, }; let mojom = {}; async function loadMojomDefinitions() { const deviceMojom = await import('/gen/services/device/public/mojom/usb_device.mojom.m.js'); const serviceMojom = await import( '/gen/third_party/blink/public/mojom/usb/web_usb_service.mojom.m.js'); return { ...deviceMojom, ...serviceMojom, }; } function getMessagePort(target) { return new Promise(resolve => { target.addEventListener('message', messageEvent => { if (messageEvent.data.type === 'ReadyForAttachment') { if (internal.messagePort === null) { internal.messagePort = messageEvent.data.port; } resolve(); } }, {once: true}); }); } // Converts an ECMAScript String object to an instance of // mojo_base.mojom.String16. function mojoString16ToString(string16) { return String.fromCharCode.apply(null, string16.data); } // Converts an instance of mojo_base.mojom.String16 to an ECMAScript String. function stringToMojoString16(string) { let array = new Array(string.length); for (var i = 0; i < string.length; ++i) { array[i] = string.charCodeAt(i); } return { data: array } } function fakeDeviceInitToDeviceInfo(guid, init) { let deviceInfo = { guid: guid + "", usbVersionMajor: init.usbVersionMajor, usbVersionMinor: init.usbVersionMinor, usbVersionSubminor: init.usbVersionSubminor, classCode: init.deviceClass, subclassCode: init.deviceSubclass, protocolCode: init.deviceProtocol, vendorId: init.vendorId, productId: init.productId, deviceVersionMajor: init.deviceVersionMajor, deviceVersionMinor: init.deviceVersionMinor, deviceVersionSubminor: init.deviceVersionSubminor, manufacturerName: stringToMojoString16(init.manufacturerName), productName: stringToMojoString16(init.productName), serialNumber: stringToMojoString16(init.serialNumber), activeConfiguration: init.activeConfigurationValue, configurations: [] }; init.configurations.forEach(config => { var configInfo = { configurationValue: config.configurationValue, configurationName: stringToMojoString16(config.configurationName), selfPowered: false, remoteWakeup: false, maximumPower: 0, interfaces: [], extraData: new Uint8Array() }; config.interfaces.forEach(iface => { var interfaceInfo = { interfaceNumber: iface.interfaceNumber, alternates: [] }; iface.alternates.forEach(alternate => { var alternateInfo = { alternateSetting: alternate.alternateSetting, classCode: alternate.interfaceClass, subclassCode: alternate.interfaceSubclass, protocolCode: alternate.interfaceProtocol, interfaceName: stringToMojoString16(alternate.interfaceName), endpoints: [], extraData: new Uint8Array() }; alternate.endpoints.forEach(endpoint => { var endpointInfo = { endpointNumber: endpoint.endpointNumber, packetSize: endpoint.packetSize, synchronizationType: mojom.UsbSynchronizationType.NONE, usageType: mojom.UsbUsageType.DATA, pollingInterval: 0, extraData: new Uint8Array() }; switch (endpoint.direction) { case "in": endpointInfo.direction = mojom.UsbTransferDirection.INBOUND; break; case "out": endpointInfo.direction = mojom.UsbTransferDirection.OUTBOUND; break; } switch (endpoint.type) { case "bulk": endpointInfo.type = mojom.UsbTransferType.BULK; break; case "interrupt": endpointInfo.type = mojom.UsbTransferType.INTERRUPT; break; case "isochronous": endpointInfo.type = mojom.UsbTransferType.ISOCHRONOUS; break; } alternateInfo.endpoints.push(endpointInfo); }); interfaceInfo.alternates.push(alternateInfo); }); configInfo.interfaces.push(interfaceInfo); }); deviceInfo.configurations.push(configInfo); }); return deviceInfo; } function convertMojoDeviceFilters(input) { let output = []; input.forEach(filter => { output.push(convertMojoDeviceFilter(filter)); }); return output; } function convertMojoDeviceFilter(input) { let output = {}; if (input.hasVendorId) output.vendorId = input.vendorId; if (input.hasProductId) output.productId = input.productId; if (input.hasClassCode) output.classCode = input.classCode; if (input.hasSubclassCode) output.subclassCode = input.subclassCode; if (input.hasProtocolCode) output.protocolCode = input.protocolCode; if (input.serialNumber) output.serialNumber = mojoString16ToString(input.serialNumber); return output; } class FakeDevice { constructor(deviceInit) { this.info_ = deviceInit; this.opened_ = false; this.currentConfiguration_ = null; this.claimedInterfaces_ = new Map(); } getConfiguration() { if (this.currentConfiguration_) { return Promise.resolve({ value: this.currentConfiguration_.configurationValue }); } else { return Promise.resolve({ value: 0 }); } } open() { assert_false(this.opened_); this.opened_ = true; return Promise.resolve({error: mojom.UsbOpenDeviceError.OK}); } close() { assert_true(this.opened_); this.opened_ = false; return Promise.resolve(); } setConfiguration(value) { assert_true(this.opened_); let selectedConfiguration = this.info_.configurations.find( configuration => configuration.configurationValue == value); // Blink should never request an invalid configuration. assert_not_equals(selectedConfiguration, undefined); this.currentConfiguration_ = selectedConfiguration; return Promise.resolve({ success: true }); } async claimInterface(interfaceNumber) { assert_true(this.opened_); assert_false(this.currentConfiguration_ == null, 'device configured'); assert_false(this.claimedInterfaces_.has(interfaceNumber), 'interface already claimed'); const protectedInterfaces = new Set([ mojom.USB_AUDIO_CLASS, mojom.USB_HID_CLASS, mojom.USB_MASS_STORAGE_CLASS, mojom.USB_SMART_CARD_CLASS, mojom.USB_VIDEO_CLASS, mojom.USB_AUDIO_VIDEO_CLASS, mojom.USB_WIRELESS_CLASS, ]); let iface = this.currentConfiguration_.interfaces.find( iface => iface.interfaceNumber == interfaceNumber); // Blink should never request an invalid interface or alternate. assert_false(iface == undefined); if (iface.alternates.some( alt => protectedInterfaces.has(alt.interfaceClass))) { return {result: mojom.UsbClaimInterfaceResult.kProtectedClass}; } this.claimedInterfaces_.set(interfaceNumber, 0); return {result: mojom.UsbClaimInterfaceResult.kSuccess}; } releaseInterface(interfaceNumber) { assert_true(this.opened_); assert_false(this.currentConfiguration_ == null, 'device configured'); assert_true(this.claimedInterfaces_.has(interfaceNumber)); this.claimedInterfaces_.delete(interfaceNumber); return Promise.resolve({ success: true }); } setInterfaceAlternateSetting(interfaceNumber, alternateSetting) { assert_true(this.opened_); assert_false(this.currentConfiguration_ == null, 'device configured'); assert_true(this.claimedInterfaces_.has(interfaceNumber)); let iface = this.currentConfiguration_.interfaces.find( iface => iface.interfaceNumber == interfaceNumber); // Blink should never request an invalid interface or alternate. assert_false(iface == undefined); assert_true(iface.alternates.some( x => x.alternateSetting == alternateSetting)); this.claimedInterfaces_.set(interfaceNumber, alternateSetting); return Promise.resolve({ success: true }); } reset() { assert_true(this.opened_); return Promise.resolve({ success: true }); } clearHalt(endpoint) { assert_true(this.opened_); assert_false(this.currentConfiguration_ == null, 'device configured'); // TODO(reillyg): Assert that endpoint is valid. return Promise.resolve({ success: true }); } async controlTransferIn(params, length, timeout) { assert_true(this.opened_); if ((params.recipient == mojom.UsbControlTransferRecipient.INTERFACE || params.recipient == mojom.UsbControlTransferRecipient.ENDPOINT) && this.currentConfiguration_ == null) { return { status: mojom.UsbTransferStatus.PERMISSION_DENIED, }; } return { status: mojom.UsbTransferStatus.OK, data: { buffer: [ length >> 8, length & 0xff, params.request, params.value >> 8, params.value & 0xff, params.index >> 8, params.index & 0xff ] } }; } async controlTransferOut(params, data, timeout) { assert_true(this.opened_); if ((params.recipient == mojom.UsbControlTransferRecipient.INTERFACE || params.recipient == mojom.UsbControlTransferRecipient.ENDPOINT) && this.currentConfiguration_ == null) { return { status: mojom.UsbTransferStatus.PERMISSION_DENIED, }; } return {status: mojom.UsbTransferStatus.OK, bytesWritten: data.byteLength}; } genericTransferIn(endpointNumber, length, timeout) { assert_true(this.opened_); assert_false(this.currentConfiguration_ == null, 'device configured'); // TODO(reillyg): Assert that endpoint is valid. let data = new Array(length); for (let i = 0; i < length; ++i) data[i] = i & 0xff; return Promise.resolve( {status: mojom.UsbTransferStatus.OK, data: {buffer: data}}); } genericTransferOut(endpointNumber, data, timeout) { assert_true(this.opened_); assert_false(this.currentConfiguration_ == null, 'device configured'); // TODO(reillyg): Assert that endpoint is valid. return Promise.resolve( {status: mojom.UsbTransferStatus.OK, bytesWritten: data.byteLength}); } isochronousTransferIn(endpointNumber, packetLengths, timeout) { assert_true(this.opened_); assert_false(this.currentConfiguration_ == null, 'device configured'); // TODO(reillyg): Assert that endpoint is valid. let data = new Array(packetLengths.reduce((a, b) => a + b, 0)); let dataOffset = 0; let packets = new Array(packetLengths.length); for (let i = 0; i < packetLengths.length; ++i) { for (let j = 0; j < packetLengths[i]; ++j) data[dataOffset++] = j & 0xff; packets[i] = { length: packetLengths[i], transferredLength: packetLengths[i], status: mojom.UsbTransferStatus.OK }; } return Promise.resolve({data: {buffer: data}, packets: packets}); } isochronousTransferOut(endpointNumber, data, packetLengths, timeout) { assert_true(this.opened_); assert_false(this.currentConfiguration_ == null, 'device configured'); // TODO(reillyg): Assert that endpoint is valid. let packets = new Array(packetLengths.length); for (let i = 0; i < packetLengths.length; ++i) { packets[i] = { length: packetLengths[i], transferredLength: packetLengths[i], status: mojom.UsbTransferStatus.OK }; } return Promise.resolve({ packets: packets }); } } class FakeWebUsbService { constructor() { this.receiver_ = new mojom.WebUsbServiceReceiver(this); this.devices_ = new Map(); this.devicesByGuid_ = new Map(); this.client_ = null; this.nextGuid_ = 0; } addBinding(handle) { this.receiver_.$.bindHandle(handle); } addDevice(fakeDevice, info) { let device = { fakeDevice: fakeDevice, guid: (this.nextGuid_++).toString(), info: info, receivers: [], }; this.devices_.set(fakeDevice, device); this.devicesByGuid_.set(device.guid, device); if (this.client_) this.client_.onDeviceAdded(fakeDeviceInitToDeviceInfo(device.guid, info)); } async forgetDevice(guid) { // Permissions are currently untestable through WPT. } removeDevice(fakeDevice) { let device = this.devices_.get(fakeDevice); if (!device) throw new Error('Cannot remove unknown device.'); for (const receiver of device.receivers) receiver.$.close(); this.devices_.delete(device.fakeDevice); this.devicesByGuid_.delete(device.guid); if (this.client_) { this.client_.onDeviceRemoved( fakeDeviceInitToDeviceInfo(device.guid, device.info)); } } removeAllDevices() { this.devices_.forEach(device => { for (const receiver of device.receivers) receiver.$.close(); this.client_.onDeviceRemoved( fakeDeviceInitToDeviceInfo(device.guid, device.info)); }); this.devices_.clear(); this.devicesByGuid_.clear(); } getDevices() { let devices = []; this.devices_.forEach(device => { devices.push(fakeDeviceInitToDeviceInfo(device.guid, device.info)); }); return Promise.resolve({ results: devices }); } getDevice(guid, request) { let retrievedDevice = this.devicesByGuid_.get(guid); if (retrievedDevice) { const receiver = new mojom.UsbDeviceReceiver(new FakeDevice(retrievedDevice.info)); receiver.$.bindHandle(request.handle); receiver.onConnectionError.addListener(() => { if (retrievedDevice.fakeDevice.onclose) retrievedDevice.fakeDevice.onclose(); }); retrievedDevice.receivers.push(receiver); } else { request.handle.close(); } } getPermission(deviceFilters) { return new Promise(resolve => { if (navigator.usb.test.onrequestdevice) { navigator.usb.test.onrequestdevice( new USBDeviceRequestEvent(deviceFilters, resolve)); } else { resolve({ result: null }); } }); } setClient(client) { this.client_ = client; } } class USBDeviceRequestEvent { constructor(deviceFilters, resolve) { this.filters = convertMojoDeviceFilters(deviceFilters); this.resolveFunc_ = resolve; } respondWith(value) { // Wait until |value| resolves (if it is a Promise). This function returns // no value. Promise.resolve(value).then(fakeDevice => { let device = internal.webUsbService.devices_.get(fakeDevice); let result = null; if (device) { result = fakeDeviceInitToDeviceInfo(device.guid, device.info); } this.resolveFunc_({ result: result }); }, () => { this.resolveFunc_({ result: null }); }); } } // Unlike FakeDevice this class is exported to callers of USBTest.addFakeDevice. class FakeUSBDevice { constructor() { this.onclose = null; } disconnect() { setTimeout(() => internal.webUsbService.removeDevice(this), 0); } } class USBTest { constructor() { this.onrequestdevice = undefined; } async initialize() { if (internal.initialized) return; // Be ready to handle 'ReadyForAttachment' message from child iframes. if ('window' in self) { getMessagePort(window); } mojom = await loadMojomDefinitions(); internal.webUsbService = new FakeWebUsbService(); internal.webUsbServiceInterceptor = new MojoInterfaceInterceptor(mojom.WebUsbService.$interfaceName); internal.webUsbServiceInterceptor.oninterfacerequest = e => internal.webUsbService.addBinding(e.handle); internal.webUsbServiceInterceptor.start(); // Wait for a call to GetDevices() to pass between the renderer and the // mock in order to establish that everything is set up. await navigator.usb.getDevices(); internal.initialized = true; } // Returns a promise that is resolved when the implementation of |usb| in the // global scope for |context| is controlled by the current context. attachToContext(context) { if (!internal.initialized) throw new Error('Call initialize() before attachToContext()'); let target = context.constructor.name === 'Worker' ? context : window; return getMessagePort(target).then(() => { return new Promise(resolve => { internal.messagePort.onmessage = channelEvent => { switch (channelEvent.data.type) { case mojom.WebUsbService.$interfaceName: internal.webUsbService.addBinding(channelEvent.data.handle); break; case 'Complete': resolve(); break; } }; internal.messagePort.postMessage({ type: 'Attach', interfaces: [ mojom.WebUsbService.$interfaceName, ] }); }); }); } addFakeDevice(deviceInit) { if (!internal.initialized) throw new Error('Call initialize() before addFakeDevice().'); // |addDevice| and |removeDevice| are called in a setTimeout callback so // that tests do not rely on the device being immediately available which // may not be true for all implementations of this test API. let fakeDevice = new FakeUSBDevice(); setTimeout( () => internal.webUsbService.addDevice(fakeDevice, deviceInit), 0); return fakeDevice; } reset() { if (!internal.initialized) throw new Error('Call initialize() before reset().'); // Reset the mocks in a setTimeout callback so that tests do not rely on // the fact that this polyfill can do this synchronously. return new Promise(resolve => { setTimeout(() => { if (internal.messagePort !== null) internal.messagePort.close(); internal.messagePort = null; internal.webUsbService.removeAllDevices(); resolve(); }, 0); }); } } navigator.usb.test = new USBTest(); })();