UNPKG

@yume-chan/adb

Version:

TypeScript implementation of Android Debug Bridge (ADB) protocol.

483 lines (428 loc) 16.1 kB
import { AsyncOperationManager, PromiseResolver, delay, } from "@yume-chan/async"; import { getUint32LittleEndian, setUint32LittleEndian, } from "@yume-chan/no-data-view"; import type { ReadableWritablePair, WritableStreamDefaultWriter, } from "@yume-chan/stream-extra"; import { AbortController, Consumable, WritableStream, } from "@yume-chan/stream-extra"; import { EmptyUint8Array, decodeUtf8, encodeUtf8 } from "@yume-chan/struct"; import type { AdbIncomingSocketHandler, AdbSocket, Closeable } from "../adb.js"; import type { AdbPacketData, AdbPacketInit } from "./packet.js"; import { AdbCommand, calculateChecksum } from "./packet.js"; import { AdbDaemonSocketController } from "./socket.js"; export interface AdbPacketDispatcherOptions { /** * From Android 9.0, ADB stopped checking the checksum in packet header to improve performance. * * The value should be inferred from the device's ADB protocol version. */ calculateChecksum: boolean; /** * Before Android 9.0, ADB uses `char*` to parse service strings, * thus requires a null character to terminate. * * The value should be inferred from the device's ADB protocol version. * Usually it should have the same value as `calculateChecksum`, since they both changed * in Android 9.0. */ appendNullToServiceString: boolean; maxPayloadSize: number; /** * Whether to keep the `connection` open (don't call `writable.close` and `readable.cancel`) * when `AdbPacketDispatcher.close` is called. * * @default false */ preserveConnection?: boolean | undefined; /** * The number of bytes the device can send before receiving an ack packet. * Using delayed ack can improve the throughput, * especially when the device is connected over Wi-Fi (so the latency is higher). * * This must be the negotiated value between the client and device. If the device enabled * delayed ack but the client didn't, the device will throw an error when the client sends * the first `WRTE` packet. And vice versa. */ initialDelayedAckBytes: number; /** * When set, the dispatcher will throw an error when * one of the socket readable stalls for this amount of milliseconds. * * Because ADB is a multiplexed protocol, blocking one socket will also block all other sockets. * It's important to always read from all sockets to prevent stalling. * * This option is helpful to detect bugs in the client code. * * @default false */ readTimeLimit?: number | undefined; } interface SocketOpenResult { remoteId: number; availableWriteBytes: number; } /** * The dispatcher is the "dumb" part of the connection handling logic. * * Except some options to change some minor behaviors, * its only job is forwarding packets between authenticated underlying streams * and abstracted socket objects. * * The `Adb` class is responsible for doing the authentication, * negotiating the options, and has shortcuts to high-level services. */ export class AdbPacketDispatcher implements Closeable { // ADB socket id starts from 1 // (0 means open failed) readonly #initializers = new AsyncOperationManager(1); /** * Socket local ID to the socket controller. */ readonly #sockets = new Map<number, AdbDaemonSocketController>(); readonly #writer: WritableStreamDefaultWriter<Consumable<AdbPacketInit>>; readonly options: AdbPacketDispatcherOptions; #closed = false; readonly #disconnected = new PromiseResolver<void>(); get disconnected() { return this.#disconnected.promise; } readonly #incomingSocketHandlers = new Map< string, AdbIncomingSocketHandler >(); readonly #readAbortController = new AbortController(); constructor( connection: ReadableWritablePair< AdbPacketData, Consumable<AdbPacketInit> >, options: AdbPacketDispatcherOptions, ) { this.options = options; // Don't allow negative values in dispatcher if (this.options.initialDelayedAckBytes < 0) { this.options.initialDelayedAckBytes = 0; } connection.readable .pipeTo( new WritableStream({ write: async (packet) => { switch (packet.command) { case AdbCommand.Close: await this.#handleClose(packet); break; case AdbCommand.Okay: this.#handleOkay(packet); break; case AdbCommand.Open: await this.#handleOpen(packet); break; case AdbCommand.Write: await this.#handleWrite(packet); break; default: // Junk data may only appear in the authentication phase, // since the dispatcher only works after authentication, // all packets should have a valid command. // (although it's possible that Adb added new commands in the future) throw new Error( `Unknown command: ${packet.command.toString( 16, )}`, ); } }, }), { preventCancel: options.preserveConnection ?? false, signal: this.#readAbortController.signal, }, ) .then( () => { this.#dispose(); }, (e) => { if (!this.#closed) { this.#disconnected.reject(e); } this.#dispose(); }, ); this.#writer = connection.writable.getWriter(); } async #handleClose(packet: AdbPacketData) { // If the socket is still pending if ( packet.arg0 === 0 && this.#initializers.reject( packet.arg1, new Error("Socket open failed"), ) ) { // Device failed to create the socket // (unknown service string, failed to execute command, etc.) // it doesn't break the connection, // so only reject the socket creation promise, // don't throw an error here. return; } // From https://android.googlesource.com/platform/packages/modules/adb/+/65d18e2c1cc48b585811954892311b28a4c3d188/adb.cpp#459 /* According to protocol.txt, p->msg.arg0 might be 0 to indicate * a failed OPEN only. However, due to a bug in previous ADB * versions, CLOSE(0, remote-id, "") was also used for normal * CLOSE() operations. */ // Ignore `arg0` and search for the socket const socket = this.#sockets.get(packet.arg1); if (socket) { await socket.close(); socket.dispose(); this.#sockets.delete(packet.arg1); return; } // TODO: adb: is double closing an socket a catastrophic error? // If the client sends two `CLSE` packets for one socket, // the device may also respond with two `CLSE` packets. } #handleOkay(packet: AdbPacketData) { let ackBytes: number; if (this.options.initialDelayedAckBytes !== 0) { if (packet.payload.length !== 4) { throw new Error( "Invalid OKAY packet. Payload size should be 4", ); } ackBytes = getUint32LittleEndian(packet.payload, 0); } else { if (packet.payload.length !== 0) { throw new Error( "Invalid OKAY packet. Payload size should be 0", ); } ackBytes = Infinity; } if ( this.#initializers.resolve(packet.arg1, { remoteId: packet.arg0, availableWriteBytes: ackBytes, } satisfies SocketOpenResult) ) { // Device successfully created the socket return; } const socket = this.#sockets.get(packet.arg1); if (socket) { // When delayed ack is enabled, `ackBytes` is a positive number represents // how many bytes the device has received from this socket. // When delayed ack is disabled, `ackBytes` is always `Infinity` represents // the device has received last `WRTE` packet from the socket. socket.ack(ackBytes); return; } // Maybe the device is responding to a packet of last connection // Tell the device to close the socket void this.sendPacket( AdbCommand.Close, packet.arg1, packet.arg0, EmptyUint8Array, ); } #sendOkay(localId: number, remoteId: number, ackBytes: number) { let payload: Uint8Array; if (this.options.initialDelayedAckBytes !== 0) { // TODO: try reusing this buffer to reduce memory allocation // However, that requires blocking reentrance of `sendOkay`, which might be more expensive payload = new Uint8Array(4); setUint32LittleEndian(payload, 0, ackBytes); } else { payload = EmptyUint8Array; } return this.sendPacket(AdbCommand.Okay, localId, remoteId, payload); } async #handleOpen(packet: AdbPacketData) { // Allocate a local ID for the socket from `#initializers`. // `AsyncOperationManager` doesn't directly support returning the next ID, // so use `add` + `resolve` to simulate this const [localId] = this.#initializers.add<number>(); this.#initializers.resolve(localId, undefined); const remoteId = packet.arg0; let availableWriteBytes = packet.arg1; let service = decodeUtf8(packet.payload); // ADB Daemon still adds a null character to the service string if (service.endsWith("\0")) { service = service.substring(0, service.length - 1); } // Check remote delayed ack enablement is consistent with local if (this.options.initialDelayedAckBytes === 0) { if (availableWriteBytes !== 0) { throw new Error("Invalid OPEN packet. arg1 should be 0"); } availableWriteBytes = Infinity; } else { if (availableWriteBytes === 0) { throw new Error( "Invalid OPEN packet. arg1 should be greater than 0", ); } } const handler = this.#incomingSocketHandlers.get(service); if (!handler) { await this.sendPacket( AdbCommand.Close, 0, remoteId, EmptyUint8Array, ); return; } const controller = new AdbDaemonSocketController({ dispatcher: this, localId, remoteId, localCreated: false, service, availableWriteBytes, }); try { await handler(controller.socket); this.#sockets.set(localId, controller); await this.#sendOkay( localId, remoteId, this.options.initialDelayedAckBytes, ); } catch { await this.sendPacket( AdbCommand.Close, 0, remoteId, EmptyUint8Array, ); } } async #handleWrite(packet: AdbPacketData) { const socket = this.#sockets.get(packet.arg1); if (!socket) { throw new Error(`Unknown local socket id: ${packet.arg1}`); } let handled = false; const promises: Promise<void>[] = [ (async () => { await socket.enqueue(packet.payload); await this.#sendOkay( packet.arg1, packet.arg0, packet.payload.length, ); handled = true; })(), ]; if (this.options.readTimeLimit) { promises.push( (async () => { await delay(this.options.readTimeLimit!); if (!handled) { throw new Error( `readable of \`${socket.service}\` has stalled for ${this.options.readTimeLimit} milliseconds`, ); } })(), ); } await Promise.race(promises); } async createSocket(service: string): Promise<AdbSocket> { if (this.options.appendNullToServiceString) { service += "\0"; } const [localId, initializer] = this.#initializers.add<SocketOpenResult>(); await this.sendPacket( AdbCommand.Open, localId, this.options.initialDelayedAckBytes, service, ); // Fulfilled by `handleOkay` const { remoteId, availableWriteBytes } = await initializer; const controller = new AdbDaemonSocketController({ dispatcher: this, localId, remoteId, localCreated: true, service, availableWriteBytes, }); this.#sockets.set(localId, controller); return controller.socket; } addReverseTunnel(service: string, handler: AdbIncomingSocketHandler) { this.#incomingSocketHandlers.set(service, handler); } removeReverseTunnel(address: string) { this.#incomingSocketHandlers.delete(address); } clearReverseTunnels() { this.#incomingSocketHandlers.clear(); } async sendPacket( command: AdbCommand, arg0: number, arg1: number, // PERF: It's slightly faster to not use default parameter values payload: string | Uint8Array, ): Promise<void> { if (typeof payload === "string") { payload = encodeUtf8(payload); } if (payload.length > this.options.maxPayloadSize) { throw new TypeError("payload too large"); } await Consumable.WritableStream.write(this.#writer, { command, arg0, arg1, payload, checksum: this.options.calculateChecksum ? calculateChecksum(payload) : 0, magic: command ^ 0xffffffff, }); } async close() { // Send `CLSE` packets for all sockets await Promise.all( Array.from(this.#sockets.values(), (socket) => socket.close()), ); // Stop receiving // It's possible that we haven't received all `CLSE` confirm packets, // but it doesn't matter, the next connection can cope with them. this.#closed = true; this.#readAbortController.abort(); if (this.options.preserveConnection) { this.#writer.releaseLock(); } else { await this.#writer.close(); } // `pipe().then()` will call `dispose` } #dispose() { for (const socket of this.#sockets.values()) { socket.dispose(); } this.#disconnected.resolve(); } }