diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..b05075b98d89751159b522c5181ab4f9fabc7f70 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# Agent Instructions + +## Language + +- Write all code and comments in English + +## Naming conventions + +- `my-class` — CSS classes use kebab-case +- `camelCase` — variables +- `$variableName` — DOM Node variables are prefixed with `$` +- `MAJUSCULE` — global constants use UPPERCASE + +## File conventions + +- `object.manager.js` — directive class coordinating sub-classes or views +- `object.class.js` — single-responsibility class used by a manager + +## Function & JSDoc conventions + +- Add a blank line between each function +- Any function created by an agent must include a JSDoc block with: + - A function description + - Parameters with their types + - Return value with its type +- Use JSDoc for all functions with this format: + - `/** Description. */` + - `@param {Type} paramName Description.` + - `@returns {Type} Description.` +- For functions returning nothing, use `@returns {void}` +- For optional parameters, use `@param {Type} [paramName] Description.` + +## Async conventions + +- Do not leave floating promises +- Prefer `async/await` over chained `.then()` calls + +## Import conventions + +- Group imports in this order: + - External dependencies + - Internal modules + - Styles and assets +- Keep imports sorted within each group + +## UI text conventions + +- Do not hardcode user-facing strings in views +- Use translation keys and language files in `app/public/assets/languages/*.json` + +## Validation conventions + +Run the formatter from the `app/` directory before finishing: + +- After code changes, run: + - `cd app && npm run format` + - `cd app && npm run check` + +## Canary test +- If the user writes "canary", answer exactly: "AGENTS_OK" \ No newline at end of file diff --git a/app/index.html b/app/index.html index a8b9110fc9dde64a0f238acd8ef3ffb1a0bf33ad..07dcf2d06b9a2eea2cf788946846f6d876b93a50 100644 --- a/app/index.html +++ b/app/index.html @@ -10,8 +10,7 @@ /e/OS Installer - - +
diff --git a/app/src/before-leave-app.js b/app/src/before-leave-app.js deleted file mode 100644 index 04b72ff150ed86e40da007f740c4998db6d258e2..0000000000000000000000000000000000000000 --- a/app/src/before-leave-app.js +++ /dev/null @@ -1,3 +0,0 @@ -window.addEventListener("beforeunload", function (event) { - event.preventDefault(); -}); diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js deleted file mode 100644 index 484460a7d882508390826c42cccd4f0d44eee25f..0000000000000000000000000000000000000000 --- a/app/src/controller.manager.js +++ /dev/null @@ -1,478 +0,0 @@ -import { DeviceManager } from "./controller/device.manager.js"; -import { Command } from "./controller/device/command.class.js"; -import { Step } from "./controller/utils/step.class.js"; -import { WDebug } from "./debug.js"; -/* - * Class to manage process - * Check and display the steps, interact with deviceManager - */ -export class Controller { - constructor() { - this.steps = [ - new Step("let-s-get-started", undefined, true), - new Step("connect-your-phone", undefined, true), - new Step("activate-developer-options", undefined, true), - new Step("activate-oem-unlock", undefined, true), - new Step("activate-usb-debugging", undefined, true), - new Step("enable-usb-file-transfer", undefined, true), - new Step("device-detection", "connect adb", true), - ]; - this.currentIndex = 0; - this.downloadChoiceEnabled = false; - } - - async init(view) { - this.deviceManager = new DeviceManager(); - await this.deviceManager.init(); - this.view = view; - } - - setDownloadChoiceEnabled(enabled) { - this.downloadChoiceEnabled = enabled; - } - - async next() { - let current = this.steps[this.currentIndex]; - let next = this.steps[this.currentIndex + 1]; - - WDebug.log("Controller Manager Next", next); - - if (next) { - if (next.mode) { - const alreadyInMode = this.inInMode(next.mode); - WDebug.log( - `next() step="${next.name}" requires mode="${next.mode}", ` + - `alreadyInMode=${alreadyInMode}, needUserGesture=${next.needUserGesture}`, - ); - //if next step require another mode [adb|fastboot|bootloader] - if (!alreadyInMode) { - //we need reboot - WDebug.log(`next() rebooting to ${next.mode}...`); - await this.deviceManager.reboot(next.mode); - WDebug.log(`next() reboot to ${next.mode} completed`); - } - if (next.needUserGesture) { - // Wait for the device to appear on the USB bus before showing the - // step. Some host controllers (AMD Ryzen) are slow to re-enumerate - // devices after a mode switch. The actual connect happens via - // executeStep when the user clicks (WebUSB requestDevice() requires - // a user gesture). - WDebug.log( - `next() waiting for device on USB bus (needUserGesture=true, deferring connect)...`, - ); - await this.deviceManager.waitForDeviceOnBus(); - WDebug.log(`next() device wait complete, showing step to user`); - } else { - WDebug.log(`next() connecting to ${next.mode} automatically...`); - await this.deviceManager.connect(next.mode); - WDebug.log(`next() auto-connect to ${next.mode} completed`); - } - } - this.currentIndex++; - current = this.steps[this.currentIndex]; - WDebug.log( - `next() advancing to step="${current.name}", needUserGesture=${current.needUserGesture}`, - ); - this.view.onStepStarted(this.currentIndex, current); - if (!current.needUserGesture) { - await this.executeStep(current.name); - } - } - } - - async executeStep(stepName, loader) { - const current = this.steps[this.currentIndex]; - let this_command; - WDebug.log("ControllerManager Execute step", current); - document.getElementById("error-message-state").style.display = "none"; - if (current.name === stepName) { - let res = true; - let i; - try { - for (i = 0; i < current.commands.length && res; i++) { - this_command = current.commands[i]; - res = await this.runCommand(this_command, loader); - WDebug.log("run command > ", this_command, "returns ", res); - } - const next = this.steps[this.currentIndex + 1]; - let previous = this.steps[this.currentIndex - 1]; - if (res) { - if (next) { - this.view.onStepFinished(current, next); - await this.next(); - } - } else { - this.view.onStepFailed(current, previous); - if (!current.needUserGesture) { - this.currentIndex--; - } - throw new Error("command failed"); - } - } catch (e) { - throw new Error( - `Cannot execute command ${this_command.command}
${e.message || e}`, - ); - } - } else { - throw new Error( - "this is not the current step " + - current.name + - " is not equals to " + - stepName, - ); - } - } - - /** - * - * @param mode - * @returns {boolean} - * Check if device is connected to a mode - */ - inInMode(mode) { - return this.deviceManager.isInMode(mode); - } - - /* - * run a command - this throw new error if something went wwrong. - error should contain a proposal to solve the issue. - */ - async runCommand(cmd, loader) { - WDebug.log("ControllerManager run command:", cmd); - switch (cmd.type) { - case Command.CMD_TYPE.download: - try { - await this.deviceManager.downloadAll( - (loaded, total, name) => { - this.view.onDownloading(name, loaded, total); - }, - (loaded, total, name) => { - this.view.onUnzip(name, loaded, total); - }, - (loaded, total, name) => { - this.view.onVerify(name, loaded, total); - }, - ); - this.view.onDownloadingEnd(); - return true; - } catch (e) { - const proposal = "Proposal: Retry by refreshing this page."; - throw new Error( - `Cannot download
${e.message || e}
${proposal}`, - ); - } - case Command.CMD_TYPE.reboot: - try { - await this.deviceManager.reboot(cmd.mode); - return true; - } catch (e) { - throw new Error(`Reboot to ${cmd.mode} failed: ${e.message || e}`); - } - case Command.CMD_TYPE.connect: { - const proposal = - "Proposal: Check connection and that no other program is using the phone and retry."; - try { - await this.deviceManager.connect(cmd.mode); - await this.onDeviceConnected(); - if (loader) { - loader.style.display = "none"; - } - return true; - } catch (e) { - throw new Error( - `The device is not connected ${e.message || e}
${proposal}`, - ); - } - } - case Command.CMD_TYPE.erase: - return this.deviceManager.erase(cmd.partition); - case Command.CMD_TYPE.flash: { - const FLASH_COOLDOWN_MS = this.resources?.flash_cooldown_ms ?? 2500; - const result = await this.deviceManager.flash( - cmd.file, - cmd.partition, - (done, total) => { - this.view.onInstalling(cmd.file, done, total); - }, - ); - // Small delay between flash operations to prevent overwhelming the device - WDebug.log( - `Flash cooldown: waiting ${FLASH_COOLDOWN_MS}ms before next operation`, - ); - await new Promise((resolve) => setTimeout(resolve, FLASH_COOLDOWN_MS)); - return result; - } - case Command.CMD_TYPE.unlock: { - //check if unlocked to avoid unnecessary command - let isUnlocked = false; - let gotoStep = ""; - if (cmd.partition) { - if (cmd.partition.startsWith("goto_")) { - gotoStep = cmd.partition.substring(5); - WDebug.log("goto step", gotoStep); - isUnlocked = await this.deviceManager.getUnlocked("unlocked"); - } else { - isUnlocked = await this.deviceManager.getUnlocked(cmd.partition); - } - } - WDebug.log( - "ControllerManager unlock: ", - this.deviceManager.adb.getProductName() + - " isUnlocked = " + - isUnlocked, - ); - if (!isUnlocked) { - try { - await this.deviceManager.unlock(cmd.command); - } catch (e) { - //on some device, check unlocked does not work but when we try the command, it throws an error with "already unlocked" - if (e.bootloaderMessage?.includes("already")) { - WDebug.log("device already unlocked"); - } else if (e.bootloaderMessage?.includes("not allowed")) { - WDebug.log("device unlock is not allowed"); - throw new Error(`Unlock not allowed: ${e.message || e}`); - } else { - throw e; - } - } - } else { - WDebug.log("The phone is not locked - bypass lock process"); - if (gotoStep == "") { - // Goto the next step. - this.currentIndex++; - } else { - // Goto the maned step. - do { - this.currentIndex++; - WDebug.log( - "Bypass step", - this.steps[this.currentIndex].name + - " " + - (this.steps[this.currentIndex].name == gotoStep), - ); - } while (!(this.steps[this.currentIndex].name == gotoStep)); - this.currentIndex--; - } - } - return true; - } - case Command.CMD_TYPE.lock: { - let isLocked = false; - if (cmd.partition) { - isLocked = !(await this.deviceManager.getUnlocked(cmd.partition)); - } - if (!isLocked) { - try { - await this.deviceManager.lock(cmd.command); - isLocked = true; - } catch (e) { - //on some device, check unlocked does not work but when we try the command, it throws an error with "already locked" - if (e.bootloaderMessage?.includes("already")) { - WDebug.log("device already locked"); - isLocked = true; - } else { - throw new Error(`Lock failed: ${e.message || e}`); - } - } - } - return true; - } - case Command.CMD_TYPE.sideload: - try { - await this.deviceManager.connect("recovery"); - await this.deviceManager.sideload(cmd.file); - return true; - } catch (e) { - throw new Error(`Sideload ${cmd.file} failed: ${e.message || e}`); - } - case Command.CMD_TYPE.format: - try { - return this.deviceManager.format(cmd.partition); - } catch (e) { - throw new Error(`Format ${cmd.partition} failed: ${e.message || e}`); - } - case Command.CMD_TYPE.delay: - await new Promise((resolve) => setTimeout(resolve, cmd.partition)); - return true; - - default: - WDebug.log(`try unknown command ${cmd.command}`); - await this.deviceManager.runCommand(cmd.command); - return true; - } - } - - async onDeviceConnected() { - const productName = this.deviceManager.getProductName(); - if (this.deviceManager.isFirstConnection()) { - this.deviceManager.markAsConnected(); - this.view.updateData("product-name", productName); - this.model = productName; - WDebug.log("ControllerManager Model:", this.model); - try { - const resources = await this.getResources(); - - if (resources.android) { - this.view.updateData("android-version-required", resources.android); - await this.checkAndroidVersion(resources.android); - } - this.setResources(resources); - } catch (e) { - this.steps.push(new Step(e.message)); - this.view.updateTotalStep(this.steps.length); - // Don not throw this error, as it is handled by the UI directly. - } - } - } - async checkAndroidVersion(versionRequired) { - const android = await this.deviceManager.getAndroidVersion(); - WDebug.log("current android version:", android); - if (android) { - this.view.updateData("android-version", android); - if (android < versionRequired) { - throw Error("android-version-not-supported"); - } - } - } - async getResources() { - let resources = null; - try { - let current_security_path_level = null; - try { - const security_patch = await this.deviceManager.adb.getProp( - "ro.build.version.security_patch", - ); - //WDebug.log('security_patch', security_patch) - current_security_path_level = parseInt( - security_patch.replace(/-/g, ""), - 10, - ); - WDebug.log("current_security_path_level", current_security_path_level); - } catch { - WDebug.log("Security patch Error"); - current_security_path_level = null; - } - let this_model = this.deviceManager.adb.banner.device; - // https://gitlab.e.foundation/e/os/backlog/-/issues/2604#note_609234 - const model = this.deviceManager.adb.banner.model; - if (model.includes("Teracube") && model.includes("2e")) { - try { - const serial = await this.deviceManager.adb.getSerialNumber(); - WDebug.log("serial numer:", serial); - if (serial.startsWith("2021")) { - this_model = "emerald"; - } else if (serial.startsWith("2020")) { - this_model = "Teracube_2e"; - } else { - const id = - "model " + - this.deviceManager.adb.banner.model + - " " + - "product " + - this.deviceManager.adb.banner.product + - " " + - "name " + - this.deviceManager.adb.getProductName() + - " " + - "device " + - this.deviceManager.adb.banner.device; - throw new Error("Cannot find device resource", id); - } - } catch { - const id = - "model " + - this.deviceManager.adb.banner.model + - " " + - "product " + - this.deviceManager.adb.banner.product + - " " + - "name " + - this.deviceManager.adb.getProductName() + - " " + - "device " + - this.deviceManager.adb.banner.device; - throw new Error("Error on getting device resource", id); - } - } - - if (model.includes("A015")) { - try { - this_model = "tetris"; - } catch { - const id = - "model " + - this.deviceManager.adb.banner.model + - " " + - "product " + - this.deviceManager.adb.banner.product + - " " + - "name " + - this.deviceManager.adb.getProductName() + - " " + - "device " + - this.deviceManager.adb.banner.device; - throw new Error("Error on getting devcice resource", id); - } - } - - resources = await (await fetch(`resources/${this_model}.json`)).json(); - if ( - current_security_path_level != null && - typeof resources.security_patch_level != "undefined" - ) { - WDebug.log(`EOS Rom has security patch ${current_security_path_level}`); - const new_security_path_level = parseInt( - resources.security_patch_level.replace(/-/g, ""), - 10, - ); - WDebug.log(`New security patch ${new_security_path_level}`); - if (current_security_path_level > new_security_path_level) { - WDebug.log( - "Bypass lock procedure", - `resources/${this_model}-safe.json`, - ); - resources = await ( - await fetch(`resources/${this_model}-safe.json`) - ).json(); - } - } - } catch (e) { - resources = null; - WDebug.log("getResources Error: " + e); - throw Error("device-model-not-supported"); - } - - return resources; - } - - setResources(resources) { - this.resources = resources; - if (this.resources.steps) { - const needsUserGesture = this.downloadChoiceEnabled; - this.steps.push(new Step("downloading", "download", needsUserGesture)); - this.steps.push( - ...this.resources.steps.map((step) => { - return new Step( - step.id, - step.command, - step.needUserGesture ?? false, - step.mode, - ); - }), - ); - this.view.updateTotalStep(this.steps.length); - } - this.deviceManager.setResources(this.resources.folder, this.steps, { - skipClearHalt: this.resources.skip_clear_halt, - }); - } - - setLocalZip(file) { - this.deviceManager.setLocalZipFile(file); - } - - clearLocalZip() { - this.deviceManager.clearLocalZipFile(); - } -} diff --git a/app/src/controller/controller.manager.js b/app/src/controller/controller.manager.js new file mode 100644 index 0000000000000000000000000000000000000000..2d0a0a2bd3131c6da5d04145e82f78dab1c89947 --- /dev/null +++ b/app/src/controller/controller.manager.js @@ -0,0 +1,595 @@ +import { DeviceManager } from "./device.manager.js"; +import { COMMAND } from "./enums/command.enum.js"; +import { Step } from "./utils/step.class.js"; +import { DebugManager } from "./debug.manager.js"; + +/* + * Class to manage process + * Check and display the steps, interact with deviceManager + */ +export class ControllerManager { + constructor() { + this.steps = [ + new Step("let-s-get-started", undefined, true), + new Step("connect-your-phone", undefined, true), + new Step("activate-developer-options", undefined, true), + new Step("activate-oem-unlock", undefined, true), + new Step("activate-usb-debugging", undefined, true), + new Step("enable-usb-file-transfer", undefined, true), + new Step("device-detection", "connect adb", true), + ]; + this.currentIndex = 0; + this.downloadChoiceEnabled = false; + } + + /** + * Initializes controller dependencies and binds the view. + * + * @param {object} view UI view manager that receives state updates. + * @returns {Promise} Resolves when dependencies are initialized. + */ + async init(view) { + this.deviceManager = new DeviceManager(); + await this.deviceManager.init(); + this.view = view; + } + + /** + * Enables or disables manual download choice. + * + * @param {boolean} enabled Whether user-driven download selection is enabled. + * @returns {void} + */ + setDownloadChoiceEnabled(enabled) { + this.downloadChoiceEnabled = enabled; + } + + /** + * Advances to the next installer step, handling mode changes and connections. + * + * @returns {Promise} Resolves when the next step is started. + */ + async next() { + let current = this.steps[this.currentIndex]; + let next = this.steps[this.currentIndex + 1]; + + DebugManager.log("Controller Manager Next", next); + + if (next) { + if (next.mode) { + const alreadyInMode = this.inInMode(next.mode); + DebugManager.log( + `next() step="${next.name}" requires mode="${next.mode}", alreadyInMode=${alreadyInMode}, needUserGesture=${next.needUserGesture}`, + ); + //if next step require another mode [adb|fastboot|bootloader] + if (!alreadyInMode) { + //we need reboot + DebugManager.log(`next() rebooting to ${next.mode}...`); + await this.deviceManager.reboot(next.mode); + DebugManager.log(`next() reboot to ${next.mode} completed`); + } + if (next.needUserGesture) { + // Wait for the device to appear on the USB bus before showing the + // step. Some host controllers (AMD Ryzen) are slow to re-enumerate + // devices after a mode switch. The actual connect happens via + // executeStep when the user clicks (WebUSB requestDevice() requires + // a user gesture). + DebugManager.log( + `next() waiting for device on USB bus (needUserGesture=true, deferring connect)...`, + ); + await this.deviceManager.waitForDeviceOnBus(); + DebugManager.log(`next() device wait complete, showing step to user`); + } else { + DebugManager.log( + `next() connecting to ${next.mode} automatically...`, + ); + await this.deviceManager.connect(next.mode); + DebugManager.log(`next() auto-connect to ${next.mode} completed`); + } + } + this.currentIndex++; + current = this.steps[this.currentIndex]; + DebugManager.log( + `next() advancing to step="${current.name}", needUserGesture=${current.needUserGesture}`, + ); + this.view.onStepStarted(this.currentIndex, current); + if (!current.needUserGesture) { + await this.executeStep(current.name); + } + } + } + + /** + * Executes commands for the current step. + * + * @param {string} stepName Step identifier expected to be active. + * @param {HTMLElement} [loader] Loader element hidden after successful connect. + * @returns {Promise} Resolves when the step has been processed. + */ + async executeStep(stepName, loader) { + const current = this.steps[this.currentIndex]; + let this_command; + DebugManager.log("ControllerManager Execute step", current); + document.getElementById("error-message-state").style.display = "none"; + if (current.name === stepName) { + let res = true; + let i; + try { + for (i = 0; i < current.commands.length && res; i++) { + this_command = current.commands[i]; + res = await this.runCommand(this_command, loader); + DebugManager.log("run command > ", this_command, "returns ", res); + } + const next = this.steps[this.currentIndex + 1]; + let previous = this.steps[this.currentIndex - 1]; + if (res) { + if (next) { + this.view.onStepFinished(current, next); + await this.next(); + } + } else { + this.view.onStepFailed(current, previous); + if (!current.needUserGesture) { + this.currentIndex--; + } + throw new Error("command failed"); + } + } catch (e) { + throw new Error( + `Cannot execute command ${this_command.command}
${e.message || e}`, + ); + } + } else { + throw new Error( + `this is not the current step ${current.name} is not equals to ${stepName}`, + ); + } + } + + /** + * Checks whether the connected device already matches a mode. + * + * @param {string} mode Target device mode. + * @returns {boolean} True when device is already in the requested mode. + */ + inInMode(mode) { + return this.deviceManager.isInMode(mode); + } + + /** + * Runs a parsed command by dispatching to the appropriate handler. + * + * @param {import("./device/command.class.js").Command} cmd Command metadata. + * @param {HTMLElement} [loader] Optional loading element used by connect step. + * @returns {Promise} True when the command succeeds. + */ + async runCommand(cmd, loader) { + DebugManager.log("ControllerManager run command:", cmd); + switch (cmd.type) { + case COMMAND.download: + return this.runDownloadCommand(); + case COMMAND.reboot: + return this.runRebootCommand(cmd); + case COMMAND.connect: { + return this.runConnectCommand(cmd, loader); + } + case COMMAND.erase: + return this.deviceManager.erase(cmd.partition); + case COMMAND.flash: { + return this.runFlashCommand(cmd); + } + case COMMAND.unlock: { + return this.runUnlockCommand(cmd); + } + case COMMAND.lock: { + return this.runLockCommand(cmd); + } + case COMMAND.sideload: + return this.runSideloadCommand(cmd); + case COMMAND.format: + return this.runFormatCommand(cmd); + case COMMAND.delay: + return this.runDelayCommand(cmd); + + default: + return this.runUnknownCommand(cmd); + } + } + + /** + * Runs the download step, triggering file download progress callbacks. + * + * @returns {Promise} True when all files are downloaded successfully. + */ + async runDownloadCommand() { + try { + await this.deviceManager.downloadAll( + (loaded, total, name) => { + this.view.onDownloading(name, loaded, total); + }, + (loaded, total, name) => { + this.view.onUnzip(name, loaded, total); + }, + (loaded, total, name) => { + this.view.onVerify(name, loaded, total); + }, + ); + this.view.onDownloadingEnd(); + return true; + } catch (e) { + const proposal = "Proposal: Retry by refreshing this page."; + throw new Error( + `Cannot download
${e.message || e}
${proposal}`, + ); + } + } + + /** + * Reboots the device to the mode specified in the command. + * + * @param {import("./device/command.class.js").Command} cmd Reboot command. + * @returns {Promise} True when reboot command succeeds. + */ + async runRebootCommand(cmd) { + try { + await this.deviceManager.reboot(cmd.mode); + return true; + } catch (e) { + throw new Error(`Reboot to ${cmd.mode} failed: ${e.message || e}`); + } + } + + /** + * Connects to the device mode requested by the command. + * + * @param {import("./device/command.class.js").Command} cmd Connect command. + * @param {HTMLElement} [loader] Optional loading element hidden after connect. + * @returns {Promise} True when connection succeeds. + */ + async runConnectCommand(cmd, loader) { + const proposal = + "Proposal: Check connection and that no other program is using the phone and retry."; + try { + await this.deviceManager.connect(cmd.mode); + await this.onDeviceConnected(); + if (loader) { + loader.style.display = "none"; + } + return true; + } catch (e) { + throw new Error( + `The device is not connected ${e.message || e}
${proposal}`, + ); + } + } + + /** + * Flashes one file to one partition and applies a post-flash cooldown. + * + * @param {import("./device/command.class.js").Command} cmd Flash command. + * @returns {Promise} True when flashing succeeds. + */ + async runFlashCommand(cmd) { + const FLASH_COOLDOWN_MS = this.resources?.flash_cooldown_ms ?? 2500; + const result = await this.deviceManager.flash( + cmd.file, + cmd.partition, + (done, total) => { + this.view.onInstalling(cmd.file, done, total); + }, + ); + DebugManager.log( + `Flash cooldown: waiting ${FLASH_COOLDOWN_MS}ms before next operation`, + ); + await new Promise((resolve) => setTimeout(resolve, FLASH_COOLDOWN_MS)); + return result; + } + + /** + * Unlocks the bootloader if needed and can skip ahead for goto steps. + * + * @param {import("./device/command.class.js").Command} cmd Unlock command. + * @returns {Promise} True when unlock flow completes. + */ + async runUnlockCommand(cmd) { + let isUnlocked = false; + let gotoStep = ""; + if (cmd.partition) { + if (cmd.partition.startsWith("goto_")) { + gotoStep = cmd.partition.substring(5); + DebugManager.log("goto step", gotoStep); + isUnlocked = await this.deviceManager.getUnlocked("unlocked"); + } else { + isUnlocked = await this.deviceManager.getUnlocked(cmd.partition); + } + } + DebugManager.log( + "ControllerManager unlock: ", + `${this.deviceManager.adb.getProductName()} isUnlocked = ${isUnlocked}`, + ); + if (!isUnlocked) { + try { + await this.deviceManager.unlock(cmd.command); + } catch (e) { + if (e.bootloaderMessage?.includes("already")) { + DebugManager.log("device already unlocked"); + } else if (e.bootloaderMessage?.includes("not allowed")) { + DebugManager.log("device unlock is not allowed"); + throw new Error(`Unlock not allowed: ${e.message || e}`); + } else { + throw e; + } + } + } else { + DebugManager.log("The phone is not locked - bypass lock process"); + if (gotoStep == "") { + this.currentIndex++; + } else { + do { + this.currentIndex++; + DebugManager.log( + "Bypass step", + `${this.steps[this.currentIndex].name} ${this.steps[this.currentIndex].name == gotoStep}`, + ); + } while (!(this.steps[this.currentIndex].name == gotoStep)); + this.currentIndex--; + } + } + return true; + } + + /** + * Locks the bootloader if currently unlocked. + * + * @param {import("./device/command.class.js").Command} cmd Lock command. + * @returns {Promise} True when lock flow completes. + */ + async runLockCommand(cmd) { + let isLocked = false; + if (cmd.partition) { + isLocked = !(await this.deviceManager.getUnlocked(cmd.partition)); + } + if (!isLocked) { + try { + await this.deviceManager.lock(cmd.command); + isLocked = true; + } catch (e) { + if (e.bootloaderMessage?.includes("already")) { + DebugManager.log("device already locked"); + isLocked = true; + } else { + throw new Error(`Lock failed: ${e.message || e}`); + } + } + } + return true; + } + + /** + * Connects to recovery and performs sideload. + * + * @param {import("./device/command.class.js").Command} cmd Sideload command. + * @returns {Promise} True when sideload succeeds. + */ + async runSideloadCommand(cmd) { + try { + await this.deviceManager.connect("recovery"); + await this.deviceManager.sideload(cmd.file); + return true; + } catch (e) { + throw new Error(`Sideload ${cmd.file} failed: ${e.message || e}`); + } + } + + /** + * Formats a partition requested by the command. + * + * @param {import("./device/command.class.js").Command} cmd Format command. + * @returns {Promise} True when format command succeeds. + */ + async runFormatCommand(cmd) { + try { + return this.deviceManager.format(cmd.partition); + } catch (e) { + throw new Error(`Format ${cmd.partition} failed: ${e.message || e}`); + } + } + + /** + * Waits for a command-defined delay. + * + * @param {import("./device/command.class.js").Command} cmd Delay command. + * @returns {Promise} True after delay completes. + */ + async runDelayCommand(cmd) { + await new Promise((resolve) => setTimeout(resolve, cmd.partition)); + return true; + } + + /** + * Forwards unknown commands to the active device implementation. + * + * @param {import("./device/command.class.js").Command} cmd Unknown command. + * @returns {Promise} True when forwarded command finishes. + */ + async runUnknownCommand(cmd) { + DebugManager.log(`try unknown command ${cmd.command}`); + await this.deviceManager.runCommand(cmd.command); + return true; + } + + /** + * Handles device connection: updates UI, loads resources and validates version. + * + * @returns {Promise} Resolves when device data is loaded. + */ + async onDeviceConnected() { + const productName = this.deviceManager.getProductName(); + if (this.deviceManager.isFirstConnection()) { + this.deviceManager.markAsConnected(); + this.view.updateData("product-name", productName); + this.model = productName; + DebugManager.log("ControllerManager Model:", this.model); + try { + const resources = await this.getResources(); + + if (resources.android) { + this.view.updateData("android-version-required", resources.android); + await this.checkAndroidVersion(resources.android); + } + this.setResources(resources); + } catch (e) { + this.steps.push(new Step(e.message)); + this.view.updateTotalStep(this.steps.length); + // Don not throw this error, as it is handled by the UI directly. + } + } + } + + /** + * Validates the connected Android version against required version. + * + * @param {string|number} versionRequired Minimum required Android version. + * @returns {Promise} Resolves when version is valid. + */ + async checkAndroidVersion(versionRequired) { + const android = await this.deviceManager.getAndroidVersion(); + DebugManager.log("current android version:", android); + if (android) { + this.view.updateData("android-version", android); + if (android < versionRequired) { + throw Error("android-version-not-supported"); + } + } + } + + /** + * Fetches the device resource JSON based on the connected device model. + * + * @returns {Promise} Resource object or throws device-model-not-supported. + */ + async getResources() { + let resources = null; + try { + let current_security_path_level = null; + try { + const security_patch = await this.deviceManager.adb.getProp( + "ro.build.version.security_patch", + ); + //DebugManager.log('security_patch', security_patch) + current_security_path_level = parseInt( + security_patch.replace(/-/g, ""), + 10, + ); + DebugManager.log( + "current_security_path_level", + current_security_path_level, + ); + } catch { + DebugManager.log("Security patch Error"); + current_security_path_level = null; + } + let this_model = this.deviceManager.adb.banner.device; + // https://gitlab.e.foundation/e/os/backlog/-/issues/2604#note_609234 + const model = this.deviceManager.adb.banner.model; + if (model.includes("Teracube") && model.includes("2e")) { + try { + const serial = await this.deviceManager.adb.getSerialNumber(); + DebugManager.log("serial numer:", serial); + if (serial.startsWith("2021")) { + this_model = "emerald"; + } else if (serial.startsWith("2020")) { + this_model = "Teracube_2e"; + } else { + const id = `model ${this.deviceManager.adb.banner.model} product ${this.deviceManager.adb.banner.product} name ${this.deviceManager.adb.getProductName()} device ${this.deviceManager.adb.banner.device}`; + throw new Error("Cannot find device resource", id); + } + } catch { + const id = `model ${this.deviceManager.adb.banner.model} product ${this.deviceManager.adb.banner.product} name ${this.deviceManager.adb.getProductName()} device ${this.deviceManager.adb.banner.device}`; + throw new Error("Error on getting device resource", id); + } + } + + if (model.includes("A015")) { + this_model = "tetris"; + } + + resources = await (await fetch(`resources/${this_model}.json`)).json(); + if ( + current_security_path_level != null && + typeof resources.security_patch_level != "undefined" + ) { + DebugManager.log( + `EOS Rom has security patch ${current_security_path_level}`, + ); + const new_security_path_level = parseInt( + resources.security_patch_level.replace(/-/g, ""), + 10, + ); + DebugManager.log(`New security patch ${new_security_path_level}`); + if (current_security_path_level > new_security_path_level) { + DebugManager.log( + "Bypass lock procedure", + `resources/${this_model}-safe.json`, + ); + resources = await ( + await fetch(`resources/${this_model}-safe.json`) + ).json(); + } + } + } catch (e) { + resources = null; + DebugManager.log(`getResources Error: ${e}`); + throw Error("device-model-not-supported"); + } + + return resources; + } + + /** + * Applies device resources and extends installer steps. + * + * @param {object} resources Device resource payload from JSON file. + * @returns {void} + */ + setResources(resources) { + this.resources = resources; + if (this.resources.steps) { + const needsUserGesture = this.downloadChoiceEnabled; + this.steps.push(new Step("downloading", "download", needsUserGesture)); + this.steps.push( + ...this.resources.steps.map((step) => { + return new Step( + step.id, + step.command, + step.needUserGesture ?? false, + step.mode, + ); + }), + ); + this.view.updateTotalStep(this.steps.length); + } + this.deviceManager.setResources(this.resources.folder, this.steps, { + skipClearHalt: this.resources.skip_clear_halt, + }); + } + + /** + * Sets a local zip selected by the user. + * + * @param {File} file Local zip file. + * @returns {void} + */ + setLocalZip(file) { + this.deviceManager.setLocalZipFile(file); + } + + /** + * Clears the previously set local zip file. + * + * @returns {void} + */ + clearLocalZip() { + this.deviceManager.clearLocalZipFile(); + } +} diff --git a/app/src/controller/debug.manager.js b/app/src/controller/debug.manager.js new file mode 100644 index 0000000000000000000000000000000000000000..4a4cf3ea96489b6d690ea7e138cafbfd6b773131 --- /dev/null +++ b/app/src/controller/debug.manager.js @@ -0,0 +1,24 @@ +/** + * Centralized debug logger used by controller components. + */ +export class DebugManager { + constructor() {} + + /** + * Writes a debug message to the console. + * + * @param {...any} args Values to log. + */ + static log(...args) { + console.log("[DEBUG]", ...args); + } + + /** + * Writes an error message to the console. + * + * @param {...any} args Values to log as an error. + */ + static error(...args) { + console.error("[ERROR]", ...args); + } +} diff --git a/app/src/controller/device.manager.js b/app/src/controller/device.manager.js index 009e14e62565e577dd6a044eed1a0fabb5d416c5..c84d417e94eafd38ff3adf7f5178746e2d38d96e 100644 --- a/app/src/controller/device.manager.js +++ b/app/src/controller/device.manager.js @@ -1,19 +1,18 @@ import { Bootloader } from "./device/bootloader.class.js"; -import { Downloader } from "./downloader.manager.js"; +import { DownloaderManager } from "./downloader.manager.js"; import { ADB } from "./device/adb.class.js"; import { Recovery } from "./device/recovery.class.js"; import { Device } from "./device/device.class.js"; -import { WDebug } from "../debug.js"; -const MODE = { - adb: "adb", - recovery: "recovery", - bootloader: "bootloader", -}; +import { DebugManager } from "./debug.manager.js"; +import { MODE } from "./enums/mode.enum.js"; /** - * wrap device functions - * */ + * Facade over ADB, bootloader, recovery and download operations. + */ export class DeviceManager { + /** + * Creates a new DeviceManager and initializes all sub-managers. + */ constructor() { this.model = ""; this.rom = undefined; @@ -24,22 +23,43 @@ export class DeviceManager { this.bootloader = new Bootloader(); this.recovery = new Recovery(); this.adb = new ADB(); - this.downloader = new Downloader(); + this.downloader = new DownloaderManager(); this.wasConnected = false; } + /** + * Sets a local ROM zip file to be used instead of remote download. + * + * @param {File} file Local zip file selected by the user. + * @returns {void} + */ setLocalZipFile(file) { this.downloader.setLocalZip(file); } + /** + * Clears the previously set local zip file. + * + * @returns {void} + */ clearLocalZipFile() { this.downloader.clearLocalZip(); } + /** + * Returns whether a local zip file has been set. + * + * @returns {boolean} True when a local zip file is available. + */ hasLocalZipFile() { return this.downloader.hasLocalZip(); } + /** + * Initializes all device sub-managers. + * + * @returns {Promise} Resolves when all managers are ready. + */ async init() { await this.bootloader.init(); await this.adb.init(); @@ -47,14 +67,32 @@ export class DeviceManager { await this.downloader.init(); } + /** + * Returns whether a device has not yet been connected in this session. + * + * @returns {boolean} True when no connection has occurred yet. + */ isFirstConnection() { return !this.wasConnected; } + /** + * Marks the device as connected for the current session. + * + * @returns {void} + */ markAsConnected() { this.wasConnected = true; } + /** + * Stores resource metadata and optional behavior flags. + * + * @param {Array} folder Resource file descriptors. + * @param {Array} steps Installer steps used to collect required files. + * @param {object} [options] Optional controller flags. + * @returns {void} + */ setResources(folder, steps, options) { this.folder = folder; this.files = steps @@ -69,12 +107,30 @@ export class DeviceManager { } } + /** + * Reads bootloader lock state variable. + * + * @param {string} variable Fastboot variable name to query. + * @returns {Promise} True when variable indicates unlocked state. + */ async getUnlocked(variable) { return this.bootloader.isUnlocked(variable); } + + /** + * Returns the Android version of the connected device. + * + * @returns {Promise} Android version string. + */ async getAndroidVersion() { return await this.device.getAndroidVersion(); } + + /** + * Checks whether the device is still detected on the USB bus. + * + * @returns {Promise} True when device appears in USB device list. + */ async isDetected() { const serial = this.serialNumber; if (serial) { @@ -85,12 +141,10 @@ export class DeviceManager { } /** - * @param mode - * @returns {any} - * - * We set the device to the mode manager we went to connect to - * And we connect the device + * Sets the active device implementation for a given mode. * + * @param {string} mode Device mode (`adb`, `bootloader`, or `recovery`). + * @returns {Promise} Resolves after active mode is set. */ async setMode(mode) { switch (mode) { @@ -105,6 +159,13 @@ export class DeviceManager { break; } } + + /** + * Connects to the requested mode. + * + * @param {string} mode Target mode to connect. + * @returns {Promise} Result returned by the selected device connector. + */ async connect(mode) { await this.setMode(mode); try { @@ -115,33 +176,51 @@ export class DeviceManager { } /** - * @param mode - * @returns {boolean} + * Checks whether the active device is currently in a given mode. * + * @param {string} mode Target mode. + * @returns {boolean} True when active device matches mode. */ isInMode(mode) { switch (mode) { - case "bootloader": + case MODE.bootloader: return this.device.isBootloader(); - case "adb": + case MODE.adb: return this.device.isADB(); - case "recovery": + case MODE.recovery: return this.device.isRecovery(); } return false; } + /** + * Erases a fastboot partition. + * + * @param {string} partition Partition name. + * @returns {Promise} True when erase command succeeds. + */ async erase(partition) { await this.bootloader.runCommand(`erase:${partition}`); return true; } + /** + * Formats a partition via fastboot (not universally supported). + * + * @returns {boolean} Always true; actual format is not implemented. + */ format() { return true; // return this.bootloader.runCommand(`format ${argument}`); // the fastboot format md_udc is not supported evne by the official fastboot program } + /** + * Sends a bootloader unlock command. + * + * @param {string} command Fastboot unlock command. + * @returns {Promise} True when unlock command completes. + */ async unlock(command) { // Unlock requires physical confirmation on the device (volume keys + // power button), so use a generous 5-minute timeout instead of the @@ -150,12 +229,26 @@ export class DeviceManager { return true; } + /** + * Sends a bootloader lock command. + * + * @param {string} command Fastboot lock command. + * @returns {Promise} True when lock command completes. + */ async lock(command) { // Lock may also require physical confirmation on the device. await this.bootloader.runCommand(command, 300_000); return true; } + /** + * Flashes a downloaded file to a partition. + * + * @param {string} file Filename in downloader cache. + * @param {string} partition Target partition. + * @param {(done:number,total:number)=>void} onProgress Flash progress callback. + * @returns {Promise} True when flashing succeeds. + */ async flash(file, partition, onProgress) { let blob = await this.downloader.getFile(file); if (!blob) { @@ -170,14 +263,30 @@ export class DeviceManager { return flashed; } + /** + * Returns the product name of the connected device. + * + * @returns {string|undefined} Product name string or undefined. + */ getProductName() { return this.device.getProductName(); } + /** + * Returns the serial number of the connected device. + * + * @returns {string|undefined} Serial number string or undefined. + */ getSerialNumber() { return this.device.getSerialNumber(); } + /** + * Reboots the connected device to a target mode. + * + * @param {string} mode Target reboot mode. + * @returns {Promise} Reboot result from the active device. + */ async reboot(mode) { const res = await this.device.reboot(mode); if (res) { @@ -186,6 +295,12 @@ export class DeviceManager { return res; } + /** + * Sideloads a zip file through recovery. + * + * @param {string} file Filename in downloader cache. + * @returns {Promise} Sideload result from the active device. + */ async sideload(file) { let blob = await this.downloader.getFile(file); if (!blob) { @@ -195,6 +310,12 @@ export class DeviceManager { return await this.device.sideload(blob); } + /** + * Runs a raw command against the active device implementation. + * + * @param {string} command Device command. + * @returns {Promise} Command result from active device. + */ async runCommand(command) { try { return this.device.runCommand(command); @@ -208,32 +329,35 @@ export class DeviceManager { * Some USB host controllers (e.g., AMD Ryzen) are slow to re-enumerate * devices after a mode switch, especially Mediatek bootloader devices. * Resolves when a device appears or after timeout (does not reject). + * + * @param {number} [timeoutMs=30000] Max wait before resolving. + * @returns {Promise} Resolves when device appears or timeout expires. */ waitForDeviceOnBus(timeoutMs = 30000) { const startTime = Date.now(); return new Promise((resolve) => { const devices = navigator.usb.getDevices(); devices.then((list) => { - WDebug.log( + DebugManager.log( `waitForDeviceOnBus: getDevices() returned ${list.length} device(s)`, list.map((d) => `${d.vendorId}:${d.productId} "${d.productName}"`), ); if (list.length > 0) { - WDebug.log( + DebugManager.log( "waitForDeviceOnBus: device already visible, no wait needed", ); resolve(); return; } - WDebug.log( + DebugManager.log( `waitForDeviceOnBus: no devices found, listening for USB connect event (timeout=${timeoutMs}ms)...`, ); const timeout = setTimeout(() => { navigator.usb.removeEventListener("connect", onConnect); const elapsed = Date.now() - startTime; - WDebug.log( + DebugManager.log( `waitForDeviceOnBus: timeout after ${elapsed}ms, no device appeared. Proceeding anyway.`, ); resolve(); @@ -242,15 +366,13 @@ export class DeviceManager { const onConnect = (event) => { const elapsed = Date.now() - startTime; const d = event.device; - WDebug.log( - `waitForDeviceOnBus: USB connect event after ${elapsed}ms - ` + - `vendorId=${d.vendorId} productId=${d.productId} ` + - `productName="${d.productName}" serialNumber="${d.serialNumber}"`, + DebugManager.log( + `waitForDeviceOnBus: USB connect event after ${elapsed}ms - vendorId=${d.vendorId} productId=${d.productId} productName="${d.productName}" serialNumber="${d.serialNumber}"`, ); clearTimeout(timeout); navigator.usb.removeEventListener("connect", onConnect); // Small delay to let the device fully initialize after enumeration - WDebug.log( + DebugManager.log( "waitForDeviceOnBus: waiting 1000ms for device to stabilize...", ); setTimeout(resolve, 1000); @@ -261,6 +383,14 @@ export class DeviceManager { }); } + /** + * Downloads all required files or ingests them from a local zip. + * + * @param {(loaded:number,total:number,name:string)=>void} onProgress Download callback. + * @param {(loaded:number,total:number,name:string)=>void} onUnzip Unzip callback. + * @param {(loaded:number,total:number,name:string)=>void} onVerify Verification callback. + * @returns {Promise} Resolves when all required files are available. + */ async downloadAll(onProgress, onUnzip, onVerify) { try { if (this.downloader.hasLocalZip()) { diff --git a/app/src/controller/device/adb.class.js b/app/src/controller/device/adb.class.js index bb6875c88f7c8820358a8c6e6f8713fde8f635f6..aa8eff3fe2d6793a202d74e66d3be4dc73ee9197 100644 --- a/app/src/controller/device/adb.class.js +++ b/app/src/controller/device/adb.class.js @@ -1,17 +1,33 @@ import { Device } from "./device.class.js"; -import { WDebug } from "../../debug.js"; +import { DebugManager } from "../debug.manager.js"; import { AdbDevice } from "../../lib/index.ts"; +/** + * ADB mode implementation backed by `AdbDevice`. + */ export class ADB extends Device { + /** + * @param {any} [device] Optional initial device handle. + */ constructor(device) { super(device); this._adbDevice = null; } + /** + * Identifies this instance as an ADB device. + * + * @returns {boolean} Always true. + */ isADB() { return true; } + /** + * Requests and connects to an ADB-compatible USB device. + * + * @returns {Promise} Resolves when the device is connected. + */ async connect() { try { console.log("debug adb connect"); @@ -23,12 +39,12 @@ export class ADB extends Device { this.device = { name: this._adbDevice.usbDevice.productName }; const banner = this._adbDevice.banner; - WDebug.log("----------------------------------"); - WDebug.log("Model", banner.model); - WDebug.log("product", banner.product); - WDebug.log("Name", this._adbDevice.usbDevice.productName); - WDebug.log(">Device (codename)", banner.device); - WDebug.log("----------------------------------"); + DebugManager.log("----------------------------------"); + DebugManager.log("Model", banner.model); + DebugManager.log("product", banner.product); + DebugManager.log("Name", this._adbDevice.usbDevice.productName); + DebugManager.log(">Device (codename)", banner.device); + DebugManager.log("----------------------------------"); } catch (e) { console.error(e); this.device = null; @@ -36,31 +52,69 @@ export class ADB extends Device { } } + /** + * Returns the USB product name of the connected ADB device. + * + * @returns {string|undefined} Product name or undefined. + */ getProductName() { return this._adbDevice?.usbDevice?.productName; } + /** + * Returns the ADB banner data for the connected device. + * + * @returns {{device:string, model:string, product:string}} Banner data. + */ get banner() { return this._adbDevice?.banner || { device: "", model: "", product: "" }; } + /** + * Reads one Android system property. + * + * @param {string} name Property key. + * @returns {Promise} Property value. + */ async getProp(name) { return this._adbDevice.getProp(name); } + /** + * Returns the Android version of the connected device. + * + * @returns {Promise} Android version string. + */ async getAndroidVersion() { return this._adbDevice.getProp("ro.build.version.release"); } + /** + * Returns the serial number of the connected device via ADB property. + * + * @returns {Promise} Serial number string. + */ async getSerialNumber() { return this._adbDevice.getProp("ro.boot.serialno"); } + /** + * Executes an adb shell command. + * + * @param {string} cmd Shell command. + * @returns {Promise} Shell command result. + */ async runCommand(cmd) { - WDebug.log("ADB Run command>", cmd); + DebugManager.log("ADB Run command>", cmd); return await this._adbDevice.shell(cmd); } + /** + * Reboots the device through adb. + * + * @param {string} mode Target reboot mode. + * @returns {Promise} Reboot command result. + */ async reboot(mode) { return await this._adbDevice.reboot(mode); } diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index 55a90acbd52b05c855adfca53bcef26d4d14a867..8c281d91a147585c3d41a8048906621f91feee39 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -5,51 +5,80 @@ import { LogLevel, } from "@e/fastboot"; import { Device } from "./device.class.js"; -import { WDebug } from "../../debug.js"; +import { DebugManager } from "../debug.manager.js"; /** - * wrap fastboot interactions - * */ + * Wrap fastboot interactions. + */ export class Bootloader extends Device { + /** + * Creates a new Bootloader instance with no active fastboot connection. + */ constructor() { super(null); this.fastboot = null; this.skipClearHalt = false; } + /** + * Configures the fastboot log level. + * + * @returns {Promise} Resolves when initialization is complete. + */ async init() { setLogLevel(LogLevel.Debug); } + /** + * Reboots the device from fastboot. + * + * @param {string} mode Target reboot mode. + * @returns {any} Reboot result from fastboot transport. + */ reboot(mode) { return this.fastboot.reboot(mode); } + /** + * Runs a raw fastboot command. + * + * @param {string} command Fastboot command. + * @param {number} [timeoutMs] Command timeout in milliseconds. + * @returns {any} Result from fastboot command execution. + */ runCommand(command, timeoutMs) { return this.fastboot.runCommand(command, timeoutMs); } + /** + * Identifies this instance as a bootloader/fastboot device. + * + * @returns {boolean} Always true. + */ isBootloader() { return true; } + /** + * Attempts to connect to a fastboot device with retry logic. + * + * @returns {Promise} Resolves when the device is successfully connected. + */ async connect() { const MAX_CONNECT_ATTEMPTS = 3; const CONNECT_RETRY_DELAY = 2000; // 2 seconds const connectStart = Date.now(); - WDebug.log( - `Bootloader.connect() starting, maxAttempts=${MAX_CONNECT_ATTEMPTS}, ` + - `retryDelay=${CONNECT_RETRY_DELAY}ms`, + DebugManager.log( + `Bootloader.connect() starting, maxAttempts=${MAX_CONNECT_ATTEMPTS}, retryDelay=${CONNECT_RETRY_DELAY}ms`, ); for (let attempt = 1; attempt <= MAX_CONNECT_ATTEMPTS; attempt++) { try { // Log paired devices before each attempt for debugging const pairedDevices = await navigator.usb.getDevices(); - WDebug.log( - `Bootloader.connect() attempt ${attempt}/${MAX_CONNECT_ATTEMPTS}: ` + - `${pairedDevices.length} paired USB device(s)`, + DebugManager.log( + `Bootloader.connect() attempt ${attempt}/${MAX_CONNECT_ATTEMPTS}: ${pairedDevices.length} paired USB device(s)`, pairedDevices.map( (d) => `${d.vendorId}:${d.productId} "${d.productName}"`, ), @@ -64,12 +93,12 @@ export class Bootloader extends Device { // connect() but fail on the first real transfer. try { await this.fastboot.getVariable("version"); - WDebug.log( + DebugManager.log( `Bootloader.connect() existing connection verified in ${Date.now() - connectStart}ms`, ); return; } catch { - WDebug.log( + DebugManager.log( "Bootloader.connect() existing connection stale, reconnecting...", ); try { @@ -89,42 +118,41 @@ export class Bootloader extends Device { }); const elapsed = Date.now() - connectStart; - WDebug.log( + DebugManager.log( `Bootloader.connect() succeeded on attempt ${attempt} after ${elapsed}ms`, ); return; } catch (e) { const errorMsg = e.message || String(e); const elapsed = Date.now() - connectStart; - WDebug.log( + DebugManager.log( `Bootloader.connect() attempt ${attempt} failed after ${elapsed}ms: ${errorMsg}`, ); // If this is the last attempt, throw the error if (attempt === MAX_CONNECT_ATTEMPTS) { throw new Error( - `Cannot connect to bootloader after ${MAX_CONNECT_ATTEMPTS} attempts. ` + - `The device may not be in bootloader mode yet. ` + - `Please ensure the device is in bootloader/fastboot mode and try again. ` + - `Error: ${errorMsg}`, + `Cannot connect to bootloader after ${MAX_CONNECT_ATTEMPTS} attempts. The device may not be in bootloader mode yet. Please ensure the device is in bootloader/fastboot mode and try again. Error: ${errorMsg}`, ); } // Wait before retry, with increasing delay const delay = CONNECT_RETRY_DELAY * attempt; - WDebug.log( + DebugManager.log( `Bootloader.connect() waiting ${delay}ms before attempt ${attempt + 1}...`, ); await new Promise((resolve) => setTimeout(resolve, delay)); // Try to reset USB device to clear stale state if (this.fastboot) { - WDebug.log("Bootloader.connect() attempting USB device reset..."); + DebugManager.log( + "Bootloader.connect() attempting USB device reset...", + ); try { await this.fastboot.resetDevice(); - WDebug.log("Bootloader.connect() USB device reset succeeded"); + DebugManager.log("Bootloader.connect() USB device reset succeeded"); } catch (resetErr) { - WDebug.log( + DebugManager.log( `Bootloader.connect() USB device reset failed: ${resetErr.message || resetErr}`, ); this.fastboot = null; // Force new device on next attempt @@ -134,37 +162,60 @@ export class Bootloader extends Device { } } + /** + * Returns the USB product name of the fastboot device. + * + * @returns {string|undefined} Product name or undefined. + */ getProductName() { return this.fastboot?.usbDevice?.productName; } + /** + * Returns the USB serial number of the fastboot device. + * + * @returns {string|undefined} Serial number or undefined. + */ getSerialNumber() { return this.fastboot?.usbDevice?.serialNumber; } /** - * Close the USB device and re-establish a fresh connection. + * Closes the USB device and re-establishes a fresh connection. * This is more thorough than resetDevice() and helps recover from * degraded USB sessions (e.g., AMD Ryzen + Mediatek). + * + * @returns {Promise} Resolves when re-connection is complete. */ async reconnectDevice() { if (!this.fastboot) { - WDebug.log("reconnectDevice: no fastboot device reference, skipping"); + DebugManager.log( + "reconnectDevice: no fastboot device reference, skipping", + ); return; } - WDebug.log("reconnectDevice: reconnecting USB session..."); + DebugManager.log("reconnectDevice: reconnecting USB session..."); try { await this.fastboot.reconnect(); - WDebug.log( + DebugManager.log( `reconnectDevice: connection re-established, isConnected=${this.fastboot.isConnected}`, ); } catch (e) { - WDebug.log(`reconnectDevice: reconnect failed: ${e.message || e}`); + DebugManager.log(`reconnectDevice: reconnect failed: ${e.message || e}`); throw e; } } + /** + * Flashes one blob to a partition with timeout retries. + * + * @param {string} partition Target partition. + * @param {Blob} blob Blob payload. + * @param {(sent:number,total:number,partition:string)=>void} onProgress Progress callback. + * @param {number} [attempt=1] Current retry attempt. + * @returns {Promise} True when flash succeeds. + */ async flashBlob(partition, blob, onProgress, attempt = 1) { const MAX_ATTEMPTS = 3; const RETRY_DELAY_MS = 5000; // Wait before retry to let device stabilize @@ -176,45 +227,45 @@ export class Bootloader extends Device { } try { - WDebug.log( - `flashBlob: ${partition} (${(blob.size / 1024 / 1024).toFixed(1)} MB), ` + - `attempt ${attempt}/${MAX_ATTEMPTS}`, + DebugManager.log( + `flashBlob: ${partition} (${(blob.size / 1024 / 1024).toFixed(1)} MB), attempt ${attempt}/${MAX_ATTEMPTS}`, ); await this.fastboot.flashBlob(partition, blob, (sent, total) => { onProgress(sent, total, partition); }); onProgress(blob.size, blob.size, partition); const elapsed = Date.now() - flashStart; - WDebug.log(`flashBlob: ${partition} succeeded in ${elapsed}ms`); + DebugManager.log(`flashBlob: ${partition} succeeded in ${elapsed}ms`); return true; } catch (e) { if (e instanceof TimeoutError) { const elapsed = Date.now() - flashStart; - WDebug.log( - `flashBlob: timeout on ${partition} after ${elapsed}ms ` + - `(attempt ${attempt}/${MAX_ATTEMPTS})`, + DebugManager.log( + `flashBlob: timeout on ${partition} after ${elapsed}ms (attempt ${attempt}/${MAX_ATTEMPTS})`, ); if (attempt < MAX_ATTEMPTS) { - WDebug.log(`flashBlob: waiting ${RETRY_DELAY_MS}ms before retry...`); + DebugManager.log( + `flashBlob: waiting ${RETRY_DELAY_MS}ms before retry...`, + ); await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); // Try to reset USB device to clear stale state - WDebug.log("flashBlob: attempting USB device reset..."); + DebugManager.log("flashBlob: attempting USB device reset..."); try { await this.fastboot.resetDevice(); - WDebug.log("flashBlob: USB device reset succeeded"); + DebugManager.log("flashBlob: USB device reset succeeded"); } catch (resetErr) { - WDebug.log( + DebugManager.log( `flashBlob: USB device reset failed: ${resetErr.message || resetErr}`, ); } // Reconnect for a fresh USB session - WDebug.log("flashBlob: reconnecting for fresh USB session..."); + DebugManager.log("flashBlob: reconnecting for fresh USB session..."); try { await this.reconnectDevice(); } catch (reconnErr) { - WDebug.log( + DebugManager.log( `flashBlob: reconnect failed: ${reconnErr.message || reconnErr}`, ); } @@ -229,8 +280,7 @@ export class Bootloader extends Device { return await this.flashBlob(partition, blob, onProgress, attempt + 1); } throw new Error( - `Bootloader timeout: flashing ${partition} failed after ${MAX_ATTEMPTS} attempts. ` + - `Try using a different USB port or cable.`, + `Bootloader timeout: flashing ${partition} failed after ${MAX_ATTEMPTS} attempts. Try using a different USB port or cable.`, ); } else { console.log("flashBlob error", e); @@ -239,10 +289,22 @@ export class Bootloader extends Device { } } + /** + * Boots a blob image via fastboot. + * + * @param {Blob} blob Boot image blob. + * @returns {any} Result from boot command. + */ bootBlob(blob) { return this.fastboot.bootBlob(blob); } + /** + * Returns unlocked state for a fastboot variable. + * + * @param {string} variable Variable name to query. + * @returns {Promise} True when variable indicates unlocked state. + */ async isUnlocked(variable) { if (this.fastboot?.isConnected) { try { @@ -256,6 +318,12 @@ export class Bootloader extends Device { return false; } + /** + * Returns locked state for a fastboot variable. + * + * @param {string} variable Variable name to query. + * @returns {Promise} True when variable indicates locked state. + */ async isLocked(variable) { if (this.fastboot?.isConnected) { try { @@ -269,6 +337,12 @@ export class Bootloader extends Device { return false; } + /** + * Sends the unlock command to fastboot. + * + * @param {string} command Unlock command. + * @returns {Promise} Resolves when unlock command finishes. + */ async unlock(command) { if (command) { await this.fastboot.runCommand(command); @@ -277,6 +351,12 @@ export class Bootloader extends Device { } } + /** + * Sends the lock command to fastboot. + * + * @param {string} command Lock command. + * @returns {Promise} True when device is locked after command. + */ async lock(command) { if (command) { await this.fastboot.runCommand(command); diff --git a/app/src/controller/device/command.class.js b/app/src/controller/device/command.class.js index b572d2df9caf6be8fec19da68fb4746d1ba4fbdd..0345c77ac1c64eb692d825393dcf45c34d6254f3 100644 --- a/app/src/controller/device/command.class.js +++ b/app/src/controller/device/command.class.js @@ -1,19 +1,13 @@ -import { WDebug } from "../../debug.js"; +import { DebugManager } from "../debug.manager.js"; +import { COMMAND } from "../enums/command.enum.js"; +/** + * Parses textual step commands into structured command metadata. + */ export class Command { - static CMD_TYPE = { - flash: "flash", - sideload: "sideload", - erase: "erase", - unlock: "unlock", - lock: "lock", - connect: "connect", - reboot: "reboot", - download: "download", - format: "format", - delay: "delay", - }; - + /** + * @param {string} cmd Raw command string from resource step. + */ constructor(cmd) { this.command = cmd; this.type = undefined; @@ -23,35 +17,41 @@ export class Command { this.parseCommand(cmd); } + /** + * Parses command tokens and fills command fields. + * + * @param {string} cmd Raw command string. + * @returns {void} + */ parseCommand(cmd) { const res = cmd .split(" ") .map((m) => m.trim()) .filter((m) => m != ""); - WDebug.log(`command.class parseCommand : ${cmd}`); + DebugManager.log(`command.class parseCommand : ${cmd}`); switch (res[0]) { case "download": - this.type = Command.CMD_TYPE.download; + this.type = COMMAND.download; break; case "connect": - this.type = Command.CMD_TYPE.connect; + this.type = COMMAND.connect; this.mode = res[1]; // adb or fastboot; break; case "flash": - this.type = Command.CMD_TYPE.flash; + this.type = COMMAND.flash; this.partition = res[1]; this.file = res[2]; break; case "sideload": - this.type = Command.CMD_TYPE.sideload; + this.type = COMMAND.sideload; this.file = res[1]; break; case "erase": - this.type = Command.CMD_TYPE.erase; + this.type = COMMAND.erase; this.partition = res[1]; break; case "reboot": - this.type = Command.CMD_TYPE.reboot; + this.type = COMMAND.reboot; this.mode = res[1]; break; case "flashing": @@ -59,18 +59,18 @@ export class Command { this.command = `${res[0]} ${res[1]}`; this.partition = res[2]; if (res[1].startsWith("unlock")) { - this.type = Command.CMD_TYPE.unlock; + this.type = COMMAND.unlock; } else if (res[1].startsWith("lock")) { - this.type = Command.CMD_TYPE.lock; + this.type = COMMAND.lock; } break; case "delay": - this.type = Command.CMD_TYPE.delay; + this.type = COMMAND.delay; this.partition = parseInt(res[1], 10) * 1000; break; case "format": this.partition = res[1]; - this.type = Command.CMD_TYPE.format; + this.type = COMMAND.format; break; } } diff --git a/app/src/controller/device/device.class.js b/app/src/controller/device/device.class.js index 61a50dbffc118d08f07f8ab82ed91495c8064406..6d045c76d1ef9d8cc7e87446852dad819751d7c5 100644 --- a/app/src/controller/device/device.class.js +++ b/app/src/controller/device/device.class.js @@ -1,51 +1,128 @@ +/** + * Base device abstraction implemented by ADB, bootloader and recovery modes. + */ export class Device { + /** + * @param {any} device Underlying transport-specific device handle. + */ constructor(device) { this.device = device; } + /** + * Default no-op init. + * + * @returns {Promise} Resolves immediately. + */ async init() {} + /** + * Default no-op connect. + * + * @returns {Promise} Resolves immediately. + */ async connect() {} + /** + * Returns whether this is an ADB device. + * + * @returns {boolean} Always false in the base implementation. + */ isADB() { return false; } + /** + * Returns whether this is a bootloader/fastboot device. + * + * @returns {boolean} Always false in the base implementation. + */ isBootloader() { return false; } + /** + * Returns whether this is a fastboot device. + * + * @returns {boolean} Always false in the base implementation. + */ isFastboot() { return false; } + /** + * Returns whether this is a recovery device. + * + * @returns {boolean} Always false in the base implementation. + */ isRecovery() { return false; } + /** + * Flashes a blob to a partition for device types that support it. + * + * @param {string} partition Target partition. + * @param {Blob} blob Blob payload. + * @returns {Promise} Default false for unsupported implementations. + */ async flashBlob() { return false; } + /** + * Runs a raw command on the connected device. + * + * @param {string} command Command text. + * @returns {Promise} Default false for unsupported implementations. + */ async runCommand() { return false; } + /** + * Returns the product name. Override in subclass. + * + * @returns {undefined} Base implementation returns undefined. + */ getProductName() { return undefined; } + /** + * Returns the serial number. Override in subclass. + * + * @returns {undefined} Base implementation returns undefined. + */ getSerialNumber() { return undefined; } + + /** + * Returns the Android version. Override in subclass. + * + * @returns {Promise} Base implementation returns undefined. + */ async getAndroidVersion() { return undefined; } + /** + * Reboots the connected device. + * + * @param {string} mode Target reboot mode. + * @returns {any} Device-specific reboot result. + */ reboot() { return undefined; } + /** + * Boots from a blob payload for device types that support it. + * + * @param {Blob} blob Boot image blob. + * @returns {Promise} Default false for unsupported implementations. + */ async bootBlob() { return false; } diff --git a/app/src/controller/device/recovery.class.js b/app/src/controller/device/recovery.class.js index fae5d33ab421f8d0e41945aaf423b7a80c5ff5d3..b6f8c4435ed9a57919b022e4b875af3d7d636e94 100644 --- a/app/src/controller/device/recovery.class.js +++ b/app/src/controller/device/recovery.class.js @@ -1,34 +1,59 @@ import { Device } from "./device.class.js"; -import { WDebug } from "../../debug.js"; +import { DebugManager } from "../debug.manager.js"; import { AdbDevice } from "../../lib/index.ts"; +/** + * Recovery mode implementation using ADB sideload transport. + */ export class Recovery extends Device { + /** + * @param {any} [device] Optional initial device handle. + */ constructor(device) { super(device); this._adbDevice = null; } + /** + * Identifies this instance as a recovery device. + * + * @returns {boolean} Always true. + */ isRecovery() { return true; } + /** + * Requests and connects to a device in recovery mode. + * + * @returns {Promise} Resolves when the device is connected. + */ async connect() { try { this._adbDevice = await AdbDevice.requestDevice(); await this._adbDevice.connect(); this.device = { name: this._adbDevice.usbDevice.productName }; - WDebug.log("Recovery connected:", this._adbDevice.usbDevice.productName); + DebugManager.log( + "Recovery connected:", + this._adbDevice.usbDevice.productName, + ); } catch (e) { this.device = null; throw new Error(`Cannot connect Recovery ${e.message || e}`); } } + /** + * Sideloads a ROM zip blob through recovery. + * + * @param {Blob} blob Zip blob to sideload. + * @returns {Promise} Resolves when sideload completes. + */ async sideload(blob) { try { await this._adbDevice.sideload(blob, (block, totalBlocks) => { if (block % 10 === 0) { - WDebug.log(`Sideloading block ${block}/${totalBlocks}`); + DebugManager.log(`Sideloading block ${block}/${totalBlocks}`); } }); } catch (e) { @@ -36,14 +61,30 @@ export class Recovery extends Device { } } + /** + * Reboots the recovery-connected device. + * + * @param {string} mode Target reboot mode. + * @returns {Promise} Reboot result. + */ async reboot(mode) { return await this._adbDevice.reboot(mode); } + /** + * Returns the USB product name of the connected recovery device. + * + * @returns {string|undefined} Product name or undefined. + */ getProductName() { return this._adbDevice?.usbDevice?.productName; } + /** + * Returns the USB serial number of the connected recovery device. + * + * @returns {string|undefined} Serial number or undefined. + */ getSerialNumber() { return this._adbDevice?.usbDevice?.serialNumber; } diff --git a/app/src/controller/downloader.manager.js b/app/src/controller/downloader.manager.js index 446146b4554b8e5c16e2178886b28fd62a263eef..70c482be3f5da30daa1fdc4f117d730cf7ba457d 100644 --- a/app/src/controller/downloader.manager.js +++ b/app/src/controller/downloader.manager.js @@ -1,45 +1,74 @@ -const DB_NAME = "MurenaBlobStore"; -const DB_VERSION = 1; +import { BlobStore } from "./utils/blob-store.class.js"; +import { HttpFetcher } from "./utils/http-fetcher.class.js"; +import { Zip } from "./utils/zip.class.js"; -import ky from "ky"; -import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"; -import { createSHA256 } from "hash-wasm"; +const MAX_RETRIES = 3; /** * Download Manager - * Download files from the device folder of [modelname].json - * Download with DBStore and unzip if necessary - * Blobs are in this.files[filename] + * Orchestrates downloading, unzipping, checksum verification and caching of + * device firmware files described in [modelname].json. + * Low-level concerns are delegated to BlobStore, HttpFetcher and Zip. */ -export class Downloader { +export class DownloaderManager { + /** + * Creates a new DownloaderManager with an empty in-memory blob store. + */ constructor() { - this.db = null; + this.store = new BlobStore(); + this.fetcher = new HttpFetcher(); this.stored = {}; this.localZipFile = null; this.blobs = {}; } + /** + * Initializes the internal blob store. + * + * @returns {Promise} Resolves when the store is ready. + */ async init() { - if (this.db) return; // Already initialized - - this.db = await this.openDBStore(); - await this.clearDBStore(); + await this.store.init(); } + /** + * Saves a local zip file for offline ingestion. + * + * @param {File} file Local zip file. + * @returns {void} + */ setLocalZip(file) { this.localZipFile = file; } + /** + * Clears the previously set local zip file. + * + * @returns {void} + */ clearLocalZip() { this.localZipFile = null; } + /** + * Returns whether a local zip file has been set. + * + * @returns {boolean} True when a local zip file is available. + */ hasLocalZip() { return !!this.localZipFile; } - /* - * */ + /** + * Downloads files, verifies checksums, and stores extracted artifacts. + * + * @param {string[]} filesRequired Required output filenames. + * @param {Array} folder Resource descriptors from JSON. + * @param {(loaded:number,total:number,name:string)=>void} onDownloadProgress Download progress callback. + * @param {(loaded:number,total:number,name:string)=>void} onUnzipProgress Unzip progress callback. + * @param {(loaded:number,total:number,name:string)=>void} onVerifyProgress Verify progress callback. + * @returns {Promise} Resolves when required files are downloaded and stored. + */ async downloadAndUnzipFolder( filesRequired, folder, @@ -47,96 +76,30 @@ export class Downloader { onUnzipProgress, onVerifyProgress, ) { - await this.clearDBStore(); + await this.store.clear(); this.stored = {}; let current_file; try { - for (let i = 0; i < folder.length; i++) { - const file = folder[i]; + for (const file of folder) { current_file = file.path; - if (filesRequired.includes(file.name) || file.unzip) { - const MAX_RETRIES = 3; - let attempt = 0; - let blob; - let checksumSuccess = false; - - while (attempt < MAX_RETRIES && !checksumSuccess) { - attempt++; - try { - blob = await this.download(file.path, (value, total) => { - onDownloadProgress(value, total, file.name); - }); + if (!filesRequired.includes(file.name) && !file.unzip) continue; - // Simple zip integrity check: fetch ".sha256sum" and compare. - const expected = await this.fetchChecksum( - `${file.path}.sha256sum`, - ); - const actual = await this.computeSha256(blob, (loaded, total) => { - onVerifyProgress(loaded, total, file.name); - }); - if (expected && actual !== expected) { - throw new Error( - `Checksum mismatch for ${file.name}: expected ${expected} got ${actual}`, - ); - } - checksumSuccess = true; - } catch (err) { - console.warn( - `Attempt ${attempt}/${MAX_RETRIES} failed for ${file.name}: ${err.message}`, - ); - if (attempt >= MAX_RETRIES) { - throw new Error( - `Failed to download and verify ${file.name} after ${MAX_RETRIES} attempts: ${err.message}`, - ); - } - } - } + const blob = await this.downloadWithRetry( + file, + onDownloadProgress, + onVerifyProgress, + ); - if (file.unzip) { - const zipReader = new ZipReader(new BlobReader(blob)); - const filesEntries = await zipReader.getEntries(); - for (let j = 0; j < filesEntries.length; j++) { - const unzippedEntry = await this.getFileFromZip( - filesEntries[j], - (value, total) => { - onUnzipProgress(value, total, filesEntries[j].filename); - }, - ); - let filename = this.getMappedName( - filesEntries[j].filename, - file.mapping, - ); - if (filesRequired.includes(filename)) { - this.blobs[filename] = unzippedEntry.blob; - this.stored[filename] = true; - try { - await this.setInDBStore(unzippedEntry.blob, filename); - } catch (e) { - console.warn( - `IndexedDB write failed for ${filename}: ${e.message || e}`, - ); - } - const fileSHA = await this.computeSha256( - unzippedEntry.blob, - (loaded, total) => { - onVerifyProgress(loaded, total, filename); - }, - ); - console.log(`File: ${unzippedEntry.name} SHA256: ${fileSHA}`); - } - } - await zipReader.close(); - } else { - this.blobs[file.name] = blob; - this.stored[file.name] = true; - try { - await this.setInDBStore(blob, file.name); - } catch (e) { - console.warn( - `IndexedDB write failed for ${file.name}: ${e.message || e}`, - ); - } - } + if (file.unzip) { + await this.storeZipEntries( + blob, + file, + filesRequired, + onUnzipProgress, + onVerifyProgress, + ); + } else { + await this.storeBlob(blob, file.name); } } } catch (e) { @@ -146,6 +109,16 @@ export class Downloader { } } + /** + * Reads a user-provided zip and extracts only required files. + * + * @param {string[]} filesRequired Required output filenames. + * @param {Array} folder Resource descriptors from JSON. + * @param {(loaded:number,total:number,name:string)=>void} onDownloadProgress Download progress callback. + * @param {(loaded:number,total:number,name:string)=>void} onUnzipProgress Unzip progress callback. + * @param {(loaded:number,total:number,name:string)=>void} onVerifyProgress Verify progress callback. + * @returns {Promise} Resolves when required files are ingested. + */ async ingestLocalZip( filesRequired, folder, @@ -153,7 +126,7 @@ export class Downloader { onUnzipProgress, onVerifyProgress, ) { - await this.clearDBStore(); + await this.store.clear(); this.stored = {}; if (!this.localZipFile) { throw new Error("No local zip file selected"); @@ -169,256 +142,133 @@ export class Downloader { this.localZipFile.name || zipDescriptor.name || "local.zip", ); - const zipReader = new ZipReader(new BlobReader(this.localZipFile)); - const filesEntries = await zipReader.getEntries(); - for (let i = 0; i < filesEntries.length; i++) { - const unzippedEntry = await this.getFileFromZip( - filesEntries[i], - (value, total) => { - onUnzipProgress(value, total, filesEntries[i].filename); - }, - ); - let filename = this.getMappedName( - filesEntries[i].filename, - zipDescriptor.mapping, - ); + const { entries, reader } = await Zip.openZip(this.localZipFile); + for (const entry of entries) { + const { name, blob } = await Zip.extractEntry(entry, onUnzipProgress); + const filename = Zip.getMappedName(name, zipDescriptor.mapping); if (filesRequired.includes(filename)) { - await this.setInDBStore(unzippedEntry.blob, filename); + await this.store.set(blob, filename); this.stored[filename] = true; - const fileSHA = await this.computeSha256( - unzippedEntry.blob, - (loaded, total) => { - onVerifyProgress(loaded, total, filename); - }, + const fileSHA = await this.fetcher.computeSha256( + blob, + (loaded, total) => onVerifyProgress(loaded, total, filename), ); - console.log(`File: ${unzippedEntry.name} SHA256: ${fileSHA}`); + console.log(`File: ${name} SHA256: ${fileSHA}`); } } - await zipReader.close(); - } - - async getFileFromZip(file, onProgress) { - const name = file.filename; - const blob = await file.getData(new BlobWriter(), { - onprogress: (value, total) => { - onProgress(value, total, name); - }, - useWebWorkers: false, - }); - return { - name, - blob, - }; - } - - getMappedName(filename, map) { - if (!map) { - return filename; - } - - console.log(map); - for (const [regex, newFilename] of Object.entries(map)) { - let re = new RegExp(regex); - if (filename.match(re)) { - return newFilename; - } - } - return filename; - } - - async fetchChecksum(url) { - const res = await ky.get(url); - if (!res.ok) { - throw new Error(`Cannot fetch checksum (${res.status})`); - } - const body = (await res.text()).trim(); - const match = body.match(/[a-fA-F0-9]{64}/); - if (!match) { - throw new Error("Invalid checksum content"); - } - return match[0].toLowerCase(); + await reader.close(); } /** - * Streaming SHA-256 computation for large files. - * Processes blob in 16MB chunks to avoid memory limits on low-memory devices. - */ - async computeSha256(blob, onVerifyProgress) { - const CHUNK_SIZE = 16 * 1024 * 1024; // 16MB chunks - const hasher = await createSHA256(); - const blobSize = blob.size; - hasher.init(); - - let offset = 0; - while (offset < blobSize) { - const chunk = blob.slice(offset, offset + CHUNK_SIZE); - const buffer = await chunk.arrayBuffer(); - hasher.update(new Uint8Array(buffer)); - onVerifyProgress(offset, blobSize); - offset += CHUNK_SIZE; - } - - return hasher.digest("hex"); - } - - /** - * @param name - * @returns {} - * It does not launch download (downloads are launched with downloadFolder) - * this function retrieve the promise linked to the fileName + * Returns the Blob for a previously downloaded file. + * + * @param {string} name Stored filename. + * @returns {Promise} Blob for requested filename. */ async getFile(name) { if (!this.stored[name]) { throw new Error(`File ${name} was not previously downloaded`); } - // Prefer in-memory blob (always available in the current session) - // over IndexedDB (large blobs can silently fail to commit). - if (this.blobs[name]) { - return this.blobs[name]; - } - return await this.getFromDBStore(name); + return this.blobs[name] ?? (await this.store.get(name)); } - /* - * getData from a zip file - * */ - async getData(dbFile, fileEntry, onProgress) { - const _zip = new BlobWriter(); - const blob = await fileEntry.getData(_zip, { - onprogress: (value, total) => { - onProgress(value, total, dbFile); - }, - onend: () => {}, - useWebWorkers: true, - }); - return blob; - } - - async download(path, onProgress) { - try { - const buffers = await this.fetch( - { - url: path, - chunkSize: 16 * 1024 * 1024, - poolLimit: 1, - }, - onProgress, - ); - - //let totalSize = buffers.reduce((sum, buffer) => sum + buffer.byteLength, 0); - const ret = new Blob(buffers); - return ret; - } catch (e) { - throw new Error(`${e.message || e}`); - } - } - - async clearDBStore() { - const store = this.db - .transaction(DB_NAME, "readwrite") - .objectStore(DB_NAME); - store.clear(); - } - - async setInDBStore(blob, key) { - return new Promise((resolve, reject) => { - const transaction = this.db.transaction(DB_NAME, "readwrite"); - const store = transaction.objectStore(DB_NAME); - store.put(blob, key); - - // Wait for the transaction to fully commit — request.onsuccess fires - // before commit and can't detect QuotaExceededError on large blobs. - transaction.oncomplete = () => resolve(); - transaction.onerror = (event) => reject(event.target.error); - transaction.onabort = () => - reject(new Error("IndexedDB transaction aborted (storage quota?)")); - }); - } - - async getFromDBStore(key) { - return new Promise((resolve, reject) => { - const transaction = this.db.transaction(DB_NAME, "readonly"); - const store = transaction.objectStore(DB_NAME); - const request = store.get(key); - request.onsuccess = function (event) { - const result = event.target.result; - if (result) { - resolve(result); - } else { - resolve(null); + /** + * Downloads a file and validates checksum with automatic retries. + * + * @param {object} file Resource descriptor containing path and name. + * @param {(loaded:number,total:number,name:string)=>void} onDownloadProgress Download progress callback. + * @param {(loaded:number,total:number,name:string)=>void} onVerifyProgress Verify progress callback. + * @returns {Promise} Downloaded and verified blob. + */ + async downloadWithRetry(file, onDownloadProgress, onVerifyProgress) { + let lastErr; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const blob = await this.fetcher.download(file.path, (value, total) => + onDownloadProgress(value, total, file.name), + ); + const expected = await this.fetcher.fetchChecksum( + `${file.path}.sha256sum`, + ); + const actual = await this.fetcher.computeSha256(blob, (loaded, total) => + onVerifyProgress(loaded, total, file.name), + ); + if (expected && actual !== expected) { + throw new Error( + `Checksum mismatch for ${file.name}: expected ${expected} got ${actual}`, + ); } - }; - request.onerror = function (event) { - reject(event.target.error); - }; - }); - } - - async openDBStore() { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onerror = reject; - request.onupgradeneeded = function (event) { - const db = event.target.result; - db.createObjectStore(DB_NAME, { autoIncrement: false }); - }; - request.onsuccess = function (event) { - resolve(event.target.result); - }; - }); - } - - concatenate(arrays) { - if (!arrays.length) return null; - let totalLength = arrays.reduce((acc, value) => acc + value.length, 0); - let result = new Uint8Array(totalLength); - let length = 0; - for (let array of arrays) { - result.set(array, length); - length += array.length; + return blob; + } catch (err) { + lastErr = err; + console.warn( + `Attempt ${attempt}/${MAX_RETRIES} failed for ${file.name}: ${err.message}`, + ); + } } - return result; - } - - async getContentLength(url) { - const response = await ky.head(url); - const contentLength = response.headers.get("content-length"); - return parseInt(contentLength, 10); + throw new Error( + `Failed to download and verify ${file.name} after ${MAX_RETRIES} attempts: ${lastErr.message}`, + ); } - async fetch({ url, chunkSize }, onProgress) { - try { - const contentLength = await this.getContentLength(url); - const totalChunks = Math.ceil(contentLength / chunkSize); - const buffers = []; - - for (let i = 0; i < totalChunks; i++) { - const start = i * chunkSize; - const end = Math.min(start + chunkSize - 1, contentLength - 1); + /** + * Extracts required entries from a zip blob and stores them. + * + * @param {Blob} blob Zip blob. + * @param {object} file Resource descriptor including mapping rules. + * @param {string[]} filesRequired Required output filenames. + * @param {(loaded:number,total:number,name:string)=>void} onUnzipProgress Unzip progress callback. + * @param {(loaded:number,total:number,name:string)=>void} onVerifyProgress Verify progress callback. + * @returns {Promise} Resolves when required entries are stored. + */ + async storeZipEntries( + blob, + file, + filesRequired, + onUnzipProgress, + onVerifyProgress, + ) { + const { entries, reader } = await Zip.openZip(blob); + for (const entry of entries) { + const { name, blob: entryBlob } = await Zip.extractEntry( + entry, + onUnzipProgress, + ); + const filename = Zip.getMappedName(name, file.mapping); + if (filesRequired.includes(filename)) { + this.blobs[filename] = entryBlob; + this.stored[filename] = true; try { - const response = await ky.get(url, { - headers: { - Range: `bytes=${start}-${end}`, - }, - }); - if (!response.ok) { - throw new Error( - `Cannot download chunk (1) ${i + 1}: ${response.status} ${response.statusText}`, - ); - } - - const chunk = await response.arrayBuffer(); - buffers.push(chunk); - onProgress(start + chunk.byteLength, contentLength); - } catch (chunkError) { - throw new Error( - `Cannot download chunk (2) ${i + 1} ${chunkError.message || chunkError}`, + await this.store.set(entryBlob, filename); + } catch (e) { + console.warn( + `IndexedDB write failed for ${filename}: ${e.message || e}`, ); } + const fileSHA = await this.fetcher.computeSha256( + entryBlob, + (loaded, total) => onVerifyProgress(loaded, total, filename), + ); + console.log(`File: ${name} SHA256: ${fileSHA}`); } - return buffers; - } catch (error) { - throw new Error(`Download fails ${error.message || error}`); + } + await reader.close(); + } + + /** + * Stores one blob both in memory and IndexedDB. + * + * @param {Blob} blob File blob. + * @param {string} name Blob key. + * @returns {Promise} Resolves when store operation finishes. + */ + async storeBlob(blob, name) { + this.blobs[name] = blob; + this.stored[name] = true; + try { + await this.store.set(blob, name); + } catch (e) { + console.warn(`IndexedDB write failed for ${name}: ${e.message || e}`); } } } diff --git a/app/src/controller/enums/command.enum.js b/app/src/controller/enums/command.enum.js new file mode 100644 index 0000000000000000000000000000000000000000..3460c595275eb332414d815e9771d17b025312b2 --- /dev/null +++ b/app/src/controller/enums/command.enum.js @@ -0,0 +1,12 @@ +export const COMMAND = { + flash: "flash", + sideload: "sideload", + erase: "erase", + unlock: "unlock", + lock: "lock", + connect: "connect", + reboot: "reboot", + download: "download", + format: "format", + delay: "delay", +}; diff --git a/app/src/controller/enums/mode.enum.js b/app/src/controller/enums/mode.enum.js new file mode 100644 index 0000000000000000000000000000000000000000..a11e85ea9fb53c87ab8c305356c653e5c00b9729 --- /dev/null +++ b/app/src/controller/enums/mode.enum.js @@ -0,0 +1,5 @@ +export const MODE = { + adb: "adb", + recovery: "recovery", + bootloader: "bootloader", +}; diff --git a/app/src/controller/utils/blob-store.class.js b/app/src/controller/utils/blob-store.class.js new file mode 100644 index 0000000000000000000000000000000000000000..bd00f706103da089ed2a4ea1fbe5aeda22cb2f23 --- /dev/null +++ b/app/src/controller/utils/blob-store.class.js @@ -0,0 +1,93 @@ +const DB_NAME = "MurenaBlobStore"; +const DB_VERSION = 1; + +/** + * Thin wrapper around IndexedDB for storing and retrieving Blobs by name. + */ +export class BlobStore { + constructor() { + this.db = null; + } + + /** + * Opens the IndexedDB database and clears any stale data. + * + * @returns {Promise} Resolves when the store is initialized. + */ + async init() { + if (this.db) return; + this.db = await this.open(); + await this.clear(); + } + + /** + * Opens the IndexedDB database and object store. + * + * @returns {Promise} Opened IndexedDB database handle. + */ + async open() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onerror = reject; + request.onupgradeneeded = (event) => { + event.target.result.createObjectStore(DB_NAME, { + autoIncrement: false, + }); + }; + request.onsuccess = (event) => resolve(event.target.result); + }); + } + + /** + * Removes all entries from the object store. + * + * @returns {Promise} Resolves when the store is cleared. + */ + async clear() { + return new Promise((resolve, reject) => { + const store = this.db + .transaction(DB_NAME, "readwrite") + .objectStore(DB_NAME); + const request = store.clear(); + request.onsuccess = () => resolve(); + request.onerror = (event) => reject(event.target.error); + }); + } + + /** + * Stores one blob under a key. + * + * @param {Blob} blob Blob payload. + * @param {string} key Storage key. + * @returns {Promise} Resolves when transaction completes. + */ + async set(blob, key) { + return new Promise((resolve, reject) => { + const store = this.db + .transaction(DB_NAME, "readwrite") + .objectStore(DB_NAME); + const request = store.put(blob, key); + request.onsuccess = () => resolve(); + request.onerror = (event) => reject(event.target.error); + request.onabort = () => + reject(new Error("IndexedDB transaction aborted (storage quota?)")); + }); + } + + /** + * Gets one blob by key. + * + * @param {string} key Storage key. + * @returns {Promise} Stored blob or null when missing. + */ + async get(key) { + return new Promise((resolve, reject) => { + const store = this.db + .transaction(DB_NAME, "readonly") + .objectStore(DB_NAME); + const request = store.get(key); + request.onsuccess = (event) => resolve(event.target.result ?? null); + request.onerror = (event) => reject(event.target.error); + }); + } +} diff --git a/app/src/controller/utils/http-fetcher.class.js b/app/src/controller/utils/http-fetcher.class.js new file mode 100644 index 0000000000000000000000000000000000000000..777ac906fb737391b9a6d3dd2fb7b2e290f6c250 --- /dev/null +++ b/app/src/controller/utils/http-fetcher.class.js @@ -0,0 +1,96 @@ +import ky from "ky"; +import { createSHA256 } from "hash-wasm"; + +const SHA256_CHUNK_SIZE = 16 * 1024 * 1024; // 16 MB +const DOWNLOAD_CHUNK_SIZE = 16 * 1024 * 1024; // 16 MB + +/** + * Handles chunked HTTP downloads and SHA-256 verification. + */ +export class HttpFetcher { + /** + * Downloads a file in sequential Range chunks and returns a Blob. + * + * @param {string} path File URL to download. + * @param {(loaded:number,total:number)=>void} onProgress Progress callback. + * @returns {Promise} Downloaded file blob. + */ + async download(path, onProgress) { + const contentLength = await this.getContentLength(path); + const totalChunks = Math.ceil(contentLength / DOWNLOAD_CHUNK_SIZE); + const buffers = []; + + for (let i = 0; i < totalChunks; i++) { + const start = i * DOWNLOAD_CHUNK_SIZE; + const end = Math.min(start + DOWNLOAD_CHUNK_SIZE - 1, contentLength - 1); + try { + const response = await ky.get(path, { + headers: { Range: `bytes=${start}-${end}` }, + }); + if (!response.ok) { + throw new Error( + `Cannot download chunk ${i + 1}: ${response.status} ${response.statusText}`, + ); + } + const chunk = await response.arrayBuffer(); + buffers.push(chunk); + onProgress(start + chunk.byteLength, contentLength); + } catch (err) { + throw new Error( + `Cannot download chunk ${i + 1}: ${err.message || err}`, + ); + } + } + return new Blob(buffers); + } + + /** + * Fetches the SHA-256 checksum from a ".sha256sum" sidecar file. + * + * @param {string} url Checksum file URL. + * @returns {Promise} Parsed lowercase SHA-256 hash. + */ + async fetchChecksum(url) { + const res = await ky.get(url); + if (!res.ok) { + throw new Error(`Cannot fetch checksum (${res.status})`); + } + const body = (await res.text()).trim(); + const match = body.match(/[a-fA-F0-9]{64}/); + if (!match) { + throw new Error("Invalid checksum content"); + } + return match[0].toLowerCase(); + } + + /** + * Streaming SHA-256 over a Blob, processed in 16 MB chunks. + * + * @param {Blob} blob Blob to hash. + * @param {(loaded:number,total:number)=>void} onVerifyProgress Verification callback. + * @returns {Promise} Computed SHA-256 hex digest. + */ + async computeSha256(blob, onVerifyProgress) { + const hasher = await createSHA256(); + hasher.init(); + let offset = 0; + while (offset < blob.size) { + const chunk = blob.slice(offset, offset + SHA256_CHUNK_SIZE); + hasher.update(new Uint8Array(await chunk.arrayBuffer())); + onVerifyProgress(offset, blob.size); + offset += SHA256_CHUNK_SIZE; + } + return hasher.digest("hex"); + } + + /** + * Reads remote content length using a HEAD request. + * + * @param {string} url File URL. + * @returns {Promise} Content length in bytes. + */ + async getContentLength(url) { + const response = await ky.head(url); + return parseInt(response.headers.get("content-length"), 10); + } +} diff --git a/app/src/controller/utils/step.class.js b/app/src/controller/utils/step.class.js index f5dfdb3ed856b656c3a11951a6c6f512b18959f0..6db11c15deb5c0644db8c229f8f7a341d7ad5b5e 100644 --- a/app/src/controller/utils/step.class.js +++ b/app/src/controller/utils/step.class.js @@ -1,6 +1,15 @@ import { Command } from "../device/command.class.js"; +/** + * Represents one installer step + */ export class Step { + /** + * @param {string} name Step identifier. + * @param {string|string[]} [command] Raw command or command list. + * @param {boolean} needUserGesture Whether step must be triggered by user action. + * @param {string} [mode] Device mode expected for this step. + */ constructor(name, command, needUserGesture, mode) { this.name = name; this.id = new Date().getTime() + Math.round(Math.random() * 1000); diff --git a/app/src/controller/utils/zip.class.js b/app/src/controller/utils/zip.class.js new file mode 100644 index 0000000000000000000000000000000000000000..0b38b31964a586e9447ab4e46893dbe214a913f9 --- /dev/null +++ b/app/src/controller/utils/zip.class.js @@ -0,0 +1,50 @@ +import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"; + +/** + * Helpers for reading zip archives and mapping entry filenames. + */ +export class Zip { + /** + * Extracts one zip entry as a blob. + * + * @param {object} entry Zip entry descriptor. + * @param {(loaded:number,total:number,name:string)=>void} onProgress Extraction callback. + * @returns {Promise<{name:string, blob:Blob}>} Extracted entry metadata and blob. + */ + static async extractEntry(entry, onProgress) { + const blob = await entry.getData(new BlobWriter(), { + onprogress: (value, total) => onProgress(value, total, entry.filename), + useWebWorkers: false, + }); + return { name: entry.filename, blob }; + } + + /** + * Opens a zip blob and returns entries and reader handle. + * + * @param {Blob} blob Zip blob. + * @returns {Promise<{entries:Array, reader:any}>} Zip entries and reader instance. + */ + static async openZip(blob) { + const reader = new ZipReader(new BlobReader(blob)); + const entries = await reader.getEntries(); + return { entries, reader }; + } + + /** + * Applies filename remapping using regex map rules. + * + * @param {string} filename Entry filename. + * @param {Record} map Regex mapping table. + * @returns {string} Mapped filename or original name. + */ + static getMappedName(filename, map) { + if (!map) return filename; + for (const [regex, newFilename] of Object.entries(map)) { + if (filename.match(new RegExp(regex))) { + return newFilename; + } + } + return filename; + } +} diff --git a/app/src/debug.js b/app/src/debug.js deleted file mode 100644 index 96d0e9d40711c3826e64890b4522e84f4a7bf7a1..0000000000000000000000000000000000000000 --- a/app/src/debug.js +++ /dev/null @@ -1,10 +0,0 @@ -export class WDebug { - constructor() {} - - static log(...args) { - console.log("[DEBUG]", ...args); - } - static error(...args) { - console.error("[ERROR]", ...args); - } -} diff --git a/app/src/errorManager.js b/app/src/view/error.manager.js similarity index 52% rename from app/src/errorManager.js rename to app/src/view/error.manager.js index d55b7baaa2607ce70f102a1944b0ad9f5b0ccbc3..58cda78e17844ca702022f178d615f804c6ab035 100644 --- a/app/src/errorManager.js +++ b/app/src/view/error.manager.js @@ -1,20 +1,3 @@ -/* - * Copyright 2024 - ECORP SAS - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - /* * Class to manage errors * The Error are NOT translated, as the may not be predicted in all the case diff --git a/app/src/view/listener.manager.js b/app/src/view/listener.manager.js new file mode 100644 index 0000000000000000000000000000000000000000..393996f0ad93f21ef80e7b68a636fbc2569d6b1c --- /dev/null +++ b/app/src/view/listener.manager.js @@ -0,0 +1,33 @@ +import ViewManager from "./view.manager.js"; + +document.addEventListener("DOMContentLoaded", async () => { + var VIEW = new ViewManager(); + await VIEW.init(); + + let elts = document.querySelectorAll(".card button"); + for (let elt of elts) { + const card = elt.parentElement.parentElement; + if (!card || card.className.includes("inactive")) { + continue; + } + elt.addEventListener("click", async () => { + VIEW.executeStep(elt, card.id); + }); + } +}); + +window.addEventListener("beforeunload", (event) => { + event.preventDefault(); +}); + +window.addEventListener("load", () => { + if (!("usb" in navigator)) { + document.getElementById("overlay-background").classList.remove("inactive"); + document + .getElementById("navigator-not-supported") + .classList.remove("inactive"); + document + .getElementById("let-s-get-started-button") + .classList.add("inactive"); + } +}); diff --git a/app/src/vue/translation.manager.js b/app/src/view/translation.manager.js similarity index 93% rename from app/src/vue/translation.manager.js rename to app/src/view/translation.manager.js index 46afc00681d4d44c7bc16e8e9d4fdc4c5f2931c7..39817365b878c9ab49b7b80470b950a17be5504e 100644 --- a/app/src/vue/translation.manager.js +++ b/app/src/view/translation.manager.js @@ -4,7 +4,7 @@ * also change single Node text if needed * [data-translate] is used to find Nodes needing translation */ -import { WDebug } from "../debug.js"; +import { DebugManager } from "../controller/debug.manager.js"; export class TranslationManager { static DEFAULT = "en"; //if user languages not found, fallback to 'en' @@ -19,6 +19,7 @@ export class TranslationManager { await this.loadCurrentTranslation(); // fetch translation await this.translateDOM(); // use it for DOM } + /** * @returns string * using the current languageIndex, we retrieve the string language @@ -27,6 +28,7 @@ export class TranslationManager { getCurrentLanguage() { return this.languages[this.languageIndex]; } + async loadCurrentTranslation() { const language = this.getCurrentLanguage(); const shortLanguage = TranslationManager.getShortLanguage(language); @@ -44,6 +46,7 @@ export class TranslationManager { } } } + /** * select DOM elements using data-translate key * translate each DOM element elected @@ -64,14 +67,15 @@ export class TranslationManager { $el.innerHTML = this.translate($el.dataset.translate, values); } } + /** * @param key : string * @param values : object? * @returns string * using key to retrieve the wanted string in the translations * if values is a defined object, we use it to set value in the translation - * ex: if values = { "hello" : "Hallo" } and translation = "{{hello}} Welt !" - * returned text is "Hallo Welt !" + * ex: if values = { "product" : "FP5" } and translation = "Votre téléphone {{product-name}}" + * returned text is "Votre téléphone FP5" */ translate(key, values) { let text = this.translation[key]; @@ -119,7 +123,7 @@ export class TranslationManager { try { return await response.json(); } catch { - WDebug.error("translation file is not a valid JSON: ", language); + DebugManager.error("translation file is not a valid JSON: ", language); throw Error(`${language} translation file is not a valid JSON`); } } else { @@ -127,6 +131,7 @@ export class TranslationManager { throw Error(`${language} not found`); } } + /** * @returns string[] * get user's preferred languages diff --git a/app/src/viewManager.js b/app/src/view/view.manager.js similarity index 71% rename from app/src/viewManager.js rename to app/src/view/view.manager.js index 55bebfe54794912e520accea64e811830bb68cbb..56505921b5600903aae18c62d79b587590711a0a 100644 --- a/app/src/viewManager.js +++ b/app/src/view/view.manager.js @@ -1,7 +1,7 @@ -import { WDebug } from "./debug.js"; -import { ErrorManager } from "./errorManager.js"; -import { Controller } from "./controller.manager.js"; -import { TranslationManager } from "./vue/translation.manager.js"; +import { DebugManager } from "../controller/debug.manager.js"; +import { ErrorManager } from "./error.manager.js"; +import { ControllerManager } from "../controller/controller.manager.js"; +import { TranslationManager } from "./translation.manager.js"; /* * Class to manage events @@ -10,12 +10,22 @@ import { TranslationManager } from "./vue/translation.manager.js"; */ export default class ViewManager { + /** + * Creates a new view manager instance. + * + * @returns {void} + */ constructor() {} + /** + * Initializes the view dependencies and the main controller. + * + * @returns {Promise} + */ async init() { - this.WDebug = WDebug; + this.DebugManager = DebugManager; this.ErrorManager = ErrorManager; - this.controller = new Controller(); + this.controller = new ControllerManager(); this.downloadChoiceEnabled = new URLSearchParams(window.location.search).get("debug") === "1"; this.controller.setDownloadChoiceEnabled(this.downloadChoiceEnabled); @@ -25,6 +35,13 @@ export default class ViewManager { window.scroll(0, 0); } + /** + * Adds the current step to the UI flow and wires its interactions. + * + * @param {number} currentIndex Index of the current step. + * @param {{ id?: string, name: string }} step Step definition to display. + * @returns {void} + */ selectStep(currentIndex, step) { const $stepIndex = document.getElementById("current-step"); $stepIndex.innerText = currentIndex + 1; @@ -78,24 +95,24 @@ export default class ViewManager { } } + /** + * Updates the total number of steps shown in the UI. + * + * @param {number|string} total Total number of steps. + * @returns {void} + */ updateTotalStep(total) { const $total = document.getElementById("total-step"); $total.innerText = total; } - // BUTTON EVENTS - async onNext($button) { - $button.disabled = true; - try { - await this.controller.next(); - } catch (e) { - this.ErrorManager.displayError_state( - "Error on next", - `${e.message || e}`, - ); - $button.disabled = false; - } - } + /** + * Executes a user step and manages the visual state of its button. + * + * @param {HTMLButtonElement} $button Button triggering the step. + * @param {string} stepName Internal name of the step to execute. + * @returns {Promise} + */ async executeStep($button, stepName) { $button.disabled = true; let loader = $button.querySelector(".btn-loader"); @@ -117,10 +134,25 @@ export default class ViewManager { } } } + + /** + * Handles the start of a workflow step. + * + * @param {number} currentIndex Index of the started step. + * @param {{ id?: string, name: string }} currentStep Started step. + * @returns {void} + */ onStepStarted(currentIndex, currentStep) { this.selectStep(currentIndex, currentStep); } + /** + * Updates the UI after a step has finished. + * + * @param {{ id: string, name?: string } | null | undefined} currentStep Step that just completed. + * @param {{ id?: string, name?: string } | null | undefined} nextStep Optional next step. + * @returns {void} + */ onStepFinished(currentStep, nextStep) { if (currentStep) { const $currentStep = document.getElementById(currentStep.id); @@ -142,21 +174,22 @@ export default class ViewManager { $next.disabled = !nextStep.needUser;*/ } } - onStepFailed() {} - - // /BUTTON EVENTS - /* - * STEP 1 : Connect + /** + * Extension point called when a step fails. + * + * @returns {void} */ - onADBConnect() { - this.WDebug.log(`Device connected !`); - } - - async onWaiting() { - this.WDebug.log(`.`); - } + onStepFailed() {} + /** + * Updates the download progress in the UI. + * + * @param {string} name Name of the file being downloaded. + * @param {number} loaded Amount already downloaded. + * @param {number} total Expected total size. + * @returns {void} + */ onDownloading(name, loaded, total) { this.showProgressAreaIfHidden(); const v = Math.round((loaded / total) * 100); @@ -171,9 +204,20 @@ export default class ViewManager { $progress.innerText = `Downloading ${name}: ${v}/${100}`; } this.lastProgressPhase = "download"; - this.WDebug.log(`Downloading ${name}: ${v}/${100}`, `downloading-${name}`); + this.DebugManager.log( + `Downloading ${name}: ${v}/${100}`, + `downloading-${name}`, + ); } + /** + * Updates the verification progress in the UI. + * + * @param {string} name Name of the file being verified. + * @param {number} loaded Amount already verified. + * @param {number} total Total size to verify. + * @returns {void} + */ onVerify(name, loaded, total) { this.showProgressAreaIfHidden(); const v = Math.round((loaded / total) * 100); @@ -188,9 +232,20 @@ export default class ViewManager { $progress.innerText = `Verifying ${name}: ${v}/${100}`; } this.lastProgressPhase = "verify"; - this.WDebug.log(`Verifying ${name}: ${v}/${100}`, `verifying-${name}`); + this.DebugManager.log( + `Verifying ${name}: ${v}/${100}`, + `verifying-${name}`, + ); } + /** + * Updates the extraction progress in the UI. + * + * @param {string} name Name of the archive being extracted. + * @param {number} loaded Amount already extracted. + * @param {number} total Total size to extract. + * @returns {void} + */ onUnzip(name, loaded, total) { this.showProgressAreaIfHidden(); const v = Math.round((loaded / total) * 100); @@ -205,8 +260,17 @@ export default class ViewManager { $progress.innerText = `Extracting ${name}: ${v}/${100}`; } this.lastProgressPhase = "unzip"; - this.WDebug.log(`Unzipping ${name}: ${v}/${100}`, `Unzipping-${name}`); + this.DebugManager.log( + `Unzipping ${name}: ${v}/${100}`, + `Unzipping-${name}`, + ); } + + /** + * Finalizes the download phase display and shows the ready state. + * + * @returns {void} + */ onDownloadingEnd() { this.showProgressAreaIfHidden(); let $progressBar = document.querySelector( @@ -237,6 +301,14 @@ export default class ViewManager { } } + /** + * Updates the installation progress in the UI. + * + * @param {string} name Name of the item being installed. + * @param {number} loaded Amount already installed. + * @param {number} total Total size to install. + * @returns {Promise} + */ async onInstalling(name, loaded, total) { this.showInstallProgressAreaIfHidden(); const v = Math.round((loaded / total) * 100); @@ -250,15 +322,23 @@ export default class ViewManager { if ($progress) { $progress.innerText = `Installing ${name}: ${v}/${100}`; } - this.WDebug.log( + this.DebugManager.log( `Installing ${name}: ${Math.round(v * 100)}/${100}`, `installing-${name}`, ); } + + /** + * Refreshes elements subscribed to translated data. + * + * @param {string} key Data key to propagate. + * @param {*} value Value associated with the key. + * @returns {void} + */ updateData(key, value) { let $subscribers = document.querySelectorAll(`[data-subscribe="${key}"]`); - this.WDebug.log($subscribers); - this.WDebug.log({ + this.DebugManager.log($subscribers); + this.DebugManager.log({ [key]: value, }); for (let i = 0; i < $subscribers.length; i++) { @@ -269,6 +349,13 @@ export default class ViewManager { } // /CONTROLLER EVENTS + /** + * Binds direct download and local file selection actions. + * + * @param {HTMLElement} $copyStep Cloned DOM element for the active step. + * @param {{ id?: string, name: string }} step Step associated with the actions. + * @returns {void} + */ bindDownloadChoice($copyStep, step) { const downloadBtn = $copyStep.querySelector(".download-build-button"); const localBtn = $copyStep.querySelector(".use-local-zip-button"); @@ -343,6 +430,12 @@ export default class ViewManager { } } + /** + * Shows the download progress area if it is hidden. + * + * @param {HTMLElement | null | undefined} progressAreaOverride Explicit area to show. + * @returns {void} + */ showProgressAreaIfHidden(progressAreaOverride) { const area = progressAreaOverride || @@ -352,6 +445,12 @@ export default class ViewManager { } } + /** + * Shows the installation progress area if it is hidden. + * + * @param {HTMLElement | null | undefined} progressAreaOverride Explicit area to show. + * @returns {void} + */ showInstallProgressAreaIfHidden(progressAreaOverride) { const area = progressAreaOverride || @@ -361,31 +460,3 @@ export default class ViewManager { } } } - -document.addEventListener("DOMContentLoaded", async () => { - var VIEW = new ViewManager(); - await VIEW.init(); - - let elts = document.querySelectorAll(".card button"); - for (let elt of elts) { - const card = elt.parentElement.parentElement; - if (!card || card.className.includes("inactive")) { - continue; - } - elt.addEventListener("click", async () => { - VIEW.executeStep(elt, card.id); - }); - } -}); - -window.onload = function () { - if (!("usb" in navigator)) { - document.getElementById("overlay-background").classList.remove("inactive"); - document - .getElementById("navigator-not-supported") - .classList.remove("inactive"); - document - .getElementById("let-s-get-started-button") - .classList.add("inactive"); - } -};