feat(webusb): allow filters to be empty arrays

This commit is contained in:
Simon Chan 2024-06-27 14:15:46 +08:00
parent 10ed1848f5
commit af0acb577a
No known key found for this signature in database
GPG key ID: A8B69F750B9BCEDD
15 changed files with 321 additions and 82 deletions

View file

@ -130,15 +130,30 @@ importers:
specifier: workspace:^0.0.24
version: link:../struct
devDependencies:
'@jest/globals':
specifier: ^30.0.0-alpha.4
version: 30.0.0-alpha.5
'@types/node':
specifier: ^20.14.9
version: 20.14.9
'@yume-chan/eslint-config':
specifier: workspace:^1.0.0
version: link:../../toolchain/eslint-config
'@yume-chan/tsconfig':
specifier: workspace:^1.0.0
version: link:../../toolchain/tsconfig
cross-env:
specifier: ^7.0.3
version: 7.0.3
jest:
specifier: ^30.0.0-alpha.4
version: 30.0.0-alpha.5(@types/node@20.14.9)
prettier:
specifier: ^3.3.2
version: 3.3.2
ts-jest:
specifier: ^29.1.5
version: 29.1.5(@babel/core@7.24.7)(@jest/types@29.6.3)(jest@30.0.0-alpha.5)(typescript@5.5.2)
typescript:
specifier: ^5.5.2
version: 5.5.2

View file

@ -1,5 +1,5 @@
// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush.
{
"pnpmShrinkwrapHash": "4268d369f3d5b02e089540e98cb1fa9074cba8c9",
"pnpmShrinkwrapHash": "e045703b662b53e585b409888c662653aa4196da",
"preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f"
}

View file

@ -15,8 +15,11 @@
<a href="https://github.com/yume-chan/ya-webadb/releases">
<img alt="GitHub release" src="https://img.shields.io/github/v/release/yume-chan/ya-webadb?logo=github">
</a>
<a href="https://bundlephobia.com/package/@yume-chan/adb-credential-web">
<img alt="Package Size" src="https://img.shields.io/bundlephobia/minzip/%40yume-chan%2Fadb-credential-web">
</a>
<a href="https://www.npmjs.com/package/@yume-chan/adb">
<img alt="npm" src="https://img.shields.io/npm/dm/%40yume-chan/adb?logo=npm">
<img alt="npm" src="https://img.shields.io/npm/dm/%40yume-chan/adb-credential-web?logo=npm">
</a>
<a href="https://discord.gg/26k3ttC2PN">
<img alt="Discord" src="https://img.shields.io/discord/1120215514732564502?logo=discord&logoColor=%23ffffff&label=Discord">

View file

@ -15,8 +15,11 @@
<a href="https://github.com/yume-chan/ya-webadb/releases">
<img alt="GitHub release" src="https://img.shields.io/github/v/release/yume-chan/ya-webadb?logo=github">
</a>
<a href="https://www.npmjs.com/package/@yume-chan/adb">
<img alt="npm" src="https://img.shields.io/npm/dm/%40yume-chan/adb?logo=npm">
<a href="https://bundlephobia.com/package/@yume-chan/adb-daemon-webusb">
<img alt="Package Size" src="https://img.shields.io/bundlephobia/minzip/%40yume-chan%2Fadb-daemon-webusb">
</a>
<a href="https://www.npmjs.com/package/@yume-chan/adb-daemon-webusb">
<img alt="npm" src="https://img.shields.io/npm/dm/%40yume-chan/adb-daemon-webusb?logo=npm">
</a>
<a href="https://discord.gg/26k3ttC2PN">
<img alt="Discord" src="https://img.shields.io/discord/1120215514732564502?logo=discord&logoColor=%23ffffff&label=Discord">

View file

@ -0,0 +1,14 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: "ts-jest/presets/default-esm",
extensionsToTreatAsEsm: [".ts"],
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{ tsconfig: "tsconfig.test.json", useESM: true },
],
},
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
};

View file

@ -28,7 +28,8 @@
"build": "tsc -b tsconfig.build.json",
"build:watch": "tsc -b tsconfig.build.json",
"lint": "run-eslint && prettier src/**/*.ts --write --tab-width 4",
"prepublishOnly": "npm run build"
"prepublishOnly": "npm run build",
"test": "cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" TS_JEST_DISABLE_VER_CHECKER=true jest --coverage"
},
"dependencies": {
"@types/w3c-web-usb": "^1.0.10",
@ -37,9 +38,14 @@
"@yume-chan/struct": "workspace:^0.0.24"
},
"devDependencies": {
"@jest/globals": "^30.0.0-alpha.4",
"@types/node": "^20.14.9",
"@yume-chan/eslint-config": "workspace:^1.0.0",
"@yume-chan/tsconfig": "workspace:^1.0.0",
"cross-env": "^7.0.3",
"jest": "^30.0.0-alpha.4",
"prettier": "^3.3.2",
"ts-jest": "^29.1.5",
"typescript": "^5.5.2"
}
}

View file

@ -22,9 +22,10 @@ import {
import type { ExactReadable } from "@yume-chan/struct";
import { EMPTY_UINT8_ARRAY } from "@yume-chan/struct";
import type { AdbDeviceFilter } from "./utils.js";
import type { UsbInterfaceFilter } from "./utils.js";
import {
findUsbAlternateInterface,
findUsbEndpoints,
getSerialNumber,
isErrorName,
} from "./utils.js";
@ -32,49 +33,30 @@ import {
/**
* The default filter for ADB devices, as defined by Google.
*/
export const ADB_DEFAULT_DEVICE_FILTER = {
export const ADB_DEFAULT_INTERFACE_FILTER = {
classCode: 0xff,
subclassCode: 0x42,
protocolCode: 1,
} as const satisfies AdbDeviceFilter;
} as const satisfies UsbInterfaceFilter;
/**
* Find the first pair of input and output endpoints from an alternate interface.
*
* ADB interface only has two endpoints, one for input and one for output.
*/
function findUsbEndpoints(endpoints: USBEndpoint[]) {
if (endpoints.length === 0) {
throw new TypeError("No endpoints given");
export function toAdbDeviceFilters(
filters: USBDeviceFilter[] | undefined,
): (USBDeviceFilter & UsbInterfaceFilter)[] {
if (!filters || filters.length === 0) {
return [ADB_DEFAULT_INTERFACE_FILTER];
} else {
return filters.map((filter) => ({
...filter,
classCode:
filter.classCode ?? ADB_DEFAULT_INTERFACE_FILTER.classCode,
subclassCode:
filter.subclassCode ??
ADB_DEFAULT_INTERFACE_FILTER.subclassCode,
protocolCode:
filter.protocolCode ??
ADB_DEFAULT_INTERFACE_FILTER.protocolCode,
}));
}
let inEndpoint: USBEndpoint | undefined;
let outEndpoint: USBEndpoint | undefined;
for (const endpoint of endpoints) {
switch (endpoint.direction) {
case "in":
inEndpoint = endpoint;
if (outEndpoint) {
return { inEndpoint, outEndpoint };
}
break;
case "out":
outEndpoint = endpoint;
if (inEndpoint) {
return { inEndpoint, outEndpoint };
}
break;
}
}
if (!inEndpoint) {
throw new TypeError("No input endpoint found.");
}
if (!outEndpoint) {
throw new TypeError("No output endpoint found.");
}
throw new Error("unreachable");
}
class Uint8ArrayExactReadable implements ExactReadable {
@ -282,7 +264,7 @@ export class AdbDaemonWebUsbConnection
}
export class AdbDaemonWebUsbDevice implements AdbDaemonDevice {
#filters: AdbDeviceFilter[];
#filters: UsbInterfaceFilter[];
#usbManager: USB;
#raw: USBDevice;
@ -307,7 +289,7 @@ export class AdbDaemonWebUsbDevice implements AdbDaemonDevice {
*/
constructor(
device: USBDevice,
filters: AdbDeviceFilter[] = [ADB_DEFAULT_DEVICE_FILTER],
filters: UsbInterfaceFilter[],
usbManager: USB,
) {
this.#raw = device;

View file

@ -0,0 +1,167 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/require-await */
import { describe, expect, it, jest } from "@jest/globals";
import { ADB_DEFAULT_INTERFACE_FILTER, AdbDaemonWebUsbDevice } from "./device";
import { AdbDaemonWebUsbDeviceManager } from "./manager.js";
class MockUsb implements USB {
onconnect: (ev: USBConnectionEvent) => void = jest.fn();
ondisconnect: (ev: USBConnectionEvent) => void = jest.fn();
getDevices: () => Promise<USBDevice[]> = jest.fn(async () => []);
requestDevice: (options?: USBDeviceRequestOptions) => Promise<USBDevice> =
jest.fn(async () => ({ serialNumber: "abcdefgh" }) as never);
addEventListener(
type: "connect" | "disconnect",
listener: (this: this, ev: USBConnectionEvent) => void,
useCapture?: boolean,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
_type: unknown,
_listener: unknown,
_options?: unknown,
): void {
throw new Error("Method not implemented.");
}
removeEventListener(
type: "connect" | "disconnect",
callback: (this: this, ev: USBConnectionEvent) => void,
useCapture?: boolean,
): void;
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean,
): void;
removeEventListener(
_type: unknown,
_callback: unknown,
_options?: unknown,
): void {
throw new Error("Method not implemented.");
}
dispatchEvent(_event: Event): boolean {
throw new Error("Method not implemented.");
}
}
describe("AdbDaemonWebUsbDeviceManager", () => {
describe("requestDevice", () => {
it("should accept 0 args", async () => {
const usb = new MockUsb();
const manager = new AdbDaemonWebUsbDeviceManager(usb);
await expect(manager.requestDevice()).resolves.toBeInstanceOf(
AdbDaemonWebUsbDevice,
);
expect(usb.requestDevice).toHaveBeenCalledTimes(1);
expect(usb.requestDevice).toHaveBeenCalledWith({
filters: [ADB_DEFAULT_INTERFACE_FILTER],
});
});
it("should accept undefined filters", async () => {
const usb = new MockUsb();
const manager = new AdbDaemonWebUsbDeviceManager(usb);
await expect(
manager.requestDevice({ filters: undefined }),
).resolves.toBeInstanceOf(AdbDaemonWebUsbDevice);
expect(usb.requestDevice).toHaveBeenCalledTimes(1);
expect(usb.requestDevice).toHaveBeenCalledWith({
filters: [ADB_DEFAULT_INTERFACE_FILTER],
});
});
it("should accept empty filters array", async () => {
const usb = new MockUsb();
const manager = new AdbDaemonWebUsbDeviceManager(usb);
await expect(
manager.requestDevice({ filters: [] }),
).resolves.toBeInstanceOf(AdbDaemonWebUsbDevice);
expect(usb.requestDevice).toHaveBeenCalledTimes(1);
expect(usb.requestDevice).toHaveBeenCalledWith({
filters: [ADB_DEFAULT_INTERFACE_FILTER],
});
});
it("should accept empty filter object", async () => {
const usb = new MockUsb();
const manager = new AdbDaemonWebUsbDeviceManager(usb);
await expect(
manager.requestDevice({ filters: [{}] }),
).resolves.toBeInstanceOf(AdbDaemonWebUsbDevice);
expect(usb.requestDevice).toHaveBeenCalledTimes(1);
expect(usb.requestDevice).toHaveBeenCalledWith({
filters: [ADB_DEFAULT_INTERFACE_FILTER],
});
});
it("should merge missing fields with default values", async () => {
const usb = new MockUsb();
const manager = new AdbDaemonWebUsbDeviceManager(usb);
const filter: USBDeviceFilter = { vendorId: 0x1234 };
await expect(
manager.requestDevice({ filters: [filter] }),
).resolves.toBeInstanceOf(AdbDaemonWebUsbDevice);
expect(usb.requestDevice).toHaveBeenCalledTimes(1);
expect(usb.requestDevice).toHaveBeenCalledWith({
filters: [
{
...ADB_DEFAULT_INTERFACE_FILTER,
...filter,
},
],
});
});
it("should merge undefined fields with default values", async () => {
const usb = new MockUsb();
const manager = new AdbDaemonWebUsbDeviceManager(usb);
const filter: USBDeviceFilter = {
classCode: undefined,
vendorId: 0x1234,
};
await expect(
manager.requestDevice({ filters: [filter] }),
).resolves.toBeInstanceOf(AdbDaemonWebUsbDevice);
expect(usb.requestDevice).toHaveBeenCalledTimes(1);
expect(usb.requestDevice).toHaveBeenCalledWith({
filters: [
{
...filter,
...ADB_DEFAULT_INTERFACE_FILTER,
},
],
});
});
it("should accept multiple filters", async () => {
const usb = new MockUsb();
const manager = new AdbDaemonWebUsbDeviceManager(usb);
const filter1: USBDeviceFilter = { vendorId: 0x1234 };
const filter2: USBDeviceFilter = { classCode: 0xaa };
await expect(
manager.requestDevice({ filters: [filter1, filter2] }),
).resolves.toBeInstanceOf(AdbDaemonWebUsbDevice);
expect(usb.requestDevice).toHaveBeenCalledTimes(1);
expect(usb.requestDevice).toHaveBeenCalledWith({
filters: [
{
...ADB_DEFAULT_INTERFACE_FILTER,
...filter1,
},
{
...ADB_DEFAULT_INTERFACE_FILTER,
...filter2,
},
],
});
});
});
});

View file

@ -1,5 +1,4 @@
import { ADB_DEFAULT_DEVICE_FILTER, AdbDaemonWebUsbDevice } from "./device.js";
import type { AdbDeviceFilter } from "./utils.js";
import { AdbDaemonWebUsbDevice, toAdbDeviceFilters } from "./device.js";
import {
findUsbAlternateInterface,
getSerialNumber,
@ -8,7 +7,7 @@ import {
export namespace AdbDaemonWebUsbDeviceManager {
export interface RequestDeviceOptions {
filters?: AdbDeviceFilter[] | undefined;
filters?: USBDeviceFilter[] | undefined;
exclusionFilters?: USBDeviceFilter[] | undefined;
}
}
@ -44,28 +43,21 @@ export class AdbDaemonWebUsbDeviceManager {
* It must have `classCode`, `subclassCode` and `protocolCode` fields for selecting the ADB interface,
* but might also have `vendorId`, `productId` or `serialNumber` fields to limit the displayed device list.
*
* Defaults to {@link ADB_DEFAULT_DEVICE_FILTER}.
* Defaults to {@link ADB_DEFAULT_INTERFACE_FILTER}.
* @returns An {@link AdbDaemonWebUsbDevice} instance if the user selected a device,
* or `undefined` if the user cancelled the device picker.
*/
async requestDevice(
options: AdbDaemonWebUsbDeviceManager.RequestDeviceOptions = {},
): Promise<AdbDaemonWebUsbDevice | undefined> {
if (!options.filters) {
options.filters = [ADB_DEFAULT_DEVICE_FILTER];
} else if (options.filters.length === 0) {
throw new TypeError("filters must not be empty");
}
const filters = toAdbDeviceFilters(options.filters);
try {
const device = await this.#usbManager.requestDevice(
options as USBDeviceRequestOptions,
);
return new AdbDaemonWebUsbDevice(
device,
options.filters,
this.#usbManager,
);
const device = await this.#usbManager.requestDevice({
filters,
exclusionFilters: options.exclusionFilters,
});
return new AdbDaemonWebUsbDevice(device, filters, this.#usbManager);
} catch (e) {
// No device selected
if (isErrorName(e, "NotFoundError")) {
@ -85,34 +77,35 @@ export class AdbDaemonWebUsbDeviceManager {
* It must have `classCode`, `subclassCode` and `protocolCode` fields for selecting the ADB interface,
* but might also have `vendorId`, `productId` or `serialNumber` fields to limit the device list.
*
* Defaults to {@link ADB_DEFAULT_DEVICE_FILTER}.
* Defaults to {@link ADB_DEFAULT_INTERFACE_FILTER}.
* @returns An array of {@link AdbDaemonWebUsbDevice} instances for all connected and authenticated devices.
*/
getDevices(
filters?: USBDeviceFilter[] | undefined,
): Promise<AdbDaemonWebUsbDevice[]>;
async getDevices(
filters: AdbDeviceFilter[] = [ADB_DEFAULT_DEVICE_FILTER],
filters_: USBDeviceFilter[] | undefined,
): Promise<AdbDaemonWebUsbDevice[]> {
if (filters.length === 0) {
throw new TypeError("filters must not be empty");
}
const filters = toAdbDeviceFilters(filters_);
const devices = await this.#usbManager.getDevices();
return devices
.filter((device) => {
for (const filter of filters) {
if (
"vendorId" in filter &&
filter.vendorId !== undefined &&
device.vendorId !== filter.vendorId
) {
continue;
}
if (
"productId" in filter &&
filter.productId !== undefined &&
device.productId !== filter.productId
) {
continue;
}
if (
"serialNumber" in filter &&
filter.serialNumber !== undefined &&
getSerialNumber(device) !== filter.serialNumber
) {
continue;

View file

@ -7,18 +7,22 @@ export function isErrorName(e: unknown, name: string): boolean {
);
}
export type PickNonNullable<T, K extends keyof T> = {
[P in K]-?: NonNullable<T[P]>;
};
/**
* `classCode`, `subclassCode` and `protocolCode` are required
* for selecting correct USB configuration and interface.
*/
export type AdbDeviceFilter = USBDeviceFilter &
Required<
Pick<USBDeviceFilter, "classCode" | "subclassCode" | "protocolCode">
export type UsbInterfaceFilter = PickNonNullable<
USBDeviceFilter,
"classCode" | "subclassCode" | "protocolCode"
>;
function alternateMatchesFilter(
alternate: USBAlternateInterface,
filters: AdbDeviceFilter[],
filters: UsbInterfaceFilter[],
) {
return filters.some(
(filter) =>
@ -30,7 +34,7 @@ function alternateMatchesFilter(
export function findUsbAlternateInterface(
device: USBDevice,
filters: AdbDeviceFilter[],
filters: UsbInterfaceFilter[],
) {
for (const configuration of device.configurations) {
for (const interface_ of configuration.interfaces) {
@ -56,3 +60,42 @@ export function getSerialNumber(device: USBDevice) {
return padNumber(device.vendorId) + "x" + padNumber(device.productId);
}
/**
* Find the first pair of input and output endpoints from an alternate interface.
*
* ADB interface only has two endpoints, one for input and one for output.
*/
export function findUsbEndpoints(endpoints: USBEndpoint[]) {
if (endpoints.length === 0) {
throw new TypeError("No endpoints given");
}
let inEndpoint: USBEndpoint | undefined;
let outEndpoint: USBEndpoint | undefined;
for (const endpoint of endpoints) {
switch (endpoint.direction) {
case "in":
inEndpoint = endpoint;
if (outEndpoint) {
return { inEndpoint, outEndpoint };
}
break;
case "out":
outEndpoint = endpoint;
if (inEndpoint) {
return { inEndpoint, outEndpoint };
}
break;
}
}
if (!inEndpoint) {
throw new TypeError("No input endpoint found.");
}
if (!outEndpoint) {
throw new TypeError("No output endpoint found.");
}
throw new Error("unreachable");
}

View file

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.build.json",
"compilerOptions": {
"types": [
"w3c-web-usb",
"node"
]
},
"exclude": []
}

View file

@ -15,6 +15,9 @@
<a href="https://github.com/yume-chan/ya-webadb/releases">
<img alt="GitHub release" src="https://img.shields.io/github/v/release/yume-chan/ya-webadb?logo=github">
</a>
<a href="https://bundlephobia.com/package/@yume-chan/adb">
<img alt="Package Size" src="https://img.shields.io/bundlephobia/minzip/%40yume-chan%2Fadb">
</a>
<a href="https://www.npmjs.com/package/@yume-chan/adb">
<img alt="npm" src="https://img.shields.io/npm/dm/%40yume-chan/adb?logo=npm">
</a>

View file

@ -27,9 +27,9 @@
"scripts": {
"build": "tsc -b tsconfig.build.json",
"build:watch": "tsc -b tsconfig.build.json",
"test": "cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" TS_JEST_DISABLE_VER_CHECKER=true jest --coverage",
"lint": "run-eslint && prettier src/**/*.ts --write --tab-width 4",
"prepublishOnly": "npm run build"
"prepublishOnly": "npm run build",
"test": "cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" TS_JEST_DISABLE_VER_CHECKER=true jest --coverage"
},
"dependencies": {
"@yume-chan/async": "^2.2.0",

View file

@ -4,6 +4,9 @@
{
"path": "../event/tsconfig.build.json"
},
{
"path": "../no-data-view/tsconfig.build.json"
},
{
"path": "../stream-extra/tsconfig.build.json"
},

View file

@ -1,8 +1,5 @@
{
"references": [
{
"path": "../no-data-view/tsconfig.build.json"
},
{
"path": "./tsconfig.test.json"
},