feat(adb0daemon-webusb): accept exclusionFilters in getDevices and DeviceObserver

This commit is contained in:
Simon Chan 2024-11-30 09:47:12 +08:00
parent ea5002bc87
commit c68e216613
No known key found for this signature in database
GPG key ID: A8B69F750B9BCEDD
6 changed files with 190 additions and 101 deletions

View file

@ -0,0 +1,5 @@
---
"@yume-chan/adb-daemon-webusb": patch
---
Accept exclusionFilters in getDevices and DeviceObserver

View file

@ -22,39 +22,31 @@ import {
import type { ExactReadable } from "@yume-chan/struct";
import { EmptyUint8Array } from "@yume-chan/struct";
import type { UsbInterfaceFilter } from "./utils.js";
import {
findUsbAlternateInterface,
findUsbEndpoints,
getSerialNumber,
isErrorName,
} from "./utils.js";
import type { UsbInterfaceFilter, UsbInterfaceIdentifier } from "./utils.js";
import { findUsbEndpoints, getSerialNumber, isErrorName } from "./utils.js";
/**
* The default filter for ADB devices, as defined by Google.
*/
export const ADB_DEFAULT_INTERFACE_FILTER = {
export const AdbDefaultInterfaceFilter = {
classCode: 0xff,
subclassCode: 0x42,
protocolCode: 1,
} as const satisfies UsbInterfaceFilter;
export function toAdbDeviceFilters(
export function mergeDefaultAdbInterfaceFilter(
filters: USBDeviceFilter[] | undefined,
): (USBDeviceFilter & UsbInterfaceFilter)[] {
if (!filters || filters.length === 0) {
return [ADB_DEFAULT_INTERFACE_FILTER];
return [AdbDefaultInterfaceFilter];
} else {
return filters.map((filter) => ({
...filter,
classCode:
filter.classCode ?? ADB_DEFAULT_INTERFACE_FILTER.classCode,
classCode: filter.classCode ?? AdbDefaultInterfaceFilter.classCode,
subclassCode:
filter.subclassCode ??
ADB_DEFAULT_INTERFACE_FILTER.subclassCode,
filter.subclassCode ?? AdbDefaultInterfaceFilter.subclassCode,
protocolCode:
filter.protocolCode ??
ADB_DEFAULT_INTERFACE_FILTER.protocolCode,
filter.protocolCode ?? AdbDefaultInterfaceFilter.protocolCode,
}));
}
}
@ -262,7 +254,7 @@ export class AdbDaemonWebUsbConnection
}
export class AdbDaemonWebUsbDevice implements AdbDaemonDevice {
#filters: UsbInterfaceFilter[];
#interface: UsbInterfaceIdentifier;
#usbManager: USB;
#raw: USBDevice;
@ -287,22 +279,24 @@ export class AdbDaemonWebUsbDevice implements AdbDaemonDevice {
*/
constructor(
device: USBDevice,
filters: UsbInterfaceFilter[],
interface_: UsbInterfaceIdentifier,
usbManager: USB,
) {
this.#raw = device;
this.#serial = getSerialNumber(device);
this.#filters = filters;
this.#interface = interface_;
this.#usbManager = usbManager;
}
async #claimInterface(): Promise<[USBEndpoint, USBEndpoint]> {
async #claimInterface(): Promise<{
inEndpoint: USBEndpoint;
outEndpoint: USBEndpoint;
}> {
if (!this.#raw.opened) {
await this.#raw.open();
}
const { configuration, interface_, alternate } =
findUsbAlternateInterface(this.#raw, this.#filters);
const { configuration, interface_, alternate } = this.#interface;
if (
this.#raw.configuration?.configurationValue !==
@ -336,17 +330,14 @@ export class AdbDaemonWebUsbDevice implements AdbDaemonDevice {
);
}
const { inEndpoint, outEndpoint } = findUsbEndpoints(
alternate.endpoints,
);
return [inEndpoint, outEndpoint];
return findUsbEndpoints(alternate.endpoints);
}
/**
* Open the device and create a new connection to the ADB Daemon.
*/
async connect(): Promise<AdbDaemonWebUsbConnection> {
const [inEndpoint, outEndpoint] = await this.#claimInterface();
const { inEndpoint, outEndpoint } = await this.#claimInterface();
return new AdbDaemonWebUsbConnection(
this,
inEndpoint,

View file

@ -3,10 +3,7 @@
import * as assert from "node:assert";
import { describe, it, mock } from "node:test";
import {
ADB_DEFAULT_INTERFACE_FILTER,
AdbDaemonWebUsbDevice,
} from "./device.js";
import { AdbDaemonWebUsbDevice, AdbDefaultInterfaceFilter } from "./device.js";
import { AdbDaemonWebUsbDeviceManager } from "./manager.js";
class MockUsb implements USB {
@ -69,7 +66,7 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
assert.strictEqual(usb.requestDevice.mock.callCount(), 1);
assert.deepEqual(usb.requestDevice.mock.calls[0]?.arguments, [
{
filters: [ADB_DEFAULT_INTERFACE_FILTER],
filters: [AdbDefaultInterfaceFilter],
exclusionFilters: undefined,
},
]);
@ -85,7 +82,7 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
assert.strictEqual(usb.requestDevice.mock.callCount(), 1);
assert.deepEqual(usb.requestDevice.mock.calls[0]?.arguments, [
{
filters: [ADB_DEFAULT_INTERFACE_FILTER],
filters: [AdbDefaultInterfaceFilter],
exclusionFilters: undefined,
},
]);
@ -101,7 +98,7 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
assert.strictEqual(usb.requestDevice.mock.callCount(), 1);
assert.deepEqual(usb.requestDevice.mock.calls[0]?.arguments, [
{
filters: [ADB_DEFAULT_INTERFACE_FILTER],
filters: [AdbDefaultInterfaceFilter],
exclusionFilters: undefined,
},
]);
@ -117,7 +114,7 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
assert.strictEqual(usb.requestDevice.mock.callCount(), 1);
assert.deepEqual(usb.requestDevice.mock.calls[0]?.arguments, [
{
filters: [ADB_DEFAULT_INTERFACE_FILTER],
filters: [AdbDefaultInterfaceFilter],
exclusionFilters: undefined,
},
]);
@ -136,7 +133,7 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
{
filters: [
{
...ADB_DEFAULT_INTERFACE_FILTER,
...AdbDefaultInterfaceFilter,
...filter,
},
],
@ -162,7 +159,7 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
filters: [
{
...filter,
...ADB_DEFAULT_INTERFACE_FILTER,
...AdbDefaultInterfaceFilter,
},
],
exclusionFilters: undefined,
@ -185,11 +182,11 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
{
filters: [
{
...ADB_DEFAULT_INTERFACE_FILTER,
...AdbDefaultInterfaceFilter,
...filter1,
},
{
...ADB_DEFAULT_INTERFACE_FILTER,
...AdbDefaultInterfaceFilter,
...filter2,
},
],

View file

@ -1,6 +1,9 @@
import { AdbDaemonWebUsbDevice, toAdbDeviceFilters } from "./device.js";
import {
AdbDaemonWebUsbDevice,
mergeDefaultAdbInterfaceFilter,
} from "./device.js";
import { AdbDaemonWebUsbDeviceObserver } from "./observer.js";
import { isErrorName, matchesFilters } from "./utils.js";
import { isErrorName, matchFilters } from "./utils.js";
export namespace AdbDaemonWebUsbDeviceManager {
export interface RequestDeviceOptions {
@ -37,14 +40,30 @@ export class AdbDaemonWebUsbDeviceManager {
async requestDevice(
options: AdbDaemonWebUsbDeviceManager.RequestDeviceOptions = {},
): Promise<AdbDaemonWebUsbDevice | undefined> {
const filters = toAdbDeviceFilters(options.filters);
const filters = mergeDefaultAdbInterfaceFilter(options.filters);
try {
const device = await this.#usbManager.requestDevice({
filters,
exclusionFilters: options.exclusionFilters,
});
return new AdbDaemonWebUsbDevice(device, filters, this.#usbManager);
const interface_ = matchFilters(
device,
filters,
options.exclusionFilters,
);
if (!interface_) {
// `#usbManager` doesn't support `exclusionFilters`,
// selected device is invalid
return undefined;
}
return new AdbDaemonWebUsbDevice(
device,
interface_,
this.#usbManager,
);
} catch (e) {
// No device selected
if (isErrorName(e, "NotFoundError")) {
@ -58,26 +77,37 @@ export class AdbDaemonWebUsbDeviceManager {
/**
* Get all connected and requested devices that match the specified filters.
*/
getDevices(filters?: USBDeviceFilter[]): Promise<AdbDaemonWebUsbDevice[]>;
async getDevices(
filters_: USBDeviceFilter[] | undefined,
options: AdbDaemonWebUsbDeviceManager.RequestDeviceOptions = {},
): Promise<AdbDaemonWebUsbDevice[]> {
const filters = toAdbDeviceFilters(filters_);
const filters = mergeDefaultAdbInterfaceFilter(options.filters);
const devices = await this.#usbManager.getDevices();
return devices
.filter((device) => matchesFilters(device, filters))
.map(
(device) =>
// filter map
const result: AdbDaemonWebUsbDevice[] = [];
for (const device of devices) {
const interface_ = matchFilters(
device,
filters,
options.exclusionFilters,
);
if (interface_) {
result.push(
new AdbDaemonWebUsbDevice(
device,
filters,
interface_,
this.#usbManager,
),
);
);
}
}
return result;
}
trackDevices(filters?: USBDeviceFilter[]): AdbDaemonWebUsbDeviceObserver {
return new AdbDaemonWebUsbDeviceObserver(this.#usbManager, filters);
trackDevices(
options: AdbDaemonWebUsbDeviceManager.RequestDeviceOptions = {},
): AdbDaemonWebUsbDeviceObserver {
return new AdbDaemonWebUsbDeviceObserver(this.#usbManager, options);
}
}

View file

@ -1,9 +1,13 @@
import type { DeviceObserver } from "@yume-chan/adb";
import { EventEmitter } from "@yume-chan/event";
import { AdbDaemonWebUsbDevice, toAdbDeviceFilters } from "./device.js";
import {
AdbDaemonWebUsbDevice,
mergeDefaultAdbInterfaceFilter,
} from "./device.js";
import type { AdbDaemonWebUsbDeviceManager } from "./manager.js";
import type { UsbInterfaceFilter } from "./utils.js";
import { matchesFilters } from "./utils.js";
import { matchFilters } from "./utils.js";
/**
* A watcher that listens for new WebUSB devices and notifies the callback when
@ -12,7 +16,8 @@ import { matchesFilters } from "./utils.js";
export class AdbDaemonWebUsbDeviceObserver
implements DeviceObserver<AdbDaemonWebUsbDevice>
{
#filters: UsbInterfaceFilter[];
#filters: (USBDeviceFilter & UsbInterfaceFilter)[];
#exclusionFilters?: USBDeviceFilter[] | undefined;
#usbManager: USB;
#onError = new EventEmitter<Error>();
@ -29,8 +34,12 @@ export class AdbDaemonWebUsbDeviceObserver
current: AdbDaemonWebUsbDevice[] = [];
constructor(usb: USB, filters?: USBDeviceFilter[]) {
this.#filters = toAdbDeviceFilters(filters);
constructor(
usb: USB,
options: AdbDaemonWebUsbDeviceManager.RequestDeviceOptions = {},
) {
this.#filters = mergeDefaultAdbInterfaceFilter(options.filters);
this.#exclusionFilters = options.exclusionFilters;
this.#usbManager = usb;
this.#usbManager.addEventListener("connect", this.#handleConnect);
@ -38,13 +47,18 @@ export class AdbDaemonWebUsbDeviceObserver
}
#handleConnect = (e: USBConnectionEvent) => {
if (!matchesFilters(e.device, this.#filters)) {
const interface_ = matchFilters(
e.device,
this.#filters,
this.#exclusionFilters,
);
if (!interface_) {
return;
}
const device = new AdbDaemonWebUsbDevice(
e.device,
this.#filters,
interface_,
this.#usbManager,
);
this.#onDeviceAdd.fire([device]);
@ -71,5 +85,10 @@ export class AdbDaemonWebUsbDeviceObserver
"disconnect",
this.#handleDisconnect,
);
this.#onError.dispose();
this.#onDeviceAdd.dispose();
this.#onDeviceRemove.dispose();
this.#onListChange.dispose();
}
}

View file

@ -20,33 +20,47 @@ export type UsbInterfaceFilter = PickNonNullable<
"classCode" | "subclassCode" | "protocolCode"
>;
function alternateMatchesFilter(
alternate: USBAlternateInterface,
filters: UsbInterfaceFilter[],
) {
return filters.some(
(filter) =>
alternate.interfaceClass === filter.classCode &&
alternate.interfaceSubclass === filter.subclassCode &&
alternate.interfaceProtocol === filter.protocolCode,
export function isUsbInterfaceFilter(
filter: USBDeviceFilter,
): filter is UsbInterfaceFilter {
return (
filter.classCode !== undefined &&
filter.subclassCode !== undefined &&
filter.protocolCode !== undefined
);
}
export function findUsbAlternateInterface(
device: USBDevice,
filters: UsbInterfaceFilter[],
function matchUsbInterfaceFilter(
alternate: USBAlternateInterface,
filter: UsbInterfaceFilter,
) {
return (
alternate.interfaceClass === filter.classCode &&
alternate.interfaceSubclass === filter.subclassCode &&
alternate.interfaceProtocol === filter.protocolCode
);
}
export interface UsbInterfaceIdentifier {
configuration: USBConfiguration;
interface_: USBInterface;
alternate: USBAlternateInterface;
}
export function findUsbInterface(
device: USBDevice,
filter: UsbInterfaceFilter,
): UsbInterfaceIdentifier | undefined {
for (const configuration of device.configurations) {
for (const interface_ of configuration.interfaces) {
for (const alternate of interface_.alternates) {
if (alternateMatchesFilter(alternate, filters)) {
if (matchUsbInterfaceFilter(alternate, filter)) {
return { configuration, interface_, alternate };
}
}
}
}
throw new TypeError("No matched alternate interface found");
return undefined;
}
function padNumber(value: number) {
@ -100,35 +114,68 @@ export function findUsbEndpoints(endpoints: USBEndpoint[]) {
throw new Error("unreachable");
}
export function matchesFilters(
export function matchFilter(
device: USBDevice,
filter: USBDeviceFilter & UsbInterfaceFilter,
): UsbInterfaceIdentifier | false;
export function matchFilter(
device: USBDevice,
filter: USBDeviceFilter,
): boolean;
export function matchFilter(
device: USBDevice,
filter: USBDeviceFilter,
): UsbInterfaceIdentifier | boolean {
if (filter.vendorId !== undefined && device.vendorId !== filter.vendorId) {
return false;
}
if (
filter.productId !== undefined &&
device.productId !== filter.productId
) {
return false;
}
if (
filter.serialNumber !== undefined &&
getSerialNumber(device) !== filter.serialNumber
) {
return false;
}
if (isUsbInterfaceFilter(filter)) {
return findUsbInterface(device, filter) || false;
}
return true;
}
export function matchFilters(
device: USBDevice,
filters: (USBDeviceFilter & UsbInterfaceFilter)[],
) {
for (const filter of filters) {
if (
filter.vendorId !== undefined &&
device.vendorId !== filter.vendorId
) {
continue;
}
if (
filter.productId !== undefined &&
device.productId !== filter.productId
) {
continue;
}
if (
filter.serialNumber !== undefined &&
getSerialNumber(device) !== filter.serialNumber
) {
continue;
exclusionFilters?: USBDeviceFilter[],
): UsbInterfaceIdentifier | false;
export function matchFilters(
device: USBDevice,
filters: USBDeviceFilter[],
exclusionFilters?: USBDeviceFilter[],
): boolean;
export function matchFilters(
device: USBDevice,
filters: USBDeviceFilter[],
exclusionFilters?: USBDeviceFilter[],
): UsbInterfaceIdentifier | boolean {
if (exclusionFilters && exclusionFilters.length > 0) {
if (matchFilters(device, exclusionFilters)) {
return false;
}
}
try {
findUsbAlternateInterface(device, filters);
return true;
} catch {
continue;
for (const filter of filters) {
const result = matchFilter(device, filter);
if (result) {
return result;
}
}
return false;