diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml
index 583885f6..c00b60c5 100644
--- a/common/config/rush/pnpm-lock.yaml
+++ b/common/config/rush/pnpm-lock.yaml
@@ -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
diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json
index cdac9f5e..33219c8e 100644
--- a/common/config/rush/repo-state.json
+++ b/common/config/rush/repo-state.json
@@ -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"
}
diff --git a/libraries/adb-credential-web/README.md b/libraries/adb-credential-web/README.md
index 9437b8e8..c7f55597 100644
--- a/libraries/adb-credential-web/README.md
+++ b/libraries/adb-credential-web/README.md
@@ -15,8 +15,11 @@
+
+
+
-
+
diff --git a/libraries/adb-daemon-webusb/README.md b/libraries/adb-daemon-webusb/README.md
index bf2e3676..734f9faa 100644
--- a/libraries/adb-daemon-webusb/README.md
+++ b/libraries/adb-daemon-webusb/README.md
@@ -15,8 +15,11 @@
-
-
+
+
+
+
+
diff --git a/libraries/adb-daemon-webusb/jest.config.js b/libraries/adb-daemon-webusb/jest.config.js
new file mode 100644
index 00000000..ff68d1cb
--- /dev/null
+++ b/libraries/adb-daemon-webusb/jest.config.js
@@ -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",
+ },
+};
diff --git a/libraries/adb-daemon-webusb/package.json b/libraries/adb-daemon-webusb/package.json
index b0a7c842..ace841cc 100644
--- a/libraries/adb-daemon-webusb/package.json
+++ b/libraries/adb-daemon-webusb/package.json
@@ -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"
}
}
diff --git a/libraries/adb-daemon-webusb/src/device.ts b/libraries/adb-daemon-webusb/src/device.ts
index 8cc42678..ca34f69c 100644
--- a/libraries/adb-daemon-webusb/src/device.ts
+++ b/libraries/adb-daemon-webusb/src/device.ts
@@ -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;
diff --git a/libraries/adb-daemon-webusb/src/manager.spec.ts b/libraries/adb-daemon-webusb/src/manager.spec.ts
new file mode 100644
index 00000000..029a50c4
--- /dev/null
+++ b/libraries/adb-daemon-webusb/src/manager.spec.ts
@@ -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 = jest.fn(async () => []);
+ requestDevice: (options?: USBDeviceRequestOptions) => Promise =
+ 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,
+ },
+ ],
+ });
+ });
+ });
+});
diff --git a/libraries/adb-daemon-webusb/src/manager.ts b/libraries/adb-daemon-webusb/src/manager.ts
index 7583298d..75d2f218 100644
--- a/libraries/adb-daemon-webusb/src/manager.ts
+++ b/libraries/adb-daemon-webusb/src/manager.ts
@@ -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 {
- 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;
async getDevices(
- filters: AdbDeviceFilter[] = [ADB_DEFAULT_DEVICE_FILTER],
+ filters_: USBDeviceFilter[] | undefined,
): Promise {
- 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;
diff --git a/libraries/adb-daemon-webusb/src/utils.ts b/libraries/adb-daemon-webusb/src/utils.ts
index a7baad14..81c177d6 100644
--- a/libraries/adb-daemon-webusb/src/utils.ts
+++ b/libraries/adb-daemon-webusb/src/utils.ts
@@ -7,18 +7,22 @@ export function isErrorName(e: unknown, name: string): boolean {
);
}
+export type PickNonNullable = {
+ [P in K]-?: NonNullable;
+};
+
/**
* `classCode`, `subclassCode` and `protocolCode` are required
* for selecting correct USB configuration and interface.
*/
-export type AdbDeviceFilter = USBDeviceFilter &
- Required<
- Pick
- >;
+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");
+}
diff --git a/libraries/adb-daemon-webusb/tsconfig.test.json b/libraries/adb-daemon-webusb/tsconfig.test.json
new file mode 100644
index 00000000..46c93d25
--- /dev/null
+++ b/libraries/adb-daemon-webusb/tsconfig.test.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.build.json",
+ "compilerOptions": {
+ "types": [
+ "w3c-web-usb",
+ "node"
+ ]
+ },
+ "exclude": []
+}
diff --git a/libraries/adb/README.md b/libraries/adb/README.md
index d5150393..ad74b2b8 100644
--- a/libraries/adb/README.md
+++ b/libraries/adb/README.md
@@ -15,6 +15,9 @@
+
+
+
diff --git a/libraries/adb/package.json b/libraries/adb/package.json
index baa9f4b8..bfcc6904 100644
--- a/libraries/adb/package.json
+++ b/libraries/adb/package.json
@@ -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",
diff --git a/libraries/adb/tsconfig.build.json b/libraries/adb/tsconfig.build.json
index 703b7998..335d0338 100644
--- a/libraries/adb/tsconfig.build.json
+++ b/libraries/adb/tsconfig.build.json
@@ -4,6 +4,9 @@
{
"path": "../event/tsconfig.build.json"
},
+ {
+ "path": "../no-data-view/tsconfig.build.json"
+ },
{
"path": "../stream-extra/tsconfig.build.json"
},
diff --git a/libraries/adb/tsconfig.json b/libraries/adb/tsconfig.json
index 04f9aba7..85fc5a7a 100644
--- a/libraries/adb/tsconfig.json
+++ b/libraries/adb/tsconfig.json
@@ -1,8 +1,5 @@
{
"references": [
- {
- "path": "../no-data-view/tsconfig.build.json"
- },
{
"path": "./tsconfig.test.json"
},