UNPKG

@yume-chan/adb

Version:

TypeScript implementation of Android Debug Bridge (ADB) protocol.

399 lines 14 kB
// 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