import { PromiseResolver } from "@yume-chan/async"; import type { ReadableWritablePair } from "@yume-chan/stream-extra"; import { AbortController, DecodeUtf8Stream, GatherStringStream, WritableStream, } from "@yume-chan/stream-extra"; import type { AdbCredentialStore } from "./auth.js"; import { ADB_DEFAULT_AUTHENTICATORS, AdbAuthenticationProcessor, } from "./auth.js"; import type { AdbFrameBuffer } from "./commands/index.js"; import { AdbPower, AdbReverseCommand, AdbSubprocess, AdbSync, AdbTcpIpCommand, escapeArg, framebuffer, install, } from "./commands/index.js"; import { AdbFeatures } from "./features.js"; import type { AdbPacketData, AdbPacketInit } from "./packet.js"; import { AdbCommand, calculateChecksum } from "./packet.js"; import type { AdbIncomingSocketHandler, AdbSocket, Closeable, } from "./socket/index.js"; import { AdbPacketDispatcher } from "./socket/index.js"; import { decodeUtf8, encodeUtf8 } from "./utils/index.js"; export enum AdbPropKey { Product = "ro.product.name", Model = "ro.product.model", Device = "ro.product.device", Features = "features", } export const VERSION_OMIT_CHECKSUM = 0x01000001; export class Adb implements Closeable { /** * It's possible to call `authenticate` multiple times on a single connection, * every time the device receives a `CNXN` packet, it resets its internal state, * and starts a new authentication process. */ public static async authenticate( connection: ReadableWritablePair, credentialStore: AdbCredentialStore, authenticators = ADB_DEFAULT_AUTHENTICATORS ): Promise { // Initially, set to highest-supported version and payload size. let version = 0x01000001; let maxPayloadSize = 0x100000; const resolver = new PromiseResolver(); const authProcessor = new AdbAuthenticationProcessor( authenticators, credentialStore ); // Here is similar to `AdbPacketDispatcher`, // But the received packet types and send packet processing are different. const abortController = new AbortController(); const pipe = connection.readable .pipeTo( new WritableStream({ async write(packet) { switch (packet.command) { case AdbCommand.Connect: version = Math.min(version, packet.arg0); maxPayloadSize = Math.min( maxPayloadSize, packet.arg1 ); resolver.resolve(decodeUtf8(packet.payload)); break; case AdbCommand.Auth: { const response = await authProcessor.process( packet ); await sendPacket(response); break; } default: // Maybe the previous ADB client exited without reading all packets, // so they are still waiting in OS internal buffer. // Just ignore them. // Because a `Connect` packet will reset the device, // Eventually there will be `Connect` and `Auth` response packets. break; } }, }), { // Don't cancel the source ReadableStream on AbortSignal abort. preventCancel: true, signal: abortController.signal, } ) .catch((e) => { resolver.reject(e); }); const writer = connection.writable.getWriter(); async function sendPacket(init: AdbPacketData) { // Always send checksum in auth steps // Because we don't know if the device needs it or not. await writer.write(calculateChecksum(init)); } let banner: string; try { // https://android.googlesource.com/platform/packages/modules/adb/+/79010dc6d5ca7490c493df800d4421730f5466ca/transport.cpp#1252 // There are some other feature constants, but some of them are only used by ADB server, not devices (daemons). const features = [ AdbFeatures.ShellV2, AdbFeatures.Cmd, AdbFeatures.StatV2, AdbFeatures.ListV2, AdbFeatures.FixedPushMkdir, "apex", "abb", // only tells the client the symlink timestamp issue in `adb push --sync` has been fixed. // No special handling required. "fixed_push_symlink_timestamp", "abb_exec", "remount_shell", "track_app", "sendrecv_v2", "sendrecv_v2_brotli", "sendrecv_v2_lz4", "sendrecv_v2_zstd", "sendrecv_v2_dry_run_send", ].join(","); await sendPacket({ command: AdbCommand.Connect, arg0: version, arg1: maxPayloadSize, // The terminating `;` is required in formal definition // But ADB daemon (all versions) can still work without it payload: encodeUtf8(`host::features=${features};`), }); banner = await resolver.promise; } finally { // When failed, release locks on `connection` so the caller can try again. // When success, also release locks so `AdbPacketDispatcher` can use them. abortController.abort(); writer.releaseLock(); // Wait until pipe stops (`ReadableStream` lock released) await pipe; } return new Adb(connection, version, maxPayloadSize, banner); } private readonly dispatcher: AdbPacketDispatcher; public get disconnected() { return this.dispatcher.disconnected; } private _protocolVersion: number | undefined; public get protocolVersion() { return this._protocolVersion; } private _product: string | undefined; public get product() { return this._product; } private _model: string | undefined; public get model() { return this._model; } private _device: string | undefined; public get device() { return this._device; } private _features: AdbFeatures[] = []; public get features() { return this._features; } public readonly subprocess: AdbSubprocess; public readonly power: AdbPower; public readonly reverse: AdbReverseCommand; public readonly tcpip: AdbTcpIpCommand; public constructor( connection: ReadableWritablePair, version: number, maxPayloadSize: number, banner: string ) { this.parseBanner(banner); let calculateChecksum: boolean; let appendNullToServiceString: boolean; if (version >= VERSION_OMIT_CHECKSUM) { calculateChecksum = false; appendNullToServiceString = false; } else { calculateChecksum = true; appendNullToServiceString = true; } this.dispatcher = new AdbPacketDispatcher(connection, { calculateChecksum, appendNullToServiceString, maxPayloadSize, }); this._protocolVersion = version; this.subprocess = new AdbSubprocess(this); this.power = new AdbPower(this); this.reverse = new AdbReverseCommand(this); this.tcpip = new AdbTcpIpCommand(this); } private parseBanner(banner: string): void { const pieces = banner.split("::"); if (pieces.length > 1) { const props = pieces[1]!; for (const prop of props.split(";")) { if (!prop) { continue; } const keyValue = prop.split("="); if (keyValue.length !== 2) { continue; } const [key, value] = keyValue; switch (key) { case AdbPropKey.Product: this._product = value; break; case AdbPropKey.Model: this._model = value; break; case AdbPropKey.Device: this._device = value; break; case AdbPropKey.Features: this._features = value!.split(",") as AdbFeatures[]; break; } } } } public supportsFeature(feature: AdbFeatures): boolean { return this._features.includes(feature); } /** * Add a handler for incoming socket. * @param handler A function to call with new incoming sockets. It must return `true` if it accepts the socket. * @returns A function to remove the handler. */ public onIncomingSocket(handler: AdbIncomingSocketHandler) { return this.dispatcher.onIncomingSocket(handler); } public async createSocket(service: string): Promise { return this.dispatcher.createSocket(service); } public async createSocketAndWait(service: string): Promise { const socket = await this.createSocket(service); const gatherStream = new GatherStringStream(); await socket.readable .pipeThrough(new DecodeUtf8Stream()) .pipeTo(gatherStream); return gatherStream.result; } public async getProp(key: string): Promise { const stdout = await this.subprocess.spawnAndWaitLegacy([ "getprop", key, ]); return stdout.trim(); } public async rm(...filenames: string[]): Promise { const stdout = await this.subprocess.spawnAndWaitLegacy([ "rm", "-rf", ...filenames.map((arg) => escapeArg(arg)), ]); return stdout; } public install() { return install(this); } public async sync(): Promise { const socket = await this.createSocket("sync:"); return new AdbSync(this, socket); } public async framebuffer(): Promise { return framebuffer(this); } /** * Close the ADB connection. * * Note that it won't close the streams from backends. * The streams are both physically and logically intact, * and can be reused. */ public async close(): Promise { await this.dispatcher.close(); } }