Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit a2fb32e9 authored by Danny Lin's avatar Danny Lin
Browse files

Initial migration to TypeScript

This doesn't fully pass the type checker yet because we don't have
up-to-date type definitions for zip.js, but everything else is valid.
parent c552e9a8
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -12,6 +12,7 @@
    "devDependencies": {
        "@rollup/plugin-node-resolve": "^11.1.0",
        "@rollup/plugin-typescript": "^8.2.1",
        "@types/w3c-web-usb": "^1.0.4",
        "better-docs": "^2.3.2",
        "eslint": "^7.18.0",
        "eslint-config-prettier": "^7.2.0",
+1 −1
Original line number Diff line number Diff line
@@ -3,7 +3,7 @@ import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";

export default {
    input: "src/index.js",
    input: "src/index.ts",
    output: [
        {
            file: "dist/fastboot.cjs",
+24 −14
Original line number Diff line number Diff line
let debugLevel = false;
import { FactoryProgressCallback } from "./factory";

export function logDebug(...data) {
export enum DebugLevel {
    Silent = 0,
    Debug,
    Verbose,
}

let debugLevel = DebugLevel.Silent;

export function logDebug(...data: any[]) {
    if (debugLevel >= 1) {
        console.log(...data);
    }
}

export function logVerbose(...data) {
export function logVerbose(...data: any[]) {
    if (debugLevel >= 2) {
        console.log(...data);
    }
@@ -20,7 +28,7 @@ export function logVerbose(...data) {
 *
 * @param {number} level - Debug level to use.
 */
export function setDebugLevel(level) {
export function setDebugLevel(level: DebugLevel) {
    debugLevel = level;
}

@@ -31,11 +39,11 @@ export function setDebugLevel(level) {
 * @returns {buffer} ArrayBuffer containing data from the blob.
 * @ignore
 */
export function readBlobAsBuffer(blob) {
export function readBlobAsBuffer(blob: Blob): Promise<ArrayBuffer> {
    return new Promise((resolve, reject) => {
        let reader = new FileReader();
        reader.onload = () => {
            resolve(reader.result);
            resolve(reader.result! as ArrayBuffer);
        };
        reader.onerror = () => {
            reject(reader.error);
@@ -51,12 +59,12 @@ function waitForFrame() {
    });
}

export async function runWithTimedProgress(
    onProgress,
    action,
    item,
    duration,
    workPromise
export async function runWithTimedProgress<T>(
    onProgress: FactoryProgressCallback,
    action: string,
    item: string,
    duration: number,
    workPromise: Promise<T>,
) {
    let startTime = new Date().getTime();
    let stop = false;
@@ -83,14 +91,16 @@ export async function runWithTimedProgress(

/** Exception class for operations that exceeded their timeout duration. */
export class TimeoutError extends Error {
    constructor(timeout) {
    timeout: number;

    constructor(timeout: number) {
        super(`Timeout of ${timeout} ms exceeded`);
        this.name = "TimeoutError";
        this.timeout = timeout;
    }
}

export function runWithTimeout(promise, timeout) {
export function runWithTimeout<T>(promise: Promise<T>, timeout: number): Promise<T> {
    return new Promise((resolve, reject) => {
        // Set up timeout
        let timedOut = false;
+24 −14
Original line number Diff line number Diff line
import * as common from "./common.js";
import * as common from "./common";
import { ZipReader, BlobReader, BlobWriter, TextWriter } from "@zip.js/zip.js";
import { FastbootError } from "./fastboot.js";
import { FastbootDevice, FastbootError, ReconnectCallback } from "./fastboot";

/**
 * Callback for factory image flashing progress.
 *
 * @callback FactoryProgressCallback
 * @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.
 */
export type FactoryProgressCallback = (action: string, item: string, progress: number) => void;

// Images needed for fastbootd
const BOOT_CRITICAL_IMAGES = [
@@ -31,12 +41,12 @@ const BOOTLOADER_REBOOT_TIME = 4000; // ms
const FASTBOOTD_REBOOT_TIME = 16000; // ms
const USERDATA_ERASE_TIME = 1000; // ms

// Wrapper for Entry#getData() to unwrap ProgressEvent errors
// Wrapper for Entry#getData() that unwraps ProgressEvent errors
async function zipGetData(entry, writer, options = undefined) {
    try {
        return await entry.getData(writer, options);
    } catch (e) {
        if (e instanceof ProgressEvent && e.type === "error") {
        if (e instanceof ProgressEvent && e.type === "error" && e.target !== null) {
            throw e.target.error;
        } else {
            throw e;
@@ -44,7 +54,7 @@ async function zipGetData(entry, writer, options = undefined) {
    }
}

async function flashEntryBlob(device, entry, onProgress, partition) {
async function flashEntryBlob(device: FastbootDevice, entry, onProgress: FactoryProgressCallback, partition: string) {
    common.logDebug(`Unpacking ${partition}`);
    onProgress("unpack", partition, 0.0);
    let blob = await zipGetData(
@@ -64,7 +74,7 @@ async function flashEntryBlob(device, entry, onProgress, partition) {
    });
}

async function tryFlashImages(device, entries, onProgress, imageNames) {
async function tryFlashImages(device: FastbootDevice, entries, onProgress: FactoryProgressCallback, imageNames: Array<string>) {
    for (let imageName of imageNames) {
        let pattern = new RegExp(`${imageName}(?:-.+)?\\.img$`);
        let entry = entries.find((entry) => entry.filename.match(pattern));
@@ -74,7 +84,7 @@ async function tryFlashImages(device, entries, onProgress, imageNames) {
    }
}

async function checkRequirements(device, androidInfo) {
async function checkRequirements(device: FastbootDevice, androidInfo: string) {
    // Deal with CRLF just in case
    for (let line of androidInfo.replace("\r", "").split("\n")) {
        let match = line.match(/^require\s+(.+?)=(.+)$/);
@@ -89,7 +99,7 @@ async function checkRequirements(device, androidInfo) {
        }

        let expectValue = match[2];
        let expectValues = expectValue.split("|");
        let expectValues: Array<string | null> = expectValue.split("|");

        // Special case: not a real variable at all
        if (variable === "partition-exists") {
@@ -130,7 +140,7 @@ async function checkRequirements(device, androidInfo) {
    }
}

async function tryReboot(device, target, onReconnect) {
async function tryReboot(device: FastbootDevice, target: string, onReconnect: ReconnectCallback) {
    try {
        await device.reboot(target, false);
    } catch (e) {
@@ -141,11 +151,11 @@ async function tryReboot(device, target, onReconnect) {
}

export async function flashZip(
    device,
    blob,
    wipe,
    onReconnect,
    onProgress = () => {}
    device: FastbootDevice,
    blob: Blob,
    wipe: boolean,
    onReconnect: ReconnectCallback,
    onProgress: FactoryProgressCallback = (action: string, item: string, progress: number) => {}
) {
    onProgress("load", "package", 0.0);
    let reader = new ZipReader(new BlobReader(blob));
+116 −108
Original line number Diff line number Diff line
import * as Sparse from "./sparse.js";
import * as common from "./common.js";
import { flashZip as flashFactoryZip } from "./factory.js";
import * as Sparse from "./sparse";
import * as common from "./common";
import { FactoryProgressCallback, flashZip as flashFactoryZip } from "./factory";

const FASTBOOT_USB_CLASS = 0xff;
const FASTBOOT_USB_SUBCLASS = 0x42;
@@ -19,7 +19,7 @@ const GETVAR_TIMEOUT = 10000; // ms
 * Exception class for USB errors not directly thrown by WebUSB.
 */
export class UsbError extends Error {
    constructor(message) {
    constructor(message: string) {
        super(message);
        this.name = "UsbError";
    }
@@ -30,7 +30,10 @@ export class UsbError extends Error {
 * fastboot errors resulting from bootloader responses.
 */
export class FastbootError extends Error {
    constructor(status, message) {
    status: string;
    bootloaderMessage: string;

    constructor(status: string, message: string) {
        super(`Bootloader replied with ${status}: ${message}`);
        this.status = status;
        this.bootloaderMessage = message;
@@ -38,11 +41,44 @@ export class FastbootError extends Error {
    }
}

interface CommandResponse {
    text: string;
    // hex string from DATA
    dataSize: string;
}

/**
 * Callback for progress updates while flashing or uploading an image.
 *
 * @callback FlashProgressCallback
 * @param {number} progress - Progress for the current action, between 0 and 1.
 */
export type FlashProgressCallback = (progress: number) => void;

/**
 * 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
 */
 export type ReconnectCallback = () => void;

/**
 * This class is a client for executing fastboot commands and operations on a
 * device connected over USB.
 */
export class FastbootDevice {
    device: USBDevice | null;
    epIn: number | null;
    epOut: number | null;

    private registeredUsbListeners: boolean;
    private connectResolve: ((value: any) => void) | null;
    private connectReject: ((err: Error) => void) | null;
    private disconnectResolve: ((value: any) => void) | null;

    /**
     * Create a new fastboot device instance. This doesn't actually connect to
     * any USB devices; call {@link connect} to do so.
@@ -51,10 +87,10 @@ export class FastbootDevice {
        this.device = null;
        this.epIn = null;
        this.epOut = null;
        this._registeredUsbListeners = false;
        this._connectResolve = null;
        this._connectReject = null;
        this._disconnectResolve = null;
        this.registeredUsbListeners = false;
        this.connectResolve = null;
        this.connectReject = null;
        this.disconnectResolve = null;
    }

    /**
@@ -73,9 +109,13 @@ export class FastbootDevice {
     *
     * @private
     */
    async _validateAndConnectDevice() {
    private async validateAndConnectDevice() {
        if (this.device === null) {
            throw new UsbError("Attempted to connect to null device");
        }

        // Validate device
        let ife = this.device.configurations[0].interfaces[0].alternates[0];
        let ife = this.device!.configurations[0].interfaces[0].alternates[0];
        if (ife.endpoints.length !== 2) {
            throw new UsbError("Interface has wrong number of endpoints");
        }
@@ -105,32 +145,32 @@ export class FastbootDevice {
        common.logVerbose("Endpoints: in =", this.epIn, ", out =", this.epOut);

        try {
            await this.device.open();
            await this.device!.open();
            // Opportunistically reset to fix issues on some platforms
            try {
                await this.device.reset();
                await this.device!.reset();
            } catch (error) {
                /* Failed = doesn't support reset */
            }

            await this.device.selectConfiguration(1);
            await this.device.claimInterface(0); // fastboot
            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 (this.connectReject !== null) {
                this.connectReject(error);
                this.connectResolve = null;
                this.connectReject = null;
            }

            throw error;
        }

        // Return from waitForConnect()
        if (this._connectResolve !== null) {
            this._connectResolve();
            this._connectResolve = null;
            this._connectReject = null;
        if (this.connectResolve !== null) {
            this.connectResolve(undefined);
            this.connectResolve = null;
            this.connectReject = null;
        }
    }

@@ -144,26 +184,17 @@ export class FastbootDevice {
        }

        return await new Promise((resolve, _reject) => {
            this._disconnectResolve = resolve;
            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 = () => {}) {
    async waitForConnect(onReconnect: ReconnectCallback = () => {}) {
        // 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")) {
@@ -172,8 +203,8 @@ export class FastbootDevice {
        }

        return await new Promise((resolve, reject) => {
            this._connectResolve = resolve;
            this._connectReject = reject;
            this.connectResolve = resolve;
            this.connectReject = reject;
        });
    }

@@ -207,13 +238,13 @@ export class FastbootDevice {
        }
        common.logDebug("Using USB device:", this.device);

        if (!this._registeredUsbListeners) {
        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;
                    if (this.disconnectResolve !== null) {
                        this.disconnectResolve(undefined);
                        this.disconnectResolve = null;
                    }
                }
            });
@@ -223,9 +254,9 @@ export class FastbootDevice {
                this.device = event.device;

                // Check whether waitForConnect() is pending and save it for later
                let hasPromiseReject = this._connectReject !== null;
                let hasPromiseReject = this.connectReject !== null;
                try {
                    await this._validateAndConnectDevice();
                    await this.validateAndConnectDevice();
                } catch (error) {
                    // Only rethrow errors from the event handler if waitForConnect()
                    // didn't already handle them
@@ -235,10 +266,10 @@ export class FastbootDevice {
                }
            });

            this._registeredUsbListeners = true;
            this.registeredUsbListeners = true;
        }

        await this._validateAndConnectDevice();
        await this.validateAndConnectDevice();
    }

    /**
@@ -248,14 +279,12 @@ export class FastbootDevice {
     * @returns {response} Object containing response text and data size, if any.
     * @throws {FastbootError}
     */
    async _readResponse() {
        let returnData = {
            text: "",
            dataSize: null,
        };
    private async readResponse() {
        let respData = {} as CommandResponse;
        let respStatus;

        do {
            let respPacket = await this.device.transferIn(this.epIn, 64);
            let respPacket = await this.device!.transferIn(this.epIn!, 64);
            let response = new TextDecoder().decode(respPacket.data);

            respStatus = response.substring(0, 4);
@@ -264,21 +293,21 @@ export class FastbootDevice {

            if (respStatus === "OKAY") {
                // OKAY = end of response for this command
                returnData.text += respMessage;
                respData.text += respMessage;
            } else if (respStatus === "INFO") {
                // INFO = additional info line
                returnData.text += respMessage + "\n";
                respData.text += respMessage + "\n";
            } else if (respStatus === "DATA") {
                // DATA = hex string, but it's returned separately for safety
                returnData.dataSize = respMessage;
                respData.dataSize = respMessage;
            } else {
                // Assume FAIL or garbage data
                throw new FastbootError(respStatus, respMessage);
            }
            // INFO means that more packets are coming
            // INFO = more packets are coming
        } while (respStatus === "INFO");

        return returnData;
        return respData;
    }

    /**
@@ -289,18 +318,18 @@ export class FastbootDevice {
     * @returns {response} Object containing response text and data size, if any.
     * @throws {FastbootError}
     */
    async runCommand(command) {
    async runCommand(command: string) {
        // 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(this.epOut, cmdPacket);
        let cmdPacket = new TextEncoder().encode(command);
        await this.device!.transferOut(this.epOut!, cmdPacket);
        common.logDebug("Command:", command);

        return this._readResponse();
        return this.readResponse();
    }

    /**
@@ -311,7 +340,7 @@ export class FastbootDevice {
     * @returns {value} Textual content of the variable.
     * @throws {FastbootError}
     */
    async getVariable(varName) {
    async getVariable(varName: string) {
        let resp;
        try {
            resp = (
@@ -324,7 +353,7 @@ export class FastbootDevice {
            // 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;
                resp = null;
            } else {
                throw error;
            }
@@ -333,7 +362,7 @@ export class FastbootDevice {
        // Some bootloaders send whitespace around some variables.
        // According to the spec, non-existent variables should return empty
        // responses
        return resp ? resp.trim() : undefined;
        return resp ? resp.trim() : null;
    }

    /**
@@ -343,11 +372,11 @@ export class FastbootDevice {
     * @returns {downloadSize}
     * @throws {FastbootError}
     */
    async _getDownloadSize() {
    private async getDownloadSize() {
        try {
            let resp = (
                await this.getVariable("max-download-size")
            ).toLowerCase();
            )!.toLowerCase();
            if (resp) {
                // AOSP fastboot requires hex
                return Math.min(parseInt(resp, 16), MAX_DOWNLOAD_SIZE);
@@ -360,19 +389,12 @@ export class FastbootDevice {
        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) {
    private async sendRawPayload(buffer: ArrayBuffer, onProgress: FlashProgressCallback) {
        let i = 0;
        let remainingBytes = buffer.byteLength;
        while (remainingBytes > 0) {
@@ -391,7 +413,7 @@ export class FastbootDevice {
                );
            }

            await this.device.transferOut(this.epOut, chunk);
            await this.device!.transferOut(this.epOut!, chunk);

            remainingBytes -= chunk.byteLength;
            i += 1;
@@ -406,10 +428,10 @@ export class FastbootDevice {
     *
     * @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.
     * @param {FlashProgressCallback} onProgress - Callback for upload progress updates.
     * @throws {FastbootError}
     */
    async upload(partition, buffer, onProgress = () => {}) {
    async upload(partition: string, buffer: ArrayBuffer, onProgress: FlashProgressCallback = (progress) => {}) {
        common.logDebug(
            `Uploading single sparse to ${partition}: ${buffer.byteLength} bytes`
        );
@@ -435,15 +457,15 @@ export class FastbootDevice {
        if (downloadSize !== buffer.byteLength) {
            throw new FastbootError(
                "FAIL",
                `Bootloader wants ${buffer.byteLength} bytes, requested to send ${buffer.bytelength} bytes`
                `Bootloader wants ${buffer.byteLength} bytes, requested to send ${buffer.byteLength} bytes`
            );
        }

        common.logDebug(`Sending payload: ${buffer.byteLength} bytes`);
        await this._sendRawPayload(buffer, onProgress);
        await this.sendRawPayload(buffer, onProgress);

        common.logDebug("Payload sent, waiting for response...");
        await this._readResponse();
        await this.readResponse();
    }

    /**
@@ -475,25 +497,30 @@ export class FastbootDevice {
     *
     * @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.
     * @param {FlashProgressCallback} onProgress - Callback for flashing progress updates.
     * @throws {FastbootError}
     */
    async flashBlob(partition, blob, onProgress = () => {}) {
    async flashBlob(partition: string, blob: Blob, onProgress: FlashProgressCallback = (progress) => {}) {
        // 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 maxDlSize = await this.getDownloadSize();
        let fileHeader = await common.readBlobAsBuffer(
            blob.slice(0, Sparse.FILE_HEADER_SIZE)
        );
        let totalBytes = 0;
        if (Sparse.isSparse(fileHeader)) {

        let totalBytes = blob.size;
        let isSparse = false;
        try {
            let sparseHeader = Sparse.parseFileHeader(fileHeader);
            if (sparseHeader !== null) {
                totalBytes = sparseHeader.blocks * sparseHeader.blockSize;
        } else {
            totalBytes = blob.size;
                isSparse = true;
            }
        } catch (error) {
            // ImageError = invalid, so keep blob.size
        }

        // Logical partitions need to be resized before flashing because they're
@@ -509,7 +536,7 @@ export class FastbootDevice {
        }

        // Convert image to sparse (for splitting) if it exceeds the size limit
        if (blob.size > maxDlSize && !Sparse.isSparse(fileHeader)) {
        if (blob.size > maxDlSize && !isSparse) {
            common.logDebug(`${partition} image is raw, converting to sparse`);

            // Assume that non-sparse images will always be small enough to convert in RAM.
@@ -539,37 +566,18 @@ export class FastbootDevice {
        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.
     * @param {FactoryProgressCallback} onProgress - Progress callback for image flashing.
     */
    async flashFactoryZip(blob, wipe, onReconnect, onProgress = () => {}) {
    async flashFactoryZip(blob: Blob, wipe: boolean, onReconnect: ReconnectCallback, onProgress: FactoryProgressCallback = (progress) => {}) {
        return await flashFactoryZip(this, blob, wipe, onReconnect, onProgress);
    }
}
Loading