'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({result: {success: mojom.UsbOpenDeviceSuccess.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(options) {
return new Promise(resolve => {
if (navigator.usb.test.onrequestdevice) {
navigator.usb.test.onrequestdevice(
new USBDeviceRequestEvent(options, resolve));
} else {
resolve({ result: null });
}
});
}
setClient(client) {
this.client_ = client;
}
}
class USBDeviceRequestEvent {
constructor(options, resolve) {
this.filters = convertMojoDeviceFilters(options.filters);
this.exclusionFilters = convertMojoDeviceFilters(options.exclusionFilters);
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();
})();