ya-webadb/libraries/adb-daemon-webusb/src/device.ts
2025-09-09 23:37:06 +08:00

334 lines
10 KiB
TypeScript

import type {
AdbDaemonDevice,
AdbPacketData,
AdbPacketInit,
} from "@yume-chan/adb";
import {
AdbPacketHeader,
AdbPacketSerializeStream,
toLocalUint8Array,
unreachable,
} from "@yume-chan/adb";
import type {
Consumable,
ReadableWritablePair,
WritableStream,
} from "@yume-chan/stream-extra";
import {
DuplexStreamFactory,
MaybeConsumable,
ReadableStream,
pipeFrom,
} from "@yume-chan/stream-extra";
import { EmptyUint8Array, Uint8ArrayExactReadable } from "@yume-chan/struct";
import { DeviceBusyError as _DeviceBusyError } from "./error.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 AdbDefaultInterfaceFilter = {
classCode: 0xff,
subclassCode: 0x42,
protocolCode: 1,
} as const satisfies UsbInterfaceFilter;
export function mergeDefaultAdbInterfaceFilter(
filters: readonly USBDeviceFilter[] | undefined,
): (USBDeviceFilter & UsbInterfaceFilter)[] {
if (!filters || filters.length === 0) {
return [AdbDefaultInterfaceFilter];
} else {
return filters.map((filter) => ({
...filter,
classCode: filter.classCode ?? AdbDefaultInterfaceFilter.classCode,
subclassCode:
filter.subclassCode ?? AdbDefaultInterfaceFilter.subclassCode,
protocolCode:
filter.protocolCode ?? AdbDefaultInterfaceFilter.protocolCode,
}));
}
}
export class AdbDaemonWebUsbConnection
implements ReadableWritablePair<AdbPacketData, Consumable<AdbPacketInit>>
{
readonly #device: AdbDaemonWebUsbDevice;
get device() {
return this.#device;
}
readonly #inEndpoint: USBEndpoint;
get inEndpoint() {
return this.#inEndpoint;
}
readonly #outEndpoint: USBEndpoint;
get outEndpoint() {
return this.#outEndpoint;
}
readonly #readable: ReadableStream<AdbPacketData>;
get readable() {
return this.#readable;
}
readonly #writable: WritableStream<Consumable<AdbPacketInit>>;
get writable() {
return this.#writable;
}
constructor(
device: AdbDaemonWebUsbDevice,
inEndpoint: USBEndpoint,
outEndpoint: USBEndpoint,
usbManager: USB,
) {
this.#device = device;
this.#inEndpoint = inEndpoint;
this.#outEndpoint = outEndpoint;
let closed = false;
const duplex = new DuplexStreamFactory<
AdbPacketData,
Consumable<Uint8Array>
>({
close: async () => {
try {
closed = true;
await device.raw.close();
} catch {
/* device may have already disconnected */
}
},
dispose: () => {
closed = true;
usbManager.removeEventListener(
"disconnect",
handleUsbDisconnect,
);
},
});
function handleUsbDisconnect(e: USBConnectionEvent) {
if (e.device === device.raw) {
duplex.dispose().catch(unreachable);
}
}
usbManager.addEventListener("disconnect", handleUsbDisconnect);
this.#readable = duplex.wrapReadable(
new ReadableStream<AdbPacketData>(
{
pull: async (controller) => {
const packet = await this.#transferIn();
if (packet) {
controller.enqueue(packet);
} else {
controller.close();
}
},
},
{ highWaterMark: 0 },
),
);
const zeroMask = outEndpoint.packetSize - 1;
this.#writable = pipeFrom(
duplex.createWritable(
new MaybeConsumable.WritableStream({
write: async (chunk) => {
try {
await device.raw.transferOut(
outEndpoint.endpointNumber,
// WebUSB doesn't support SharedArrayBuffer
// https://github.com/WICG/webusb/issues/243
toLocalUint8Array(chunk),
);
// In USB protocol, a not-full packet indicates the end of a transfer.
// If the payload size is a multiple of the packet size,
// we need to send an empty packet to indicate the end,
// so the OS will send it to the device immediately.
if (zeroMask && (chunk.length & zeroMask) === 0) {
await device.raw.transferOut(
outEndpoint.endpointNumber,
EmptyUint8Array,
);
}
} catch (e) {
if (closed) {
return;
}
throw e;
}
},
}),
),
new AdbPacketSerializeStream(),
);
}
async #transferIn(): Promise<AdbPacketData | undefined> {
try {
while (true) {
// ADB daemon sends each packet in two parts, the 24-byte header and the payload.
const result = await this.#device.raw.transferIn(
this.#inEndpoint.endpointNumber,
this.#inEndpoint.packetSize,
);
if (result.data!.byteLength !== 24) {
continue;
}
// Per spec, the `result.data` always covers the whole `buffer`.
const buffer = new Uint8Array(result.data!.buffer);
const stream = new Uint8ArrayExactReadable(buffer);
// Add `payload` field to its type, it's assigned below.
const packet = AdbPacketHeader.deserialize(
stream,
) as AdbPacketHeader & { payload: Uint8Array };
if (packet.magic !== (packet.command ^ 0xffffffff)) {
continue;
}
if (packet.payloadLength !== 0) {
const result = await this.#device.raw.transferIn(
this.#inEndpoint.endpointNumber,
packet.payloadLength,
);
packet.payload = new Uint8Array(result.data!.buffer);
} else {
packet.payload = EmptyUint8Array;
}
return packet;
}
} catch (e) {
// On Windows, disconnecting the device will cause `NetworkError` to be thrown,
// even before the `disconnect` event is fired.
// Wait a little while and check if the device is still connected.
// https://github.com/WICG/webusb/issues/219
if (isErrorName(e, "NetworkError")) {
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 100);
});
if (closed) {
return undefined;
}
}
throw e;
}
}
}
export class AdbDaemonWebUsbDevice implements AdbDaemonDevice {
static DeviceBusyError = _DeviceBusyError;
readonly #interface: UsbInterfaceIdentifier;
readonly #usbManager: USB;
readonly #raw: USBDevice;
get raw() {
return this.#raw;
}
readonly #serial: string;
get serial(): string {
return this.#serial;
}
get name(): string {
return this.#raw.productName!;
}
/**
* Create a new instance of `AdbDaemonWebUsbConnection` using a specified `USBDevice` instance
*
* @param device The `USBDevice` instance obtained elsewhere.
* @param filters The filters to use when searching for ADB interface. Defaults to {@link ADB_DEFAULT_DEVICE_FILTER}.
*/
constructor(
device: USBDevice,
interface_: UsbInterfaceIdentifier,
usbManager: USB,
) {
this.#raw = device;
this.#serial = getSerialNumber(device);
this.#interface = interface_;
this.#usbManager = usbManager;
}
async #claimInterface(): Promise<{
inEndpoint: USBEndpoint;
outEndpoint: USBEndpoint;
}> {
if (!this.#raw.opened) {
await this.#raw.open();
}
const { configuration, interface_, alternate } = this.#interface;
if (
this.#raw.configuration?.configurationValue !==
configuration.configurationValue
) {
// Note: Switching configuration is not supported on Windows,
// but Android devices should always expose ADB function at the first (default) configuration.
await this.#raw.selectConfiguration(
configuration.configurationValue,
);
}
if (!interface_.claimed) {
try {
await this.#raw.claimInterface(interface_.interfaceNumber);
} catch (e) {
if (isErrorName(e, "NetworkError")) {
throw new AdbDaemonWebUsbDevice.DeviceBusyError(e);
}
throw e;
}
}
if (
interface_.alternate.alternateSetting !== alternate.alternateSetting
) {
await this.#raw.selectAlternateInterface(
interface_.interfaceNumber,
alternate.alternateSetting,
);
}
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();
return new AdbDaemonWebUsbConnection(
this,
inEndpoint,
outEndpoint,
this.#usbManager,
);
}
}
export namespace AdbDaemonWebUsbDevice {
export type DeviceBusyError = _DeviceBusyError;
}