@yume-chan/adb
Version:
TypeScript implementation of Android Debug Bridge (ADB) protocol.
396 lines (355 loc) • 13.5 kB
text/typescript
import type { MaybePromiseLike } from "@yume-chan/async";
import { PromiseResolver } from "@yume-chan/async";
import type { ReadableWritablePair } from "@yume-chan/stream-extra";
import {
AbortController,
Consumable,
WritableStream,
} from "@yume-chan/stream-extra";
import { decodeUtf8, encodeUtf8 } from "@yume-chan/struct";
import type {
AdbIncomingSocketHandler,
AdbSocket,
AdbTransport,
} from "../adb.js";
import { AdbBanner } from "../banner.js";
import { AdbFeature } from "../features.js";
import type { AdbAuthenticator, AdbCredentialStore } from "./auth.js";
import {
ADB_DEFAULT_AUTHENTICATORS,
AdbAuthenticationProcessor,
} from "./auth.js";
import { AdbPacketDispatcher } from "./dispatcher.js";
import type { AdbPacketData, AdbPacketInit } from "./packet.js";
import { AdbCommand, calculateChecksum } from "./packet.js";
export const ADB_DAEMON_VERSION_OMIT_CHECKSUM = 0x01000001;
// 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).
export const ADB_DAEMON_DEFAULT_FEATURES = /* #__PURE__ */ (() =>
[
AdbFeature.ShellV2,
AdbFeature.Cmd,
AdbFeature.StatV2,
AdbFeature.ListV2,
AdbFeature.FixedPushMkdir,
"apex",
AdbFeature.Abb,
// only tells the client the symlink timestamp issue in `adb push --sync` has been fixed.
// No special handling required.
"fixed_push_symlink_timestamp",
AdbFeature.AbbExec,
"remount_shell",
"track_app",
AdbFeature.SendReceiveV2,
"sendrecv_v2_brotli",
"sendrecv_v2_lz4",
"sendrecv_v2_zstd",
"sendrecv_v2_dry_run_send",
AdbFeature.DelayedAck,
] as AdbFeature[])();
export const ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE = 32 * 1024 * 1024;
export type AdbDaemonConnection = ReadableWritablePair<
AdbPacketData,
Consumable<AdbPacketInit>
>;
export interface AdbDaemonAuthenticationOptions {
serial: string;
connection: AdbDaemonConnection;
credentialStore: AdbCredentialStore;
authenticators?: AdbAuthenticator[];
features?: readonly AdbFeature[];
/**
* 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).
*
* Set to 0 or any negative value to disable delayed ack in handshake.
* Otherwise the value must be in the range of unsigned 32-bit integer.
*
* Delayed ack was added in Android 14,
* this option will be ignored when the device doesn't support it.
*
* @default ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE
*/
initialDelayedAckBytes?: number;
/**
* Whether to keep the `connection` open (don't call `writable.close` and `readable.cancel`)
* when `AdbDaemonTransport.close` is called.
*
* Note that when `authenticate` fails,
* no matter which value this option has,
* the `connection` is always kept open, so it can be used in another `authenticate` call.
*
* @default false
*/
preserveConnection?: boolean | undefined;
/**
* When set, the transport 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 undefined
*/
readTimeLimit?: number | undefined;
}
interface AdbDaemonSocketConnectorConstructionOptions {
serial: string;
connection: AdbDaemonConnection;
version: number;
maxPayloadSize: number;
banner: string;
features?: readonly AdbFeature[];
/**
* The number of bytes the device can send before receiving an ack packet.
*
* On Android 14 and newer, the Delayed Acknowledgement feature is added to
* improve performance, especially for high-latency connections like ADB over Wi-Fi.
*
* When `features` doesn't include `AdbFeature.DelayedAck`, it must be set to 0. Otherwise,
* the value must be in the range of unsigned 32-bit integer.
*
* If the device enabled delayed ack but the client didn't, the device will throw an error
* when the client sends the first data packet. And vice versa.
*/
initialDelayedAckBytes: number;
/**
* Whether to keep the `connection` open (don't call `writable.close` and `readable.cancel`)
* when `AdbDaemonTransport.close` is called.
*
* @default false
*/
preserveConnection?: boolean | undefined;
/**
* When set, the transport 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 undefined
*/
readTimeLimit?: number | undefined;
}
/**
* An ADB Transport that connects to ADB Daemons directly.
*/
export class AdbDaemonTransport implements AdbTransport {
/**
* Authenticate with the ADB Daemon and create a new transport.
*/
static async authenticate({
serial,
connection,
credentialStore,
authenticators = ADB_DEFAULT_AUTHENTICATORS,
features = ADB_DAEMON_DEFAULT_FEATURES,
initialDelayedAckBytes = ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE,
...options
}: AdbDaemonAuthenticationOptions): Promise<AdbDaemonTransport> {
// Initially, set to highest-supported version and payload size.
let version = 0x01000001;
// Android 4: 4K, Android 7: 256K, Android 9: 1M
let maxPayloadSize = 1024 * 1024;
const resolver = new PromiseResolver<string>();
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,
},
)
.then(
() => {
// If `resolver` is already settled, call `reject` won't do anything.
resolver.reject(
new Error("Connection closed unexpectedly"),
);
},
(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.
(init as AdbPacketInit).checksum = calculateChecksum(init.payload);
(init as AdbPacketInit).magic = init.command ^ 0xffffffff;
await Consumable.WritableStream.write(
writer,
init as AdbPacketInit,
);
}
const actualFeatures = features.slice();
if (initialDelayedAckBytes <= 0) {
const index = features.indexOf(AdbFeature.DelayedAck);
if (index !== -1) {
actualFeatures.splice(index, 1);
}
}
let banner: string;
try {
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=${actualFeatures.join(",")}`,
),
});
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 AdbDaemonTransport({
serial,
connection,
version,
maxPayloadSize,
banner,
features: actualFeatures,
initialDelayedAckBytes,
...options,
});
}
#connection: AdbDaemonConnection;
get connection() {
return this.#connection;
}
readonly #dispatcher: AdbPacketDispatcher;
#serial: string;
get serial() {
return this.#serial;
}
#protocolVersion: number;
get protocolVersion() {
return this.#protocolVersion;
}
get maxPayloadSize() {
return this.#dispatcher.options.maxPayloadSize;
}
#banner: AdbBanner;
get banner() {
return this.#banner;
}
get disconnected() {
return this.#dispatcher.disconnected;
}
#clientFeatures: readonly AdbFeature[];
get clientFeatures() {
return this.#clientFeatures;
}
constructor({
serial,
connection,
version,
banner,
features = ADB_DAEMON_DEFAULT_FEATURES,
initialDelayedAckBytes,
...options
}: AdbDaemonSocketConnectorConstructionOptions) {
this.#serial = serial;
this.#connection = connection;
this.#banner = AdbBanner.parse(banner);
this.#clientFeatures = features;
if (features.includes(AdbFeature.DelayedAck)) {
if (initialDelayedAckBytes <= 0) {
throw new TypeError(
"`initialDelayedAckBytes` must be greater than 0 when DelayedAck feature is enabled.",
);
}
if (!this.#banner.features.includes(AdbFeature.DelayedAck)) {
initialDelayedAckBytes = 0;
}
} else {
initialDelayedAckBytes = 0;
}
let calculateChecksum: boolean;
let appendNullToServiceString: boolean;
if (version >= ADB_DAEMON_VERSION_OMIT_CHECKSUM) {
calculateChecksum = false;
appendNullToServiceString = false;
} else {
calculateChecksum = true;
appendNullToServiceString = true;
}
this.#dispatcher = new AdbPacketDispatcher(connection, {
calculateChecksum,
appendNullToServiceString,
initialDelayedAckBytes,
...options,
});
this.#protocolVersion = version;
}
connect(service: string): MaybePromiseLike<AdbSocket> {
return this.#dispatcher.createSocket(service);
}
addReverseTunnel(
handler: AdbIncomingSocketHandler,
address?: string,
): string {
if (!address) {
const id = Math.random().toString().substring(2);
address = `localabstract:reverse_${id}`;
}
this.#dispatcher.addReverseTunnel(address, handler);
return address;
}
removeReverseTunnel(address: string): void {
this.#dispatcher.removeReverseTunnel(address);
}
clearReverseTunnels(): void {
this.#dispatcher.clearReverseTunnels();
}
close(): MaybePromiseLike<void> {
return this.#dispatcher.close();
}
}