583 lines
18 KiB
JavaScript
583 lines
18 KiB
JavaScript
'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();
|
|
|
|
})();
|