diff --git a/.changeset/cyan-readers-fail.md b/.changeset/cyan-readers-fail.md new file mode 100644 index 00000000..158b8dfc --- /dev/null +++ b/.changeset/cyan-readers-fail.md @@ -0,0 +1,5 @@ +--- +"@yume-chan/adb-daemon-webusb": patch +--- + +Accept exclusionFilters in getDevices and DeviceObserver diff --git a/libraries/adb-daemon-webusb/src/device.ts b/libraries/adb-daemon-webusb/src/device.ts index b03d91df..e053ca57 100644 --- a/libraries/adb-daemon-webusb/src/device.ts +++ b/libraries/adb-daemon-webusb/src/device.ts @@ -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 { - const [inEndpoint, outEndpoint] = await this.#claimInterface(); + const { inEndpoint, outEndpoint } = await this.#claimInterface(); return new AdbDaemonWebUsbConnection( this, inEndpoint, diff --git a/libraries/adb-daemon-webusb/src/manager.spec.ts b/libraries/adb-daemon-webusb/src/manager.spec.ts index ebfe24fd..f260f15f 100644 --- a/libraries/adb-daemon-webusb/src/manager.spec.ts +++ b/libraries/adb-daemon-webusb/src/manager.spec.ts @@ -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, }, ], diff --git a/libraries/adb-daemon-webusb/src/manager.ts b/libraries/adb-daemon-webusb/src/manager.ts index f036fe1a..73be5fec 100644 --- a/libraries/adb-daemon-webusb/src/manager.ts +++ b/libraries/adb-daemon-webusb/src/manager.ts @@ -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 { - 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; async getDevices( - filters_: USBDeviceFilter[] | undefined, + options: AdbDaemonWebUsbDeviceManager.RequestDeviceOptions = {}, ): Promise { - 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); } } diff --git a/libraries/adb-daemon-webusb/src/observer.ts b/libraries/adb-daemon-webusb/src/observer.ts index 51cba34b..580503a8 100644 --- a/libraries/adb-daemon-webusb/src/observer.ts +++ b/libraries/adb-daemon-webusb/src/observer.ts @@ -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 { - #filters: UsbInterfaceFilter[]; + #filters: (USBDeviceFilter & UsbInterfaceFilter)[]; + #exclusionFilters?: USBDeviceFilter[] | undefined; #usbManager: USB; #onError = new EventEmitter(); @@ -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(); } } diff --git a/libraries/adb-daemon-webusb/src/utils.ts b/libraries/adb-daemon-webusb/src/utils.ts index 9fb3d97c..c30a5953 100644 --- a/libraries/adb-daemon-webusb/src/utils.ts +++ b/libraries/adb-daemon-webusb/src/utils.ts @@ -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;