import * as Sparse from "./sparse.js";
import * as common from "./common.js";
import { flashZip as flashFactoryZip } from "./factory.js";
const FASTBOOT_USB_CLASS = 0xff;
const FASTBOOT_USB_SUBCLASS = 0x42;
const FASTBOOT_USB_PROTOCOL = 0x03;
const BULK_TRANSFER_SIZE = 16384;
const DEFAULT_DOWNLOAD_SIZE = 512 * 1024 * 1024; // 512 MiB
// To conserve RAM and work around Chromium's ~2 GiB size limit, we limit the
// max download size even if the bootloader can accept more data.
const MAX_DOWNLOAD_SIZE = 1024 * 1024 * 1024; // 1 GiB
const GETVAR_TIMEOUT = 10000; // ms
/**
* Exception class for USB errors not directly thrown by WebUSB.
*/
export class UsbError extends Error {
constructor(message) {
super(message);
this.name = "UsbError";
}
}
/**
* Exception class for errors returned by the bootloader, as well as high-level
* fastboot errors resulting from bootloader responses.
*/
export class FastbootError extends Error {
constructor(status, message) {
super(`Bootloader replied with ${status}: ${message}`);
this.status = status;
this.bootloaderMessage = message;
this.name = "FastbootError";
}
}
/**
* This class is a client for executing fastboot commands and operations on a
* device connected over USB.
*/
export class FastbootDevice {
/**
* Create a new fastboot device instance. This doesn't actually connect to
* any USB devices; call {@link connect} to do so.
*/
constructor() {
this.device = null;
this._registeredUsbListeners = false;
this._connectResolve = null;
this._connectReject = null;
this._disconnectResolve = null;
}
/**
* Returns whether a USB device is connected and ready for use.
*/
get isConnected() {
return (
this.device !== null &&
this.device.opened &&
this.device.configurations[0].interfaces[0].claimed
);
}
/**
* Validate the current USB device's details and connect to it.
*
* @private
*/
async _validateAndConnectDevice(rethrowErrors) {
// Validate device
let ife = this.device.configurations[0].interfaces[0].alternates[0];
if (ife.endpoints.length !== 2) {
throw new UsbError("Interface has wrong number of endpoints");
}
let epIn = null;
let epOut = null;
for (let endpoint of ife.endpoints) {
common.logVerbose("Checking endpoint:", endpoint);
if (endpoint.type !== "bulk") {
throw new UsbError("Interface endpoint is not bulk");
}
if (endpoint.direction === "in") {
if (epIn === null) {
epIn = endpoint.endpointNumber;
} else {
throw new UsbError("Interface has multiple IN endpoints");
}
} else if (endpoint.direction === "out") {
if (epOut === null) {
epOut = endpoint.endpointNumber;
} else {
throw new UsbError("Interface has multiple OUT endpoints");
}
}
}
common.logVerbose("Endpoints: in =", epIn, ", out =", epOut);
try {
await this.device.open();
// Opportunistically reset to fix issues on some platforms
try {
await this.device.reset();
} catch (error) {
/* Failed = doesn't support reset */
}
await this.device.selectConfiguration(1);
await this.device.claimInterface(0); // fastboot
} catch (error) {
// Propagate exception from waitForConnect()
if (this._connectReject !== null) {
this._connectReject(error);
this._connectResolve = null;
this._connectReject = null;
}
if (rethrowErrors) {
throw error;
}
}
// Return from waitForConnect()
if (this._connectResolve !== null) {
this._connectResolve();
this._connectResolve = null;
this._connectReject = null;
}
}
/**
* Wait for the current USB device to disconnect, if it's still connected.
* Returns immediately if no device is connected.
*/
async waitForDisconnect() {
if (this.device === null) {
return;
}
return await new Promise((resolve, _reject) => {
this._disconnectResolve = resolve;
});
}
/**
* Callback for reconnecting to the USB device.
* This is necessary because some platforms do not support automatic reconnection,
* and USB connection requests can only be triggered as the result of explicit
* user action.
*
* @callback ReconnectCallback
*/
/**
* Wait for the USB device to connect. Returns at the next connection,
* regardless of whether the connected USB device matches the previous one.
*
* @param {ReconnectCallback} onReconnect - Callback to request device reconnection on Android.
*/
async waitForConnect(onReconnect = () => {}) {
// On Android, we need to request the user to reconnect the device manually
// because there is no support for automatic reconnection.
if (navigator.userAgent.includes("Android")) {
await this.waitForDisconnect();
onReconnect();
}
return await new Promise((resolve, reject) => {
this._connectResolve = resolve;
this._connectReject = reject;
});
}
/**
* Request the user to select a USB device and connect to it using the
* fastboot protocol.
*
* @throws {UsbError}
*/
async connect() {
let devices = await navigator.usb.getDevices();
common.logDebug("Found paired USB devices:", devices);
if (devices.length === 1) {
this.device = devices[0];
} else {
// If multiple paired devices are connected, request the user to
// select a specific one to reduce ambiguity. This is also necessary
// if no devices are already paired, i.e. first use.
common.logDebug(
"No or multiple paired devices are connected, requesting one"
);
this.device = await navigator.usb.requestDevice({
filters: [
{
classCode: FASTBOOT_USB_CLASS,
subclassCode: FASTBOOT_USB_SUBCLASS,
protocolCode: FASTBOOT_USB_PROTOCOL,
},
],
});
}
common.logDebug("Using USB device:", this.device);
if (!this._registeredUsbListeners) {
navigator.usb.addEventListener("disconnect", (event) => {
if (event.device === this.device) {
common.logDebug("USB device disconnected");
if (this._disconnectResolve !== null) {
this._disconnectResolve();
this._disconnectResolve = null;
}
}
});
navigator.usb.addEventListener("connect", async (event) => {
common.logDebug("USB device connected");
this.device = event.device;
// Check whether waitForConnect() is pending and save it for later
let hasPromiseReject = this._connectReject !== null;
try {
await this._validateAndConnectDevice(false);
} catch (error) {
// Only rethrow errors from the event handler if waitForConnect()
// didn't already handle them
if (!hasPromiseReject) {
throw error;
}
}
});
this._registeredUsbListeners = true;
}
await this._validateAndConnectDevice(true);
}
/**
* Read a raw command response from the bootloader.
*
* @private
* @returns {response} Object containing response text and data size, if any.
* @throws {FastbootError}
*/
async _readResponse() {
let returnData = {
text: "",
dataSize: null,
};
let respStatus;
do {
let respPacket = await this.device.transferIn(0x01, 64);
let response = new TextDecoder().decode(respPacket.data);
respStatus = response.substring(0, 4);
let respMessage = response.substring(4);
common.logDebug(`Response: ${respStatus} ${respMessage}`);
if (respStatus === "OKAY") {
// OKAY = end of response for this command
returnData.text += respMessage;
} else if (respStatus === "INFO") {
// INFO = additional info line
returnData.text += respMessage + "\n";
} else if (respStatus === "DATA") {
// DATA = hex string, but it's returned separately for safety
returnData.dataSize = respMessage;
} else {
// Assume FAIL or garbage data
throw new FastbootError(respStatus, respMessage);
}
// INFO means that more packets are coming
} while (respStatus === "INFO");
return returnData;
}
/**
* Send a textual command to the bootloader.
* This is in raw fastboot format, not AOSP fastboot syntax.
*
* @param {string} command - The command to send.
* @returns {response} Object containing response text and data size, if any.
* @throws {FastbootError}
*/
async runCommand(command) {
// Command and response length is always 64 bytes regardless of protocol
if (command.length > 64) {
throw new RangeError();
}
// Send raw UTF-8 command
let cmdPacket = new TextEncoder("utf-8").encode(command);
await this.device.transferOut(0x01, cmdPacket);
common.logDebug("Command:", command);
return this._readResponse();
}
/**
* Read the value of a bootloader variable. Returns undefined if the variable
* does not exist.
*
* @param {string} varName - The name of the variable to get.
* @returns {value} Textual content of the variable.
* @throws {FastbootError}
*/
async getVariable(varName) {
let resp;
try {
resp = (
await common.runWithTimeout(
this.runCommand(`getvar:${varName}`),
GETVAR_TIMEOUT
)
).text;
} catch (error) {
// Some bootloaders return FAIL instead of empty responses, despite
// what the spec says. Normalize it here.
if (error instanceof FastbootError && error.status == "FAIL") {
resp = undefined;
} else {
throw error;
}
}
// Some bootloaders send whitespace around some variables.
// According to the spec, non-existent variables should return empty
// responses
return resp ? resp.trim() : undefined;
}
/**
* Get the maximum download size for a single payload, in bytes.
*
* @private
* @returns {downloadSize}
* @throws {FastbootError}
*/
async _getDownloadSize() {
try {
let resp = (
await this.getVariable("max-download-size")
).toLowerCase();
if (resp) {
// AOSP fastboot requires hex
return Math.min(parseInt(resp, 16), MAX_DOWNLOAD_SIZE);
}
} catch (error) {
/* Failed = no value, fallthrough */
}
// FAIL or empty variable means no max, set a reasonable limit to conserve memory
return DEFAULT_DOWNLOAD_SIZE;
}
/**
* Callback for progress updates while flashing or uploading an image.
*
* @callback ProgressCallback
* @param {number} progress - Progress for the current action, between 0 and 1.
*/
/**
* Send a raw data payload to the bootloader.
*
* @private
*/
async _sendRawPayload(buffer, onProgress) {
let i = 0;
let remainingBytes = buffer.byteLength;
while (remainingBytes > 0) {
let chunk = buffer.slice(
i * BULK_TRANSFER_SIZE,
(i + 1) * BULK_TRANSFER_SIZE
);
if (i % 1000 === 0) {
common.logVerbose(
` Sending ${chunk.byteLength} bytes to endpoint, ${remainingBytes} remaining, i=${i}`
);
}
if (i % 10 === 0) {
onProgress(
(buffer.byteLength - remainingBytes) / buffer.byteLength
);
}
await this.device.transferOut(0x01, chunk);
remainingBytes -= chunk.byteLength;
i += 1;
}
onProgress(1.0);
}
/**
* Upload a payload to the bootloader for further use, e.g. for flashing.
* Does not handle raw images, flashing, or splitting.
*
* @param {string} partition - Name of the partition the payload is intended for.
* @param {ArrayBuffer} buffer - Buffer containing the data to upload.
* @param {ProgressCallback} onProgress - Callback for upload progress updates.
* @throws {FastbootError}
*/
async upload(partition, buffer, onProgress = () => {}) {
common.logDebug(
`Uploading single sparse to ${partition}: ${buffer.byteLength} bytes`
);
// Bootloader requires an 8-digit hex number
let xferHex = buffer.byteLength.toString(16).padStart(8, "0");
if (xferHex.length !== 8) {
throw new FastbootError(
"FAIL",
`Transfer size overflow: ${xferHex} is more than 8 digits`
);
}
// Check with the device and make sure size matches
let downloadResp = await this.runCommand(`download:${xferHex}`);
if (downloadResp.dataSize === null) {
throw new FastbootError(
"FAIL",
`Unexpected response to download command: ${downloadResp.text}`
);
}
let downloadSize = parseInt(downloadResp.dataSize, 16);
if (downloadSize !== buffer.byteLength) {
throw new FastbootError(
"FAIL",
`Bootloader wants ${buffer.byteLength} bytes, requested to send ${buffer.bytelength} bytes`
);
}
common.logDebug(`Sending payload: ${buffer.byteLength} bytes`);
await this._sendRawPayload(buffer, onProgress);
common.logDebug("Payload sent, waiting for response...");
await this._readResponse();
}
/**
* Reboot to the given target, and optionally wait for the device to
* reconnect.
*
* @param {string} target - Where to reboot to, i.e. fastboot or bootloader.
* @param {boolean} wait - Whether to wait for the device to reconnect.
* @param {ReconnectCallback} onReconnect - Callback to request device reconnection, if wait is enabled.
*/
async reboot(target = "", wait = false, onReconnect = () => {}) {
if (target.length > 0) {
await this.runCommand(`reboot-${target}`);
} else {
await this.runCommand("reboot");
}
if (wait) {
await this.waitForConnect(onReconnect);
}
}
/**
* Flash the given Blob to the given partition on the device. Any image
* format supported by the bootloader is allowed, e.g. sparse or raw images.
* Large raw images will be converted to sparse images automatically, and
* large sparse images will be split and flashed in multiple passes
* depending on the bootloader's payload size limit.
*
* @param {string} partition - The name of the partition to flash.
* @param {Blob} blob - The Blob to retrieve data from.
* @param {ProgressCallback} onProgress - Callback for flashing progress updates.
* @throws {FastbootError}
*/
async flashBlob(partition, blob, onProgress = () => {}) {
// Use current slot if partition is A/B
if ((await this.getVariable(`has-slot:${partition}`)) === "yes") {
partition += "_" + (await this.getVariable("current-slot"));
}
let maxDlSize = await this._getDownloadSize();
let fileHeader = await common.readBlobAsBuffer(
blob.slice(0, Sparse.FILE_HEADER_SIZE)
);
let totalBytes = 0;
if (Sparse.isSparse(fileHeader)) {
let sparseHeader = Sparse.parseFileHeader(fileHeader);
totalBytes = sparseHeader.blocks * sparseHeader.blockSize;
} else {
totalBytes = blob.size;
}
// Logical partitions need to be resized before flashing because they're
// sized perfectly to the payload.
if ((await this.getVariable(`is-logical:${partition}`)) === "yes") {
// As per AOSP fastboot, we reset the partition to 0 bytes first
// to optimize extent allocation.
await this.runCommand(`resize-logical-partition:${partition}:0`);
// Set the actual size
await this.runCommand(
`resize-logical-partition:${partition}:${totalBytes}`
);
}
// Convert image to sparse (for splitting) if it exceeds the size limit
if (blob.size > maxDlSize && !Sparse.isSparse(fileHeader)) {
common.logDebug(`${partition} image is raw, converting to sparse`);
// Assume that non-sparse images will always be small enough to convert in RAM.
// The buffer is converted to a Blob for compatibility with the existing flashing code.
let rawData = await common.readBlobAsBuffer(blob);
let sparse = Sparse.fromRaw(rawData);
blob = new Blob([sparse]);
}
common.logDebug(
`Flashing ${blob.size} bytes to ${partition}, ${maxDlSize} bytes per split`
);
let splits = 0;
let sentBytes = 0;
for await (let split of Sparse.splitBlob(blob, maxDlSize)) {
await this.upload(partition, split.data, (progress) => {
onProgress((sentBytes + progress * split.bytes) / totalBytes);
});
common.logDebug("Flashing payload...");
await this.runCommand(`flash:${partition}`);
splits += 1;
sentBytes += split.bytes;
}
common.logDebug(`Flashed ${partition} with ${splits} split(s)`);
}
/**
* Callback for reconnecting the USB device.
* This is necessary because some platforms do not support automatic reconnection,
* and USB connection requests can only be triggered as the result of explicit
* user action.
*
* @callback ReconnectCallback
*/
/**
* Callback for factory image flashing progress.
*
* @callback FactoryFlashCallback
* @param {string} action - Action in the flashing process, e.g. unpack/flash.
* @param {string} item - Item processed by the action, e.g. partition being flashed.
* @param {number} progress - Progress within the current action between 0 and 1.
*/
/**
* Flash the given factory images zip onto the device, with automatic handling
* of firmware, system, and logical partitions as AOSP fastboot and
* flash-all.sh would do.
* Equivalent to `fastboot update name.zip`.
*
* @param {FastbootDevice} device - Fastboot device to flash.
* @param {Blob} blob - Blob containing the zip file to flash.
* @param {boolean} wipe - Whether to wipe super and userdata. Equivalent to `fastboot -w`.
* @param {ReconnectCallback} onReconnect - Callback to request device reconnection.
* @param {FactoryFlashCallback} onProgress - Progress callback for image flashing.
*/
async flashFactoryZip(blob, wipe, onReconnect, onProgress = () => {}) {
return await flashFactoryZip(this, blob, wipe, onReconnect, onProgress);
}
}
Source