@yume-chan/adb
Version:
TypeScript implementation of Android Debug Bridge (ADB) protocol.
399 lines • 14 kB
JavaScript
// cspell:ignore tport
import { PromiseResolver } from "@yume-chan/async";
import { getUint64LittleEndian } from "@yume-chan/no-data-view";
import { AbortController } from "@yume-chan/stream-extra";
import { AdbBanner } from "../banner.js";
import { hexToNumber } from "../utils/index.js";
import { MDnsCommands, WirelessCommands, AlreadyConnectedError as _AlreadyConnectedError, NetworkError as _NetworkError, UnauthorizedError as _UnauthorizedError, } from "./commands/index.js";
import { AdbServerDeviceObserverOwner } from "./observer.js";
import { AdbServerStream } from "./stream.js";
import { AdbServerTransport } from "./transport.js";
/**
* Client for the ADB Server.
*/
export class AdbServerClient {
static NetworkError = _NetworkError;
static UnauthorizedError = _UnauthorizedError;
static AlreadyConnectedError = _AlreadyConnectedError;
static parseDeviceList(value) {
const devices = [];
for (const line of value.split("\n")) {
if (!line) {
continue;
}
const parts = line.split(" ").filter(Boolean);
const serial = parts[0];
const status = parts[1];
if (status !== "device" && status !== "unauthorized") {
continue;
}
let product;
let model;
let device;
let transportId;
for (let i = 2; i < parts.length; i += 1) {
const [key, value] = parts[i].split(":");
switch (key) {
case "product":
product = value;
break;
case "model":
model = value;
break;
case "device":
device = value;
break;
case "transport_id":
transportId = BigInt(value);
break;
}
}
if (!transportId) {
throw new Error(`No transport id for device ${serial}`);
}
devices.push({
serial,
authenticating: status === "unauthorized",
product,
model,
device,
transportId,
});
}
return devices;
}
static formatDeviceService(device, command) {
if (!device) {
return `host:${command}`;
}
if ("transportId" in device) {
return `host-transport-id:${device.transportId}:${command}`;
}
if ("serial" in device) {
return `host-serial:${device.serial}:${command}`;
}
if ("usb" in device) {
return `host-usb:${command}`;
}
if ("tcp" in device) {
return `host-local:${command}`;
}
throw new TypeError("Invalid device selector");
}
connector;
wireless = new WirelessCommands(this);
mDns = new MDnsCommands(this);
#observerOwner = new AdbServerDeviceObserverOwner(this);
constructor(connector) {
this.connector = connector;
}
async createConnection(request, options) {
const connection = await this.connector.connect(options);
const stream = new AdbServerStream(connection);
try {
await stream.writeString(request);
}
catch (e) {
await stream.dispose();
throw e;
}
try {
// `raceSignal` throws when the signal is aborted,
// so the `catch` block can close the connection.
await raceSignal(() => stream.readOkay(), options?.signal);
return stream;
}
catch (e) {
await stream.dispose();
throw e;
}
}
/**
* `adb version`
*/
async getVersion() {
const connection = await this.createConnection("host:version");
try {
const length = hexToNumber(await connection.readExactly(4));
const version = hexToNumber(await connection.readExactly(length));
return version;
}
finally {
await connection.dispose();
}
}
async validateVersion(minimalVersion) {
const version = await this.getVersion();
if (version < minimalVersion) {
throw new Error(`adb server version (${version}) doesn't match this client (${minimalVersion})`);
}
}
/**
* `adb kill-server`
*/
async killServer() {
const connection = await this.createConnection("host:kill");
await connection.dispose();
}
/**
* `adb host-features`
*/
async getServerFeatures() {
const connection = await this.createConnection("host:host-features");
try {
const response = await connection.readString();
return response.split(",");
}
finally {
await connection.dispose();
}
}
/**
* Get a list of connected devices from ADB Server.
*
* Equivalent ADB Command: `adb devices -l`
*/
async getDevices() {
const connection = await this.createConnection("host:devices-l");
try {
const response = await connection.readString();
return AdbServerClient.parseDeviceList(response);
}
finally {
await connection.dispose();
}
}
/**
* Monitors device list changes.
*/
async trackDevices(options) {
return this.#observerOwner.createObserver(options);
}
/**
* `adb -s <device> reconnect` or `adb reconnect offline`
*/
async reconnectDevice(device) {
const connection = await this.createConnection(device === "offline"
? "host:reconnect-offline"
: AdbServerClient.formatDeviceService(device, "reconnect"));
try {
await connection.readString();
}
finally {
await connection.dispose();
}
}
/**
* Gets the features supported by the device.
* The transport ID of the selected device is also returned,
* so the caller can execute other commands against the same device.
* @param device The device selector
* @returns The transport ID of the selected device, and the features supported by the device.
*/
async getDeviceFeatures(device) {
// On paper, `host:features` is a host service (device features are cached in host),
// so it shouldn't use `createDeviceConnection`,
// which is used to forward the service to the device.
//
// However, `createDeviceConnection` is a two step process:
//
// 1. Send a switch device service to host, to switch the connection to the device.
// 2. Send the actual service to host, let it forward the service to the device.
//
// In step 2, the host only forward the service to device if the service is unknown to host.
// If the service is a host service, it's still handled by host.
//
// Even better, if the service needs a device selector, but the selector is not provided,
// the service will be executed against the device selected by the switch device service.
// So we can use all device selector formats for the host service,
// and get the transport ID in the same time.
const connection = await this.createDeviceConnection(device, "host:features");
// Luckily `AdbServerClient.Socket` is compatible with `AdbServerClient.ServerConnection`
const stream = new AdbServerStream(connection);
try {
const featuresString = await stream.readString();
const features = featuresString.split(",");
return { transportId: connection.transportId, features };
}
finally {
await stream.dispose();
}
}
/**
* Creates a connection that will forward the service to device.
* @param device The device selector
* @param service The service to forward
* @returns An `AdbServerClient.Socket` that can be used to communicate with the service
*/
async createDeviceConnection(device, service) {
let switchService;
let transportId;
if (!device) {
await this.validateVersion(41);
switchService = `host:tport:any`;
}
else if ("transportId" in device) {
switchService = `host:transport-id:${device.transportId}`;
transportId = device.transportId;
}
else if ("serial" in device) {
await this.validateVersion(41);
switchService = `host:tport:serial:${device.serial}`;
}
else if ("usb" in device) {
await this.validateVersion(41);
switchService = `host:tport:usb`;
}
else if ("tcp" in device) {
await this.validateVersion(41);
switchService = `host:tport:local`;
}
else {
throw new TypeError("Invalid device selector");
}
const connection = await this.createConnection(switchService);
try {
await connection.writeString(service);
}
catch (e) {
await connection.dispose();
throw e;
}
try {
if (transportId === undefined) {
const array = await connection.readExactly(8);
transportId = getUint64LittleEndian(array, 0);
}
await connection.readOkay();
const socket = connection.release();
return {
transportId,
service,
readable: socket.readable,
writable: socket.writable,
get closed() {
return socket.closed;
},
async close() {
await socket.close();
},
};
}
catch (e) {
await connection.dispose();
throw e;
}
}
async #waitForUnchecked(device, state, options) {
let type;
if (!device) {
type = "any";
}
else if ("transportId" in device) {
type = "any";
}
else if ("serial" in device) {
type = "any";
}
else if ("usb" in device) {
type = "usb";
}
else if ("tcp" in device) {
type = "local";
}
else {
throw new TypeError("Invalid device selector");
}
// `waitFor` can't use `connectDevice`, because the device
// might not be available yet.
const service = AdbServerClient.formatDeviceService(device, `wait-for-${type}-${state}`);
const connection = await this.createConnection(service, options);
try {
await connection.readOkay();
}
finally {
await connection.dispose();
}
}
/**
* Wait for a device to be connected or disconnected.
*
* `adb wait-for-<state>`
*
* @param device The device selector
* @param state The state to wait for
* @param options The options
* @returns A promise that resolves when the condition is met.
*/
async waitFor(device, state, options) {
if (state === "disconnect") {
await this.validateVersion(41);
}
return this.#waitForUnchecked(device, state, options);
}
async waitForDisconnect(transportId, options) {
const serverVersion = await this.getVersion();
if (serverVersion >= 41) {
return this.#waitForUnchecked({ transportId }, "disconnect", options);
}
else {
const observer = await this.trackDevices(options);
return new Promise((resolve, reject) => {
observer.onDeviceRemove((devices) => {
if (devices.some((device) => device.transportId === transportId)) {
observer.stop();
resolve();
}
});
observer.onError((e) => {
observer.stop();
reject(e);
});
});
}
}
/**
* Creates an ADB Transport for the specified device.
*/
async createTransport(device) {
const { transportId, features } = await this.getDeviceFeatures(device);
const devices = await this.getDevices();
const info = devices.find((device) => device.transportId === transportId);
const banner = new AdbBanner(info?.product, info?.model, info?.device, features);
const waitAbortController = new AbortController();
const disconnected = this.waitForDisconnect(transportId, {
unref: true,
signal: waitAbortController.signal,
});
const transport = new AdbServerTransport(this, info?.serial ?? "", banner, transportId, disconnected);
void transport.disconnected.finally(() => waitAbortController.abort());
return transport;
}
}
export async function raceSignal(callback, ...signals) {
const abortPromise = new PromiseResolver();
function abort() {
abortPromise.reject(this.reason);
}
try {
for (const signal of signals) {
if (!signal) {
continue;
}
if (signal.aborted) {
throw signal.reason;
}
signal.addEventListener("abort", abort);
}
return await Promise.race([callback(), abortPromise.promise]);
}
finally {
for (const signal of signals) {
if (!signal) {
continue;
}
signal.removeEventListener("abort", abort);
}
}
}
//# sourceMappingURL=client.js.map