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

View file

@ -3,10 +3,7 @@
import * as assert from "node:assert"; import * as assert from "node:assert";
import { describe, it, mock } from "node:test"; import { describe, it, mock } from "node:test";
import { import { AdbDaemonWebUsbDevice, AdbDefaultInterfaceFilter } from "./device.js";
ADB_DEFAULT_INTERFACE_FILTER,
AdbDaemonWebUsbDevice,
} from "./device.js";
import { AdbDaemonWebUsbDeviceManager } from "./manager.js"; import { AdbDaemonWebUsbDeviceManager } from "./manager.js";
class MockUsb implements USB { class MockUsb implements USB {
@ -69,7 +66,7 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
assert.strictEqual(usb.requestDevice.mock.callCount(), 1); assert.strictEqual(usb.requestDevice.mock.callCount(), 1);
assert.deepEqual(usb.requestDevice.mock.calls[0]?.arguments, [ assert.deepEqual(usb.requestDevice.mock.calls[0]?.arguments, [
{ {
filters: [ADB_DEFAULT_INTERFACE_FILTER], filters: [AdbDefaultInterfaceFilter],
exclusionFilters: undefined, exclusionFilters: undefined,
}, },
]); ]);
@ -85,7 +82,7 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
assert.strictEqual(usb.requestDevice.mock.callCount(), 1); assert.strictEqual(usb.requestDevice.mock.callCount(), 1);
assert.deepEqual(usb.requestDevice.mock.calls[0]?.arguments, [ assert.deepEqual(usb.requestDevice.mock.calls[0]?.arguments, [
{ {
filters: [ADB_DEFAULT_INTERFACE_FILTER], filters: [AdbDefaultInterfaceFilter],
exclusionFilters: undefined, exclusionFilters: undefined,
}, },
]); ]);
@ -101,7 +98,7 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
assert.strictEqual(usb.requestDevice.mock.callCount(), 1); assert.strictEqual(usb.requestDevice.mock.callCount(), 1);
assert.deepEqual(usb.requestDevice.mock.calls[0]?.arguments, [ assert.deepEqual(usb.requestDevice.mock.calls[0]?.arguments, [
{ {
filters: [ADB_DEFAULT_INTERFACE_FILTER], filters: [AdbDefaultInterfaceFilter],
exclusionFilters: undefined, exclusionFilters: undefined,
}, },
]); ]);
@ -117,7 +114,7 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
assert.strictEqual(usb.requestDevice.mock.callCount(), 1); assert.strictEqual(usb.requestDevice.mock.callCount(), 1);
assert.deepEqual(usb.requestDevice.mock.calls[0]?.arguments, [ assert.deepEqual(usb.requestDevice.mock.calls[0]?.arguments, [
{ {
filters: [ADB_DEFAULT_INTERFACE_FILTER], filters: [AdbDefaultInterfaceFilter],
exclusionFilters: undefined, exclusionFilters: undefined,
}, },
]); ]);
@ -136,7 +133,7 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
{ {
filters: [ filters: [
{ {
...ADB_DEFAULT_INTERFACE_FILTER, ...AdbDefaultInterfaceFilter,
...filter, ...filter,
}, },
], ],
@ -162,7 +159,7 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
filters: [ filters: [
{ {
...filter, ...filter,
...ADB_DEFAULT_INTERFACE_FILTER, ...AdbDefaultInterfaceFilter,
}, },
], ],
exclusionFilters: undefined, exclusionFilters: undefined,
@ -185,11 +182,11 @@ describe("AdbDaemonWebUsbDeviceManager", () => {
{ {
filters: [ filters: [
{ {
...ADB_DEFAULT_INTERFACE_FILTER, ...AdbDefaultInterfaceFilter,
...filter1, ...filter1,
}, },
{ {
...ADB_DEFAULT_INTERFACE_FILTER, ...AdbDefaultInterfaceFilter,
...filter2, ...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 { AdbDaemonWebUsbDeviceObserver } from "./observer.js";
import { isErrorName, matchesFilters } from "./utils.js"; import { isErrorName, matchFilters } from "./utils.js";
export namespace AdbDaemonWebUsbDeviceManager { export namespace AdbDaemonWebUsbDeviceManager {
export interface RequestDeviceOptions { export interface RequestDeviceOptions {
@ -37,14 +40,30 @@ export class AdbDaemonWebUsbDeviceManager {
async requestDevice( async requestDevice(
options: AdbDaemonWebUsbDeviceManager.RequestDeviceOptions = {}, options: AdbDaemonWebUsbDeviceManager.RequestDeviceOptions = {},
): Promise<AdbDaemonWebUsbDevice | undefined> { ): Promise<AdbDaemonWebUsbDevice | undefined> {
const filters = toAdbDeviceFilters(options.filters); const filters = mergeDefaultAdbInterfaceFilter(options.filters);
try { try {
const device = await this.#usbManager.requestDevice({ const device = await this.#usbManager.requestDevice({
filters, filters,
exclusionFilters: options.exclusionFilters, 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) { } catch (e) {
// No device selected // No device selected
if (isErrorName(e, "NotFoundError")) { if (isErrorName(e, "NotFoundError")) {
@ -58,26 +77,37 @@ export class AdbDaemonWebUsbDeviceManager {
/** /**
* Get all connected and requested devices that match the specified filters. * Get all connected and requested devices that match the specified filters.
*/ */
getDevices(filters?: USBDeviceFilter[]): Promise<AdbDaemonWebUsbDevice[]>;
async getDevices( async getDevices(
filters_: USBDeviceFilter[] | undefined, options: AdbDaemonWebUsbDeviceManager.RequestDeviceOptions = {},
): Promise<AdbDaemonWebUsbDevice[]> { ): Promise<AdbDaemonWebUsbDevice[]> {
const filters = toAdbDeviceFilters(filters_); const filters = mergeDefaultAdbInterfaceFilter(options.filters);
const devices = await this.#usbManager.getDevices(); const devices = await this.#usbManager.getDevices();
return devices // filter map
.filter((device) => matchesFilters(device, filters)) const result: AdbDaemonWebUsbDevice[] = [];
.map( for (const device of devices) {
(device) => const interface_ = matchFilters(
device,
filters,
options.exclusionFilters,
);
if (interface_) {
result.push(
new AdbDaemonWebUsbDevice( new AdbDaemonWebUsbDevice(
device, device,
filters, interface_,
this.#usbManager, this.#usbManager,
), ),
); );
}
}
return result;
} }
trackDevices(filters?: USBDeviceFilter[]): AdbDaemonWebUsbDeviceObserver { trackDevices(
return new AdbDaemonWebUsbDeviceObserver(this.#usbManager, filters); options: AdbDaemonWebUsbDeviceManager.RequestDeviceOptions = {},
): AdbDaemonWebUsbDeviceObserver {
return new AdbDaemonWebUsbDeviceObserver(this.#usbManager, options);
} }
} }

View file

@ -1,9 +1,13 @@
import type { DeviceObserver } from "@yume-chan/adb"; import type { DeviceObserver } from "@yume-chan/adb";
import { EventEmitter } from "@yume-chan/event"; 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 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 * 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 export class AdbDaemonWebUsbDeviceObserver
implements DeviceObserver<AdbDaemonWebUsbDevice> implements DeviceObserver<AdbDaemonWebUsbDevice>
{ {
#filters: UsbInterfaceFilter[]; #filters: (USBDeviceFilter & UsbInterfaceFilter)[];
#exclusionFilters?: USBDeviceFilter[] | undefined;
#usbManager: USB; #usbManager: USB;
#onError = new EventEmitter<Error>(); #onError = new EventEmitter<Error>();
@ -29,8 +34,12 @@ export class AdbDaemonWebUsbDeviceObserver
current: AdbDaemonWebUsbDevice[] = []; current: AdbDaemonWebUsbDevice[] = [];
constructor(usb: USB, filters?: USBDeviceFilter[]) { constructor(
this.#filters = toAdbDeviceFilters(filters); usb: USB,
options: AdbDaemonWebUsbDeviceManager.RequestDeviceOptions = {},
) {
this.#filters = mergeDefaultAdbInterfaceFilter(options.filters);
this.#exclusionFilters = options.exclusionFilters;
this.#usbManager = usb; this.#usbManager = usb;
this.#usbManager.addEventListener("connect", this.#handleConnect); this.#usbManager.addEventListener("connect", this.#handleConnect);
@ -38,13 +47,18 @@ export class AdbDaemonWebUsbDeviceObserver
} }
#handleConnect = (e: USBConnectionEvent) => { #handleConnect = (e: USBConnectionEvent) => {
if (!matchesFilters(e.device, this.#filters)) { const interface_ = matchFilters(
e.device,
this.#filters,
this.#exclusionFilters,
);
if (!interface_) {
return; return;
} }
const device = new AdbDaemonWebUsbDevice( const device = new AdbDaemonWebUsbDevice(
e.device, e.device,
this.#filters, interface_,
this.#usbManager, this.#usbManager,
); );
this.#onDeviceAdd.fire([device]); this.#onDeviceAdd.fire([device]);
@ -71,5 +85,10 @@ export class AdbDaemonWebUsbDeviceObserver
"disconnect", "disconnect",
this.#handleDisconnect, 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" "classCode" | "subclassCode" | "protocolCode"
>; >;
function alternateMatchesFilter( export function isUsbInterfaceFilter(
alternate: USBAlternateInterface, filter: USBDeviceFilter,
filters: UsbInterfaceFilter[], ): filter is UsbInterfaceFilter {
) { return (
return filters.some( filter.classCode !== undefined &&
(filter) => filter.subclassCode !== undefined &&
alternate.interfaceClass === filter.classCode && filter.protocolCode !== undefined
alternate.interfaceSubclass === filter.subclassCode &&
alternate.interfaceProtocol === filter.protocolCode,
); );
} }
export function findUsbAlternateInterface( function matchUsbInterfaceFilter(
device: USBDevice, alternate: USBAlternateInterface,
filters: UsbInterfaceFilter[], 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 configuration of device.configurations) {
for (const interface_ of configuration.interfaces) { for (const interface_ of configuration.interfaces) {
for (const alternate of interface_.alternates) { for (const alternate of interface_.alternates) {
if (alternateMatchesFilter(alternate, filters)) { if (matchUsbInterfaceFilter(alternate, filter)) {
return { configuration, interface_, alternate }; return { configuration, interface_, alternate };
} }
} }
} }
} }
return undefined;
throw new TypeError("No matched alternate interface found");
} }
function padNumber(value: number) { function padNumber(value: number) {
@ -100,35 +114,68 @@ export function findUsbEndpoints(endpoints: USBEndpoint[]) {
throw new Error("unreachable"); 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, device: USBDevice,
filters: (USBDeviceFilter & UsbInterfaceFilter)[], filters: (USBDeviceFilter & UsbInterfaceFilter)[],
) { exclusionFilters?: USBDeviceFilter[],
for (const filter of filters) { ): UsbInterfaceIdentifier | false;
if ( export function matchFilters(
filter.vendorId !== undefined && device: USBDevice,
device.vendorId !== filter.vendorId filters: USBDeviceFilter[],
) { exclusionFilters?: USBDeviceFilter[],
continue; ): boolean;
} export function matchFilters(
if ( device: USBDevice,
filter.productId !== undefined && filters: USBDeviceFilter[],
device.productId !== filter.productId exclusionFilters?: USBDeviceFilter[],
) { ): UsbInterfaceIdentifier | boolean {
continue; if (exclusionFilters && exclusionFilters.length > 0) {
} if (matchFilters(device, exclusionFilters)) {
if ( return false;
filter.serialNumber !== undefined &&
getSerialNumber(device) !== filter.serialNumber
) {
continue;
} }
}
try { for (const filter of filters) {
findUsbAlternateInterface(device, filters); const result = matchFilter(device, filter);
return true; if (result) {
} catch { return result;
continue;
} }
} }
return false; return false;