webusb-test.js (18369B)
1 'use strict'; 2 3 // This polyfill library implements the WebUSB Test API as specified here: 4 // https://wicg.github.io/webusb/test/ 5 6 (() => { 7 8 // These variables are logically members of the USBTest class but are defined 9 // here to hide them from being visible as fields of navigator.usb.test. 10 let internal = { 11 intialized: false, 12 13 webUsbService: null, 14 webUsbServiceInterceptor: null, 15 16 messagePort: null, 17 }; 18 19 let mojom = {}; 20 21 async function loadMojomDefinitions() { 22 const deviceMojom = 23 await import('/gen/services/device/public/mojom/usb_device.mojom.m.js'); 24 const serviceMojom = await import( 25 '/gen/third_party/blink/public/mojom/usb/web_usb_service.mojom.m.js'); 26 return { 27 ...deviceMojom, 28 ...serviceMojom, 29 }; 30 } 31 32 function getMessagePort(target) { 33 return new Promise(resolve => { 34 target.addEventListener('message', messageEvent => { 35 if (messageEvent.data.type === 'ReadyForAttachment') { 36 if (internal.messagePort === null) { 37 internal.messagePort = messageEvent.data.port; 38 } 39 resolve(); 40 } 41 }, {once: true}); 42 }); 43 } 44 45 // Converts an ECMAScript String object to an instance of 46 // mojo_base.mojom.String16. 47 function mojoString16ToString(string16) { 48 return String.fromCharCode.apply(null, string16.data); 49 } 50 51 // Converts an instance of mojo_base.mojom.String16 to an ECMAScript String. 52 function stringToMojoString16(string) { 53 let array = new Array(string.length); 54 for (var i = 0; i < string.length; ++i) { 55 array[i] = string.charCodeAt(i); 56 } 57 return { data: array } 58 } 59 60 function fakeDeviceInitToDeviceInfo(guid, init) { 61 let deviceInfo = { 62 guid: guid + "", 63 usbVersionMajor: init.usbVersionMajor, 64 usbVersionMinor: init.usbVersionMinor, 65 usbVersionSubminor: init.usbVersionSubminor, 66 classCode: init.deviceClass, 67 subclassCode: init.deviceSubclass, 68 protocolCode: init.deviceProtocol, 69 vendorId: init.vendorId, 70 productId: init.productId, 71 deviceVersionMajor: init.deviceVersionMajor, 72 deviceVersionMinor: init.deviceVersionMinor, 73 deviceVersionSubminor: init.deviceVersionSubminor, 74 manufacturerName: stringToMojoString16(init.manufacturerName), 75 productName: stringToMojoString16(init.productName), 76 serialNumber: stringToMojoString16(init.serialNumber), 77 activeConfiguration: init.activeConfigurationValue, 78 configurations: [] 79 }; 80 init.configurations.forEach(config => { 81 var configInfo = { 82 configurationValue: config.configurationValue, 83 configurationName: stringToMojoString16(config.configurationName), 84 selfPowered: false, 85 remoteWakeup: false, 86 maximumPower: 0, 87 interfaces: [], 88 extraData: new Uint8Array() 89 }; 90 config.interfaces.forEach(iface => { 91 var interfaceInfo = { 92 interfaceNumber: iface.interfaceNumber, 93 alternates: [] 94 }; 95 iface.alternates.forEach(alternate => { 96 var alternateInfo = { 97 alternateSetting: alternate.alternateSetting, 98 classCode: alternate.interfaceClass, 99 subclassCode: alternate.interfaceSubclass, 100 protocolCode: alternate.interfaceProtocol, 101 interfaceName: stringToMojoString16(alternate.interfaceName), 102 endpoints: [], 103 extraData: new Uint8Array() 104 }; 105 alternate.endpoints.forEach(endpoint => { 106 var endpointInfo = { 107 endpointNumber: endpoint.endpointNumber, 108 packetSize: endpoint.packetSize, 109 synchronizationType: mojom.UsbSynchronizationType.NONE, 110 usageType: mojom.UsbUsageType.DATA, 111 pollingInterval: 0, 112 extraData: new Uint8Array() 113 }; 114 switch (endpoint.direction) { 115 case "in": 116 endpointInfo.direction = mojom.UsbTransferDirection.INBOUND; 117 break; 118 case "out": 119 endpointInfo.direction = mojom.UsbTransferDirection.OUTBOUND; 120 break; 121 } 122 switch (endpoint.type) { 123 case "bulk": 124 endpointInfo.type = mojom.UsbTransferType.BULK; 125 break; 126 case "interrupt": 127 endpointInfo.type = mojom.UsbTransferType.INTERRUPT; 128 break; 129 case "isochronous": 130 endpointInfo.type = mojom.UsbTransferType.ISOCHRONOUS; 131 break; 132 } 133 alternateInfo.endpoints.push(endpointInfo); 134 }); 135 interfaceInfo.alternates.push(alternateInfo); 136 }); 137 configInfo.interfaces.push(interfaceInfo); 138 }); 139 deviceInfo.configurations.push(configInfo); 140 }); 141 return deviceInfo; 142 } 143 144 function convertMojoDeviceFilters(input) { 145 let output = []; 146 input.forEach(filter => { 147 output.push(convertMojoDeviceFilter(filter)); 148 }); 149 return output; 150 } 151 152 function convertMojoDeviceFilter(input) { 153 let output = {}; 154 if (input.hasVendorId) 155 output.vendorId = input.vendorId; 156 if (input.hasProductId) 157 output.productId = input.productId; 158 if (input.hasClassCode) 159 output.classCode = input.classCode; 160 if (input.hasSubclassCode) 161 output.subclassCode = input.subclassCode; 162 if (input.hasProtocolCode) 163 output.protocolCode = input.protocolCode; 164 if (input.serialNumber) 165 output.serialNumber = mojoString16ToString(input.serialNumber); 166 return output; 167 } 168 169 class FakeDevice { 170 constructor(deviceInit) { 171 this.info_ = deviceInit; 172 this.opened_ = false; 173 this.currentConfiguration_ = null; 174 this.claimedInterfaces_ = new Map(); 175 } 176 177 getConfiguration() { 178 if (this.currentConfiguration_) { 179 return Promise.resolve({ 180 value: this.currentConfiguration_.configurationValue }); 181 } else { 182 return Promise.resolve({ value: 0 }); 183 } 184 } 185 186 open() { 187 assert_false(this.opened_); 188 this.opened_ = true; 189 return Promise.resolve({result: {success: mojom.UsbOpenDeviceSuccess.OK}}); 190 } 191 192 close() { 193 assert_true(this.opened_); 194 this.opened_ = false; 195 return Promise.resolve(); 196 } 197 198 setConfiguration(value) { 199 assert_true(this.opened_); 200 201 let selectedConfiguration = this.info_.configurations.find( 202 configuration => configuration.configurationValue == value); 203 // Blink should never request an invalid configuration. 204 assert_not_equals(selectedConfiguration, undefined); 205 this.currentConfiguration_ = selectedConfiguration; 206 return Promise.resolve({ success: true }); 207 } 208 209 async claimInterface(interfaceNumber) { 210 assert_true(this.opened_); 211 assert_false(this.currentConfiguration_ == null, 'device configured'); 212 assert_false(this.claimedInterfaces_.has(interfaceNumber), 213 'interface already claimed'); 214 215 const protectedInterfaces = new Set([ 216 mojom.USB_AUDIO_CLASS, 217 mojom.USB_HID_CLASS, 218 mojom.USB_MASS_STORAGE_CLASS, 219 mojom.USB_SMART_CARD_CLASS, 220 mojom.USB_VIDEO_CLASS, 221 mojom.USB_AUDIO_VIDEO_CLASS, 222 mojom.USB_WIRELESS_CLASS, 223 ]); 224 225 let iface = this.currentConfiguration_.interfaces.find( 226 iface => iface.interfaceNumber == interfaceNumber); 227 // Blink should never request an invalid interface or alternate. 228 assert_false(iface == undefined); 229 if (iface.alternates.some( 230 alt => protectedInterfaces.has(alt.interfaceClass))) { 231 return {result: mojom.UsbClaimInterfaceResult.kProtectedClass}; 232 } 233 234 this.claimedInterfaces_.set(interfaceNumber, 0); 235 return {result: mojom.UsbClaimInterfaceResult.kSuccess}; 236 } 237 238 releaseInterface(interfaceNumber) { 239 assert_true(this.opened_); 240 assert_false(this.currentConfiguration_ == null, 'device configured'); 241 assert_true(this.claimedInterfaces_.has(interfaceNumber)); 242 this.claimedInterfaces_.delete(interfaceNumber); 243 return Promise.resolve({ success: true }); 244 } 245 246 setInterfaceAlternateSetting(interfaceNumber, alternateSetting) { 247 assert_true(this.opened_); 248 assert_false(this.currentConfiguration_ == null, 'device configured'); 249 assert_true(this.claimedInterfaces_.has(interfaceNumber)); 250 251 let iface = this.currentConfiguration_.interfaces.find( 252 iface => iface.interfaceNumber == interfaceNumber); 253 // Blink should never request an invalid interface or alternate. 254 assert_false(iface == undefined); 255 assert_true(iface.alternates.some( 256 x => x.alternateSetting == alternateSetting)); 257 this.claimedInterfaces_.set(interfaceNumber, alternateSetting); 258 return Promise.resolve({ success: true }); 259 } 260 261 reset() { 262 assert_true(this.opened_); 263 return Promise.resolve({ success: true }); 264 } 265 266 clearHalt(endpoint) { 267 assert_true(this.opened_); 268 assert_false(this.currentConfiguration_ == null, 'device configured'); 269 // TODO(reillyg): Assert that endpoint is valid. 270 return Promise.resolve({ success: true }); 271 } 272 273 async controlTransferIn(params, length, timeout) { 274 assert_true(this.opened_); 275 276 if ((params.recipient == mojom.UsbControlTransferRecipient.INTERFACE || 277 params.recipient == mojom.UsbControlTransferRecipient.ENDPOINT) && 278 this.currentConfiguration_ == null) { 279 return { 280 status: mojom.UsbTransferStatus.PERMISSION_DENIED, 281 }; 282 } 283 284 return { 285 status: mojom.UsbTransferStatus.OK, 286 data: { 287 buffer: [ 288 length >> 8, length & 0xff, params.request, params.value >> 8, 289 params.value & 0xff, params.index >> 8, params.index & 0xff 290 ] 291 } 292 }; 293 } 294 295 async controlTransferOut(params, data, timeout) { 296 assert_true(this.opened_); 297 298 if ((params.recipient == mojom.UsbControlTransferRecipient.INTERFACE || 299 params.recipient == mojom.UsbControlTransferRecipient.ENDPOINT) && 300 this.currentConfiguration_ == null) { 301 return { 302 status: mojom.UsbTransferStatus.PERMISSION_DENIED, 303 }; 304 } 305 306 return {status: mojom.UsbTransferStatus.OK, bytesWritten: data.byteLength}; 307 } 308 309 genericTransferIn(endpointNumber, length, timeout) { 310 assert_true(this.opened_); 311 assert_false(this.currentConfiguration_ == null, 'device configured'); 312 // TODO(reillyg): Assert that endpoint is valid. 313 let data = new Array(length); 314 for (let i = 0; i < length; ++i) 315 data[i] = i & 0xff; 316 return Promise.resolve( 317 {status: mojom.UsbTransferStatus.OK, data: {buffer: data}}); 318 } 319 320 genericTransferOut(endpointNumber, data, timeout) { 321 assert_true(this.opened_); 322 assert_false(this.currentConfiguration_ == null, 'device configured'); 323 // TODO(reillyg): Assert that endpoint is valid. 324 return Promise.resolve( 325 {status: mojom.UsbTransferStatus.OK, bytesWritten: data.byteLength}); 326 } 327 328 isochronousTransferIn(endpointNumber, packetLengths, timeout) { 329 assert_true(this.opened_); 330 assert_false(this.currentConfiguration_ == null, 'device configured'); 331 // TODO(reillyg): Assert that endpoint is valid. 332 let data = new Array(packetLengths.reduce((a, b) => a + b, 0)); 333 let dataOffset = 0; 334 let packets = new Array(packetLengths.length); 335 for (let i = 0; i < packetLengths.length; ++i) { 336 for (let j = 0; j < packetLengths[i]; ++j) 337 data[dataOffset++] = j & 0xff; 338 packets[i] = { 339 length: packetLengths[i], 340 transferredLength: packetLengths[i], 341 status: mojom.UsbTransferStatus.OK 342 }; 343 } 344 return Promise.resolve({data: {buffer: data}, packets: packets}); 345 } 346 347 isochronousTransferOut(endpointNumber, data, packetLengths, timeout) { 348 assert_true(this.opened_); 349 assert_false(this.currentConfiguration_ == null, 'device configured'); 350 // TODO(reillyg): Assert that endpoint is valid. 351 let packets = new Array(packetLengths.length); 352 for (let i = 0; i < packetLengths.length; ++i) { 353 packets[i] = { 354 length: packetLengths[i], 355 transferredLength: packetLengths[i], 356 status: mojom.UsbTransferStatus.OK 357 }; 358 } 359 return Promise.resolve({ packets: packets }); 360 } 361 } 362 363 class FakeWebUsbService { 364 constructor() { 365 this.receiver_ = new mojom.WebUsbServiceReceiver(this); 366 this.devices_ = new Map(); 367 this.devicesByGuid_ = new Map(); 368 this.client_ = null; 369 this.nextGuid_ = 0; 370 } 371 372 addBinding(handle) { 373 this.receiver_.$.bindHandle(handle); 374 } 375 376 addDevice(fakeDevice, info) { 377 let device = { 378 fakeDevice: fakeDevice, 379 guid: (this.nextGuid_++).toString(), 380 info: info, 381 receivers: [], 382 }; 383 this.devices_.set(fakeDevice, device); 384 this.devicesByGuid_.set(device.guid, device); 385 if (this.client_) 386 this.client_.onDeviceAdded(fakeDeviceInitToDeviceInfo(device.guid, info)); 387 } 388 389 async forgetDevice(guid) { 390 // Permissions are currently untestable through WPT. 391 } 392 393 removeDevice(fakeDevice) { 394 let device = this.devices_.get(fakeDevice); 395 if (!device) 396 throw new Error('Cannot remove unknown device.'); 397 398 for (const receiver of device.receivers) 399 receiver.$.close(); 400 this.devices_.delete(device.fakeDevice); 401 this.devicesByGuid_.delete(device.guid); 402 if (this.client_) { 403 this.client_.onDeviceRemoved( 404 fakeDeviceInitToDeviceInfo(device.guid, device.info)); 405 } 406 } 407 408 removeAllDevices() { 409 this.devices_.forEach(device => { 410 for (const receiver of device.receivers) 411 receiver.$.close(); 412 this.client_.onDeviceRemoved( 413 fakeDeviceInitToDeviceInfo(device.guid, device.info)); 414 }); 415 this.devices_.clear(); 416 this.devicesByGuid_.clear(); 417 } 418 419 getDevices() { 420 let devices = []; 421 this.devices_.forEach(device => { 422 devices.push(fakeDeviceInitToDeviceInfo(device.guid, device.info)); 423 }); 424 return Promise.resolve({ results: devices }); 425 } 426 427 getDevice(guid, request) { 428 let retrievedDevice = this.devicesByGuid_.get(guid); 429 if (retrievedDevice) { 430 const receiver = 431 new mojom.UsbDeviceReceiver(new FakeDevice(retrievedDevice.info)); 432 receiver.$.bindHandle(request.handle); 433 receiver.onConnectionError.addListener(() => { 434 if (retrievedDevice.fakeDevice.onclose) 435 retrievedDevice.fakeDevice.onclose(); 436 }); 437 retrievedDevice.receivers.push(receiver); 438 } else { 439 request.handle.close(); 440 } 441 } 442 443 getPermission(options) { 444 return new Promise(resolve => { 445 if (navigator.usb.test.onrequestdevice) { 446 navigator.usb.test.onrequestdevice( 447 new USBDeviceRequestEvent(options, resolve)); 448 } else { 449 resolve({ result: null }); 450 } 451 }); 452 } 453 454 setClient(client) { 455 this.client_ = client; 456 } 457 } 458 459 class USBDeviceRequestEvent { 460 constructor(options, resolve) { 461 this.filters = convertMojoDeviceFilters(options.filters); 462 this.exclusionFilters = convertMojoDeviceFilters(options.exclusionFilters); 463 this.resolveFunc_ = resolve; 464 } 465 466 respondWith(value) { 467 // Wait until |value| resolves (if it is a Promise). This function returns 468 // no value. 469 Promise.resolve(value).then(fakeDevice => { 470 let device = internal.webUsbService.devices_.get(fakeDevice); 471 let result = null; 472 if (device) { 473 result = fakeDeviceInitToDeviceInfo(device.guid, device.info); 474 } 475 this.resolveFunc_({ result: result }); 476 }, () => { 477 this.resolveFunc_({ result: null }); 478 }); 479 } 480 } 481 482 // Unlike FakeDevice this class is exported to callers of USBTest.addFakeDevice. 483 class FakeUSBDevice { 484 constructor() { 485 this.onclose = null; 486 } 487 488 disconnect() { 489 setTimeout(() => internal.webUsbService.removeDevice(this), 0); 490 } 491 } 492 493 class USBTest { 494 constructor() { 495 this.onrequestdevice = undefined; 496 } 497 498 async initialize() { 499 if (internal.initialized) 500 return; 501 502 // Be ready to handle 'ReadyForAttachment' message from child iframes. 503 if ('window' in self) { 504 getMessagePort(window); 505 } 506 507 mojom = await loadMojomDefinitions(); 508 internal.webUsbService = new FakeWebUsbService(); 509 internal.webUsbServiceInterceptor = 510 new MojoInterfaceInterceptor(mojom.WebUsbService.$interfaceName); 511 internal.webUsbServiceInterceptor.oninterfacerequest = 512 e => internal.webUsbService.addBinding(e.handle); 513 internal.webUsbServiceInterceptor.start(); 514 515 // Wait for a call to GetDevices() to pass between the renderer and the 516 // mock in order to establish that everything is set up. 517 await navigator.usb.getDevices(); 518 internal.initialized = true; 519 } 520 521 // Returns a promise that is resolved when the implementation of |usb| in the 522 // global scope for |context| is controlled by the current context. 523 attachToContext(context) { 524 if (!internal.initialized) 525 throw new Error('Call initialize() before attachToContext()'); 526 527 let target = context.constructor.name === 'Worker' ? context : window; 528 return getMessagePort(target).then(() => { 529 return new Promise(resolve => { 530 internal.messagePort.onmessage = channelEvent => { 531 switch (channelEvent.data.type) { 532 case mojom.WebUsbService.$interfaceName: 533 internal.webUsbService.addBinding(channelEvent.data.handle); 534 break; 535 case 'Complete': 536 resolve(); 537 break; 538 } 539 }; 540 internal.messagePort.postMessage({ 541 type: 'Attach', 542 interfaces: [ 543 mojom.WebUsbService.$interfaceName, 544 ] 545 }); 546 }); 547 }); 548 } 549 550 addFakeDevice(deviceInit) { 551 if (!internal.initialized) 552 throw new Error('Call initialize() before addFakeDevice().'); 553 554 // |addDevice| and |removeDevice| are called in a setTimeout callback so 555 // that tests do not rely on the device being immediately available which 556 // may not be true for all implementations of this test API. 557 let fakeDevice = new FakeUSBDevice(); 558 setTimeout( 559 () => internal.webUsbService.addDevice(fakeDevice, deviceInit), 0); 560 return fakeDevice; 561 } 562 563 reset() { 564 if (!internal.initialized) 565 throw new Error('Call initialize() before reset().'); 566 567 // Reset the mocks in a setTimeout callback so that tests do not rely on 568 // the fact that this polyfill can do this synchronously. 569 return new Promise(resolve => { 570 setTimeout(() => { 571 if (internal.messagePort !== null) 572 internal.messagePort.close(); 573 internal.messagePort = null; 574 internal.webUsbService.removeAllDevices(); 575 resolve(); 576 }, 0); 577 }); 578 } 579 } 580 581 navigator.usb.test = new USBTest(); 582 583 })();