Skip to content

Commit

Permalink
Polyfill WebUSB on desktop VS Code.
Browse files Browse the repository at this point in the history
  • Loading branch information
whitequark committed Jan 9, 2024
1 parent b328e3a commit 74ca3d7
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 54 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

All notable changes to the YoWASP toolchain extension will be documented in this file.

## 0.2.5

- Added support for running commands that use WebUSB on desktop VS Code.

## 0.2.4

- Updated the internal interface to the YoWASP runtime.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,6 @@ Using a version other than the default one is not recommended because Pyodide do

## License

The YoWASP extension is distributed under the terms of the [ISC license](LICENSE.txt).
The YoWASP extension is distributed under the terms of the [ISC license](LICENSE.txt).

In addition, it includes a compiled Node extension that includes [libusb](https://github.com/libusb/libusb) and is thus subject to the [LGPL](https://github.com/libusb/libusb/blob/master/COPYING); this is used only on desktop VS Code. The compiled extension binaries are copied directly from the [usb](https://www.npmjs.com/package/usb) package.
41 changes: 40 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@
"esbuild:browser:watch": "npm run esbuild:browser -- --watch",
"esbuild:node": "esbuild ./src/extension.ts --bundle --outdir=out/node/ --sourcemap --external:vscode --format=cjs --platform=node --define:USE_WEB_WORKERS=false",
"esbuild:node:watch": "npm run esbuild:node -- --watch",
"vscode:prepublish": "npm run esbuild:browser -- && npm run esbuild:node --",
"esbuild:nodeusb": "esbuild ./src/nodeUSB.ts --bundle --outfile=out/node/usb/bundle.js --sourcemap --format=cjs --platform=node --define:__dirname=__dirname_nodeUSB && cp -r node_modules/usb/prebuilds/ out/node/usb/",
"vscode:prepublish": "npm run esbuild:browser && npm run esbuild:node && npm run esbuild:nodeusb",
"browser": "vscode-test-web --coi --browserOption=--remote-debugging-port=9222 --extensionDevelopmentPath=. test"
},
"devDependencies": {
Expand All @@ -143,6 +144,7 @@
"@typescript-eslint/parser": "^6.13.1",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@vscode/test-web": "^0.0.50",
"esbuild": "^0.19.9"
"esbuild": "^0.19.9",
"usb": "^2.11.0"
}
}
17 changes: 11 additions & 6 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,13 +327,18 @@ function workerEntryPoint(self: WorkerContext) {
return;
}

if (command.requiresUSBDevice) {
if (typeof navigator === 'undefined' || typeof navigator.usb === 'undefined') {
postDiagnostic(Severity.fatal,
`The command '${argv0}' requires WebUSB, but it is not available.`);
return;
}
// Check if WebUSB is provided at all, either by the browser or via Node polyfill.
if (command.requiresUSBDevice && !self.supportsUSB) {
postDiagnostic(Severity.fatal,
`The command '${argv0}' requires WebUSB, but it is not available.`);
return;
}

// If WebUSB is provided by the browser, we need to request device permissions.
// If WebUSB is provided by the Node polyfill, no access control is performed.
// The Node polyfill is only available in the `importModule` context, not worker context.
if (command.requiresUSBDevice &&
typeof navigator !== 'undefined' && typeof navigator.usb !== 'undefined') {
let filtersMatch = false;
for (const usbDevice of await navigator.usb.getDevices()) {
if (command.requiresUSBDevice.length === 0) {
Expand Down
12 changes: 12 additions & 0 deletions src/nodeUSB.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as path from 'node:path';

// This variable is injected into the bundle via esbuild. It is necessary for node-gyp-build
// to discover the prebuilt native modules.
declare global {
var __dirname_nodeUSB: string;
}
globalThis.__dirname_nodeUSB = path.join(__filename, '..', 'dummy1', 'dummy2');

// This import has to be done via the `require()` function so that the module initialization code
// is called after the variable above is set.
export const usb = new (require('usb').WebUSB)({ allowAllDevices: true });
125 changes: 82 additions & 43 deletions src/workerThread.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// This binding is injected with `esbuild --define:USE_WEB_WORKERS=...`.
declare var USE_WEB_WORKERS: boolean;

declare global {
interface WorkerNavigator {
usb: object | undefined;
}
}

import type * as nodeWorkerThreads from 'node:worker_threads';

export interface MessageChannel {
Expand All @@ -10,6 +16,8 @@ export interface MessageChannel {

export interface WorkerContext extends MessageChannel {
importModule(url: URL | string): Promise<any>;

supportsUSB: boolean;
}

export interface WorkerThread extends MessageChannel {
Expand All @@ -26,7 +34,7 @@ if (USE_WEB_WORKERS) {
#platformWorker: Worker;

constructor(entryPoint: (self: WorkerContext) => void) {
function createSelf(): MessageChannel {
function createSelf(): WorkerContext {
const newSelf = {
postMessage(message: any): void {
self.postMessage(message);
Expand All @@ -36,7 +44,9 @@ if (USE_WEB_WORKERS) {

async importModule(url: URL | string): Promise<any> {
return await import(url.toString());
}
},

supportsUSB: navigator?.usb !== undefined
};

self.onmessage = (event) =>
Expand Down Expand Up @@ -71,25 +81,80 @@ if (USE_WEB_WORKERS) {
#platformThread: nodeWorkerThreads.Worker;

constructor(entryPoint: (self: WorkerContext) => void) {
function createSelf() {
function createSelf(): WorkerContext {
const path = require('node:path');
const vm = require('node:vm');
const threads = require('node:worker_threads');
const crypto = require('node:crypto');

let usb: any = undefined;
try {
usb = require(path.join(threads.workerData.dirname, 'usb', 'bundle.js')).usb;
} catch(e) {
console.log(`[YoWASP Toolchain] Cannot import WebUSB polyfill`, e);
}

// Without this the identity of builtins is different between threads, which results
// in astonishingly confusing and difficult to deal with bugs.
const globalThis: any = {
Object,
Boolean,
String,
Array,
Map,
Set,
Function,
Symbol,
Error,
TypeError,
Int8Array,
Int16Array,
Int32Array,
BigInt64Array,
Uint8Array,
Uint16Array,
Uint32Array,
BigUint64Array,
Float32Array,
Float64Array,
Buffer,
ArrayBuffer,
SharedArrayBuffer,
DataView,
WebAssembly,
TextDecoder,
TextEncoder,
Promise,
URL,
Blob,
fetch,
console,
performance,
crypto,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
setImmediate,
clearImmediate,
btoa,
atob,
navigator: {
userAgent: 'awful',
usb
},
importScripts: function() {
// Needs to be `TypeError` for Pyodide loader to switch to `await import`.
throw new TypeError(`importScripts() not implemented`);
}
};
// At the moment of writing, VS Code ships Node v18.15.0. This version:
// - cannot dynamically import from https:// URLs;
// - does not provide module.register() hook to extend the loader;
// - does not provide vm.Module (without a flag) to load ES modules manually.
// Thus, crimes.
//
// Almost all of this can be deleted when VS Code ships Node v18.19.0 or later.
const globalThis: any = {
fetch: fetch,
importScripts: function() {
// Needs to be `TypeError` for Pyodide loader to switch to `await import`.
throw new TypeError(`importScripts() not implemented`);
}
};
async function importModuleCriminally(url: URL | string): Promise<any> {
let code = await fetch(url).then((resp) => resp.text());
code = code.replace(/\bimport\.meta\.url\b/g, JSON.stringify(url));
Expand All @@ -101,37 +166,6 @@ if (USE_WEB_WORKERS) {
filename: url.toString()
});
const context: any = {
Object,
String,
Array,
Error,
TypeError,
Int8Array,
Int16Array,
Int32Array,
BigInt64Array,
Uint8Array,
Uint16Array,
Uint32Array,
BigUint64Array,
Float32Array,
Float64Array,
WebAssembly,
TextDecoder,
TextEncoder,
URL,
Blob,
console,
performance,
crypto,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
setImmediate,
clearImmediate,
btoa,
atob,
location: {
href: url.toString(),
toString() { return url.toString(); }
Expand All @@ -140,8 +174,10 @@ if (USE_WEB_WORKERS) {
exports: {},
globalThis,
};
// FIXME(not only this is cursed but it is also wrong) {
context.self = context;
Object.setPrototypeOf(context, globalThis);
// }
script.runInNewContext(context, { contextOrigin: url.toString() });
return context.exports;
}
Expand All @@ -155,15 +191,18 @@ if (USE_WEB_WORKERS) {

async importModule(url: URL | string): Promise<any> {
return importModuleCriminally(url);
}
},

supportsUSB: usb !== undefined
};
threads.parentPort.on('message', (message: any) =>
newSelf.processMessage(message));
return newSelf;
}

const workerCode = `(${entryPoint.toString()})((${createSelf.toString()})());`;
this.#platformThread = new threads.Worker(workerCode, { eval: true });
const workerData = { dirname: __dirname };
this.#platformThread = new threads.Worker(workerCode, { eval: true, workerData });
this.#platformThread.on('message', (message: any) =>
this.processMessage(message));
}
Expand Down
2 changes: 1 addition & 1 deletion test/.vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"yowaspToolchain.bundles": [
"@yowasp/yosys",
"@yowasp/nextpnr-ecp5",
"@yowasp/openfpgaloader"
"@yowasp/openfpgaloader",
],
"yowaspToolchain.pythonRequirements": [
"amaranth"
Expand Down

0 comments on commit 74ca3d7

Please sign in to comment.