From f054f5852e154650cbea333c984a239eb9e3a03f Mon Sep 17 00:00:00 2001 From: Paula Date: Mon, 30 Mar 2026 18:12:11 +0200 Subject: [PATCH 1/6] refractoring filenames & and splitting downloader.manager into subfiles --- app/index.html | 2 +- .../{ => controller}/controller.manager.js | 393 +++++++++------- .../{debug.js => controller/debug.manager.js} | 2 +- app/src/controller/device.manager.js | 30 +- app/src/controller/device/adb.class.js | 16 +- app/src/controller/device/bootloader.class.js | 54 ++- app/src/controller/device/command.class.js | 38 +- app/src/controller/device/recovery.class.js | 9 +- app/src/controller/downloader.manager.js | 434 +++++------------- app/src/controller/enums/command.enum.js | 12 + app/src/controller/enums/mode.enum.js | 5 + app/src/controller/utils/blob-store.class.js | 59 +++ .../controller/utils/http-fetcher.class.js | 79 ++++ app/src/controller/utils/zip.class.js | 30 ++ .../{errorManager.js => vue/error.manager.js} | 0 app/src/vue/translation.manager.js | 4 +- .../{viewManager.js => vue/view.manager.js} | 37 +- 17 files changed, 604 insertions(+), 600 deletions(-) rename app/src/{ => controller}/controller.manager.js (57%) rename app/src/{debug.js => controller/debug.manager.js} (84%) create mode 100644 app/src/controller/enums/command.enum.js create mode 100644 app/src/controller/enums/mode.enum.js create mode 100644 app/src/controller/utils/blob-store.class.js create mode 100644 app/src/controller/utils/http-fetcher.class.js create mode 100644 app/src/controller/utils/zip.class.js rename app/src/{errorManager.js => vue/error.manager.js} (100%) rename app/src/{viewManager.js => vue/view.manager.js} (93%) diff --git a/app/index.html b/app/index.html index a8b9110..8bc9299 100644 --- a/app/index.html +++ b/app/index.html @@ -10,7 +10,7 @@ /e/OS Installer - + diff --git a/app/src/controller.manager.js b/app/src/controller/controller.manager.js similarity index 57% rename from app/src/controller.manager.js rename to app/src/controller/controller.manager.js index 484460a..6549119 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller/controller.manager.js @@ -1,12 +1,12 @@ -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"; +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 Controller { +export class ControllerManager { constructor() { this.steps = [ new Step("let-s-get-started", undefined, true), @@ -35,21 +35,21 @@ export class Controller { let current = this.steps[this.currentIndex]; let next = this.steps[this.currentIndex + 1]; - WDebug.log("Controller Manager Next", next); + DebugManager.log("Controller Manager Next", next); if (next) { if (next.mode) { const alreadyInMode = this.inInMode(next.mode); - WDebug.log( + 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 - WDebug.log(`next() rebooting to ${next.mode}...`); + DebugManager.log(`next() rebooting to ${next.mode}...`); await this.deviceManager.reboot(next.mode); - WDebug.log(`next() reboot to ${next.mode} completed`); + DebugManager.log(`next() reboot to ${next.mode} completed`); } if (next.needUserGesture) { // Wait for the device to appear on the USB bus before showing the @@ -57,20 +57,22 @@ export class Controller { // devices after a mode switch. The actual connect happens via // executeStep when the user clicks (WebUSB requestDevice() requires // a user gesture). - WDebug.log( + DebugManager.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`); + DebugManager.log(`next() device wait complete, showing step to user`); } else { - WDebug.log(`next() connecting to ${next.mode} automatically...`); + DebugManager.log( + `next() connecting to ${next.mode} automatically...`, + ); await this.deviceManager.connect(next.mode); - WDebug.log(`next() auto-connect to ${next.mode} completed`); + DebugManager.log(`next() auto-connect to ${next.mode} completed`); } } this.currentIndex++; current = this.steps[this.currentIndex]; - WDebug.log( + DebugManager.log( `next() advancing to step="${current.name}", needUserGesture=${current.needUserGesture}`, ); this.view.onStepStarted(this.currentIndex, current); @@ -83,7 +85,7 @@ export class Controller { async executeStep(stepName, loader) { const current = this.steps[this.currentIndex]; let this_command; - WDebug.log("ControllerManager Execute step", current); + DebugManager.log("ControllerManager Execute step", current); document.getElementById("error-message-state").style.display = "none"; if (current.name === stepName) { let res = true; @@ -92,7 +94,7 @@ export class Controller { 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); + DebugManager.log("run command > ", this_command, "returns ", res); } const next = this.steps[this.currentIndex + 1]; let previous = this.steps[this.currentIndex - 1]; @@ -139,177 +141,209 @@ export class Controller { error should contain a proposal to solve the issue. */ async runCommand(cmd, loader) { - WDebug.log("ControllerManager run command:", cmd); + DebugManager.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.download: + return this.runDownloadCommand(); + case COMMAND.reboot: + return this.runRebootCommand(cmd); + case COMMAND.connect: { + return this.runConnectCommand(cmd, loader); } - case Command.CMD_TYPE.erase: + case COMMAND.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.flash: { + return this.runFlashCommand(cmd); } - 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; - } - } + 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); + } + } + + 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}`, + ); + } + } + + 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}`); + } + } + + 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}`, + ); + } + } + + 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; + } + + 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 { - 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--; - } + throw e; } - 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; + } 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--; } - 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}`); + } + return true; + } + + 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}`); } - case Command.CMD_TYPE.delay: - await new Promise((resolve) => setTimeout(resolve, cmd.partition)); - return true; + } + } + return true; + } - default: - WDebug.log(`try unknown command ${cmd.command}`); - await this.deviceManager.runCommand(cmd.command); - return true; + 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}`); } } + async runFormatCommand(cmd) { + try { + return this.deviceManager.format(cmd.partition); + } catch (e) { + throw new Error(`Format ${cmd.partition} failed: ${e.message || e}`); + } + } + + async runDelayCommand(cmd) { + await new Promise((resolve) => setTimeout(resolve, cmd.partition)); + return true; + } + + async runUnknownCommand(cmd) { + DebugManager.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); + DebugManager.log("ControllerManager Model:", this.model); try { const resources = await this.getResources(); @@ -327,7 +361,7 @@ export class Controller { } async checkAndroidVersion(versionRequired) { const android = await this.deviceManager.getAndroidVersion(); - WDebug.log("current android version:", android); + DebugManager.log("current android version:", android); if (android) { this.view.updateData("android-version", android); if (android < versionRequired) { @@ -343,14 +377,17 @@ export class Controller { const security_patch = await this.deviceManager.adb.getProp( "ro.build.version.security_patch", ); - //WDebug.log('security_patch', security_patch) + //DebugManager.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); + DebugManager.log( + "current_security_path_level", + current_security_path_level, + ); } catch { - WDebug.log("Security patch Error"); + DebugManager.log("Security patch Error"); current_security_path_level = null; } let this_model = this.deviceManager.adb.banner.device; @@ -359,7 +396,7 @@ export class Controller { if (model.includes("Teracube") && model.includes("2e")) { try { const serial = await this.deviceManager.adb.getSerialNumber(); - WDebug.log("serial numer:", serial); + DebugManager.log("serial numer:", serial); if (serial.startsWith("2021")) { this_model = "emerald"; } else if (serial.startsWith("2020")) { @@ -421,14 +458,16 @@ export class Controller { current_security_path_level != null && typeof resources.security_patch_level != "undefined" ) { - WDebug.log(`EOS Rom has security patch ${current_security_path_level}`); + DebugManager.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}`); + DebugManager.log(`New security patch ${new_security_path_level}`); if (current_security_path_level > new_security_path_level) { - WDebug.log( + DebugManager.log( "Bypass lock procedure", `resources/${this_model}-safe.json`, ); @@ -439,7 +478,7 @@ export class Controller { } } catch (e) { resources = null; - WDebug.log("getResources Error: " + e); + DebugManager.log("getResources Error: " + e); throw Error("device-model-not-supported"); } diff --git a/app/src/debug.js b/app/src/controller/debug.manager.js similarity index 84% rename from app/src/debug.js rename to app/src/controller/debug.manager.js index 96d0e9d..35c6813 100644 --- a/app/src/debug.js +++ b/app/src/controller/debug.manager.js @@ -1,4 +1,4 @@ -export class WDebug { +export class DebugManager { constructor() {} static log(...args) { diff --git a/app/src/controller/device.manager.js b/app/src/controller/device.manager.js index 009e14e..0870f57 100644 --- a/app/src/controller/device.manager.js +++ b/app/src/controller/device.manager.js @@ -1,14 +1,10 @@ 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 @@ -24,7 +20,7 @@ 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; } @@ -121,11 +117,11 @@ export class DeviceManager { */ 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; @@ -214,26 +210,26 @@ export class DeviceManager { 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,7 +238,7 @@ export class DeviceManager { const onConnect = (event) => { const elapsed = Date.now() - startTime; const d = event.device; - WDebug.log( + DebugManager.log( `waitForDeviceOnBus: USB connect event after ${elapsed}ms - ` + `vendorId=${d.vendorId} productId=${d.productId} ` + `productName="${d.productName}" serialNumber="${d.serialNumber}"`, @@ -250,7 +246,7 @@ export class DeviceManager { 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); diff --git a/app/src/controller/device/adb.class.js b/app/src/controller/device/adb.class.js index bb6875c..335dc75 100644 --- a/app/src/controller/device/adb.class.js +++ b/app/src/controller/device/adb.class.js @@ -1,5 +1,5 @@ import { Device } from "./device.class.js"; -import { WDebug } from "../../debug.js"; +import { DebugManager } from "../debug.manager.js"; import { AdbDevice } from "../../lib/index.ts"; export class ADB extends Device { @@ -23,12 +23,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; @@ -57,7 +57,7 @@ export class ADB extends Device { } async runCommand(cmd) { - WDebug.log("ADB Run command>", cmd); + DebugManager.log("ADB Run command>", cmd); return await this._adbDevice.shell(cmd); } diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index 55a90ac..7d9ba97 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -5,7 +5,7 @@ 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 @@ -38,7 +38,7 @@ export class Bootloader extends Device { const CONNECT_RETRY_DELAY = 2000; // 2 seconds const connectStart = Date.now(); - WDebug.log( + DebugManager.log( `Bootloader.connect() starting, maxAttempts=${MAX_CONNECT_ATTEMPTS}, ` + `retryDelay=${CONNECT_RETRY_DELAY}ms`, ); @@ -47,7 +47,7 @@ export class Bootloader extends Device { try { // Log paired devices before each attempt for debugging const pairedDevices = await navigator.usb.getDevices(); - WDebug.log( + DebugManager.log( `Bootloader.connect() attempt ${attempt}/${MAX_CONNECT_ATTEMPTS}: ` + `${pairedDevices.length} paired USB device(s)`, pairedDevices.map( @@ -64,12 +64,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,14 +89,14 @@ 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}`, ); @@ -112,19 +112,21 @@ export class Bootloader extends Device { // 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 @@ -149,18 +151,20 @@ export class Bootloader extends Device { */ 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; } } @@ -176,7 +180,7 @@ export class Bootloader extends Device { } try { - WDebug.log( + DebugManager.log( `flashBlob: ${partition} (${(blob.size / 1024 / 1024).toFixed(1)} MB), ` + `attempt ${attempt}/${MAX_ATTEMPTS}`, ); @@ -185,36 +189,38 @@ export class Bootloader extends Device { }); 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( + 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}`, ); } diff --git a/app/src/controller/device/command.class.js b/app/src/controller/device/command.class.js index b572d2d..ae983e6 100644 --- a/app/src/controller/device/command.class.js +++ b/app/src/controller/device/command.class.js @@ -1,19 +1,7 @@ -import { WDebug } from "../../debug.js"; +import { DebugManager } from "../debug.manager.js"; +import { COMMAND } from "../enums/command.enum.js"; 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", - }; - constructor(cmd) { this.command = cmd; this.type = undefined; @@ -28,30 +16,30 @@ export class Command { .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 +47,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/recovery.class.js b/app/src/controller/device/recovery.class.js index fae5d33..6bd8c8b 100644 --- a/app/src/controller/device/recovery.class.js +++ b/app/src/controller/device/recovery.class.js @@ -1,5 +1,5 @@ import { Device } from "./device.class.js"; -import { WDebug } from "../../debug.js"; +import { DebugManager } from "../debug.manager.js"; import { AdbDevice } from "../../lib/index.ts"; export class Recovery extends Device { @@ -17,7 +17,10 @@ export class Recovery extends Device { 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}`); @@ -28,7 +31,7 @@ export class Recovery extends Device { 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) { diff --git a/app/src/controller/downloader.manager.js b/app/src/controller/downloader.manager.js index 446146b..9745376 100644 --- a/app/src/controller/downloader.manager.js +++ b/app/src/controller/downloader.manager.js @@ -1,29 +1,26 @@ -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 { constructor() { - this.db = null; + this.store = new BlobStore(); + this.fetcher = new HttpFetcher(); this.stored = {}; this.localZipFile = null; this.blobs = {}; } async init() { - if (this.db) return; // Already initialized - - this.db = await this.openDBStore(); - await this.clearDBStore(); + await this.store.init(); } setLocalZip(file) { @@ -38,8 +35,6 @@ export class Downloader { return !!this.localZipFile; } - /* - * */ async downloadAndUnzipFolder( filesRequired, folder, @@ -47,96 +42,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) { @@ -153,7 +82,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 +98,105 @@ 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. */ 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); - } - - /* - * 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}`); - } + return this.blobs[name] ?? (await this.store.get(name)); } - 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); + 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); + 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(); + } + + 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 0000000..3460c59 --- /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 0000000..a11e85e --- /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 0000000..e2c7fb0 --- /dev/null +++ b/app/src/controller/utils/blob-store.class.js @@ -0,0 +1,59 @@ +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; + } + + async init() { + if (this.db) return; + this.db = await this.open(); + await this.clear(); + } + + 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); + }); + } + + async clear() { + const store = this.db + .transaction(DB_NAME, "readwrite") + .objectStore(DB_NAME); + store.clear(); + } + + async set(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); + transaction.oncomplete = () => resolve(); + transaction.onerror = (event) => reject(event.target.error); + transaction.onabort = () => + reject(new Error("IndexedDB transaction aborted (storage quota?)")); + }); + } + + async get(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 = (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 0000000..eeec896 --- /dev/null +++ b/app/src/controller/utils/http-fetcher.class.js @@ -0,0 +1,79 @@ +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. + */ + 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. + */ + 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. + */ + 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"); + } + + async getContentLength(url) { + const response = await ky.head(url); + return parseInt(response.headers.get("content-length"), 10); + } +} diff --git a/app/src/controller/utils/zip.class.js b/app/src/controller/utils/zip.class.js new file mode 100644 index 0000000..55a1bd2 --- /dev/null +++ b/app/src/controller/utils/zip.class.js @@ -0,0 +1,30 @@ +import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"; + +/** + * Helpers for reading zip archives and mapping entry filenames. + */ +export class Zip { + 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 }; + } + + static async openZip(blob) { + const reader = new ZipReader(new BlobReader(blob)); + const entries = await reader.getEntries(); + return { entries, reader }; + } + + 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/errorManager.js b/app/src/vue/error.manager.js similarity index 100% rename from app/src/errorManager.js rename to app/src/vue/error.manager.js diff --git a/app/src/vue/translation.manager.js b/app/src/vue/translation.manager.js index 46afc00..f1f373d 100644 --- a/app/src/vue/translation.manager.js +++ b/app/src/vue/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' @@ -119,7 +119,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 { diff --git a/app/src/viewManager.js b/app/src/vue/view.manager.js similarity index 93% rename from app/src/viewManager.js rename to app/src/vue/view.manager.js index 55bebfe..5cb0091 100644 --- a/app/src/viewManager.js +++ b/app/src/vue/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 @@ -13,9 +13,9 @@ export default class ViewManager { constructor() {} 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); @@ -150,11 +150,11 @@ export default class ViewManager { * STEP 1 : Connect */ onADBConnect() { - this.WDebug.log(`Device connected !`); + this.DebugManager.log(`Device connected !`); } async onWaiting() { - this.WDebug.log(`.`); + this.DebugManager.log(`.`); } onDownloading(name, loaded, total) { @@ -171,7 +171,10 @@ 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}`, + ); } onVerify(name, loaded, total) { @@ -188,7 +191,10 @@ 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}`, + ); } onUnzip(name, loaded, total) { @@ -205,7 +211,10 @@ 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}`, + ); } onDownloadingEnd() { this.showProgressAreaIfHidden(); @@ -250,15 +259,15 @@ 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}`, ); } 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++) { -- GitLab From 7c016a1dec1b4cdace58cc089d1b8d89ccda66f8 Mon Sep 17 00:00:00 2001 From: Paula Date: Tue, 31 Mar 2026 15:42:25 +0200 Subject: [PATCH 2/6] add comments on functions, remove unused one, set DOM listener in a separate file --- app/index.html | 3 +- app/src/before-leave-app.js | 3 - app/src/controller/controller.manager.js | 169 ++++++++++++++---- app/src/controller/debug.manager.js | 14 ++ app/src/controller/device.manager.js | 154 +++++++++++++++- app/src/controller/device/adb.class.js | 54 ++++++ app/src/controller/device/bootloader.class.js | 88 ++++++++- app/src/controller/device/command.class.js | 12 ++ app/src/controller/device/device.class.js | 77 ++++++++ app/src/controller/device/recovery.class.js | 38 ++++ app/src/controller/downloader.manager.js | 72 ++++++++ app/src/controller/utils/blob-store.class.js | 58 ++++-- .../controller/utils/http-fetcher.class.js | 17 ++ app/src/controller/utils/step.class.js | 9 + app/src/controller/utils/zip.class.js | 20 +++ app/src/vue/listener.manager.js | 33 ++++ app/src/vue/translation.manager.js | 9 +- app/src/vue/view.manager.js | 165 +++++++++++------ 18 files changed, 877 insertions(+), 118 deletions(-) delete mode 100644 app/src/before-leave-app.js create mode 100644 app/src/vue/listener.manager.js diff --git a/app/index.html b/app/index.html index 8bc9299..01c3b08 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 04b72ff..0000000 --- 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/controller.manager.js b/app/src/controller/controller.manager.js index 6549119..2ba0f30 100644 --- a/app/src/controller/controller.manager.js +++ b/app/src/controller/controller.manager.js @@ -2,6 +2,7 @@ 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 @@ -21,16 +22,33 @@ export class ControllerManager { 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]; @@ -82,6 +100,13 @@ export class ControllerManager { } } + /** + * 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; @@ -126,20 +151,22 @@ export class ControllerManager { } /** + * Checks whether the connected device already matches a mode. * - * @param mode - * @returns {boolean} - * Check if device is connected to 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); } - /* - * run a command - this throw new error if something went wwrong. - error should contain a proposal to solve the issue. - */ + /** + * 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) { @@ -173,6 +200,11 @@ export class ControllerManager { } } + /** + * 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( @@ -196,6 +228,12 @@ export class ControllerManager { } } + /** + * 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); @@ -205,6 +243,13 @@ export class ControllerManager { } } + /** + * 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."; @@ -222,6 +267,12 @@ export class ControllerManager { } } + /** + * 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( @@ -238,6 +289,12 @@ export class ControllerManager { 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 = ""; @@ -287,6 +344,12 @@ export class ControllerManager { 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) { @@ -308,6 +371,12 @@ export class ControllerManager { 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"); @@ -318,6 +387,12 @@ export class ControllerManager { } } + /** + * 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); @@ -326,17 +401,34 @@ export class ControllerManager { } } + /** + * 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()) { @@ -359,6 +451,13 @@ export class ControllerManager { } } } + + /** + * 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); @@ -369,6 +468,12 @@ export class ControllerManager { } } } + + /** + * 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 { @@ -417,40 +522,13 @@ export class ControllerManager { 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; + 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); - } + this_model = "tetris"; } resources = await (await fetch(`resources/${this_model}.json`)).json(); @@ -485,6 +563,12 @@ export class ControllerManager { 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) { @@ -507,10 +591,21 @@ export class ControllerManager { }); } + /** + * 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 index 35c6813..4a4cf3e 100644 --- a/app/src/controller/debug.manager.js +++ b/app/src/controller/debug.manager.js @@ -1,9 +1,23 @@ +/** + * 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 0870f57..bfd5673 100644 --- a/app/src/controller/device.manager.js +++ b/app/src/controller/device.manager.js @@ -7,9 +7,12 @@ 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,18 +27,39 @@ export class DeviceManager { 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(); @@ -43,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 @@ -65,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) { @@ -81,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) { @@ -101,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 { @@ -111,9 +176,10 @@ 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) { @@ -127,17 +193,34 @@ export class DeviceManager { 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 @@ -146,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) { @@ -166,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) { @@ -182,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) { @@ -191,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); @@ -204,6 +329,9 @@ 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(); @@ -257,6 +385,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 335dc75..aa8eff3 100644 --- a/app/src/controller/device/adb.class.js +++ b/app/src/controller/device/adb.class.js @@ -2,16 +2,32 @@ import { Device } from "./device.class.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"); @@ -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) { 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 7d9ba97..2d74680 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -8,31 +8,62 @@ import { Device } from "./device.class.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 @@ -136,18 +167,30 @@ 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) { @@ -169,6 +212,15 @@ export class Bootloader extends Device { } } + /** + * 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 @@ -245,10 +297,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 { @@ -262,6 +326,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 { @@ -275,6 +345,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); @@ -283,6 +359,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 ae983e6..0345c77 100644 --- a/app/src/controller/device/command.class.js +++ b/app/src/controller/device/command.class.js @@ -1,7 +1,13 @@ import { DebugManager } from "../debug.manager.js"; import { COMMAND } from "../enums/command.enum.js"; +/** + * Parses textual step commands into structured command metadata. + */ export class Command { + /** + * @param {string} cmd Raw command string from resource step. + */ constructor(cmd) { this.command = cmd; this.type = undefined; @@ -11,6 +17,12 @@ 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(" ") diff --git a/app/src/controller/device/device.class.js b/app/src/controller/device/device.class.js index 61a50db..6d045c7 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 6bd8c8b..b6f8c44 100644 --- a/app/src/controller/device/recovery.class.js +++ b/app/src/controller/device/recovery.class.js @@ -2,16 +2,32 @@ import { Device } from "./device.class.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(); @@ -27,6 +43,12 @@ export class Recovery extends Device { } } + /** + * 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) => { @@ -39,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 9745376..70c482b 100644 --- a/app/src/controller/downloader.manager.js +++ b/app/src/controller/downloader.manager.js @@ -11,6 +11,9 @@ const MAX_RETRIES = 3; * Low-level concerns are delegated to BlobStore, HttpFetcher and Zip. */ export class DownloaderManager { + /** + * Creates a new DownloaderManager with an empty in-memory blob store. + */ constructor() { this.store = new BlobStore(); this.fetcher = new HttpFetcher(); @@ -19,22 +22,53 @@ export class DownloaderManager { this.blobs = {}; } + /** + * Initializes the internal blob store. + * + * @returns {Promise} Resolves when the store is ready. + */ async init() { 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, @@ -75,6 +109,16 @@ export class DownloaderManager { } } + /** + * 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, @@ -117,6 +161,9 @@ export class DownloaderManager { /** * 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]) { @@ -125,6 +172,14 @@ export class DownloaderManager { return this.blobs[name] ?? (await this.store.get(name)); } + /** + * 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++) { @@ -156,6 +211,16 @@ export class DownloaderManager { ); } + /** + * 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, @@ -190,6 +255,13 @@ export class DownloaderManager { 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; diff --git a/app/src/controller/utils/blob-store.class.js b/app/src/controller/utils/blob-store.class.js index e2c7fb0..bd00f70 100644 --- a/app/src/controller/utils/blob-store.class.js +++ b/app/src/controller/utils/blob-store.class.js @@ -9,12 +9,22 @@ export class BlobStore { 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); @@ -28,29 +38,53 @@ export class BlobStore { }); } + /** + * Removes all entries from the object store. + * + * @returns {Promise} Resolves when the store is cleared. + */ async clear() { - const store = this.db - .transaction(DB_NAME, "readwrite") - .objectStore(DB_NAME); - store.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 transaction = this.db.transaction(DB_NAME, "readwrite"); - const store = transaction.objectStore(DB_NAME); - store.put(blob, key); - transaction.oncomplete = () => resolve(); - transaction.onerror = (event) => reject(event.target.error); - transaction.onabort = () => + 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 transaction = this.db.transaction(DB_NAME, "readonly"); - const store = transaction.objectStore(DB_NAME); + 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 index eeec896..777ac90 100644 --- a/app/src/controller/utils/http-fetcher.class.js +++ b/app/src/controller/utils/http-fetcher.class.js @@ -10,6 +10,10 @@ const DOWNLOAD_CHUNK_SIZE = 16 * 1024 * 1024; // 16 MB 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); @@ -42,6 +46,9 @@ export class HttpFetcher { /** * 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); @@ -58,6 +65,10 @@ export class HttpFetcher { /** * 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(); @@ -72,6 +83,12 @@ export class HttpFetcher { 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 f5dfdb3..6db11c1 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 index 55a1bd2..0b38b31 100644 --- a/app/src/controller/utils/zip.class.js +++ b/app/src/controller/utils/zip.class.js @@ -4,6 +4,13 @@ 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), @@ -12,12 +19,25 @@ export class Zip { 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)) { diff --git a/app/src/vue/listener.manager.js b/app/src/vue/listener.manager.js new file mode 100644 index 0000000..393996f --- /dev/null +++ b/app/src/vue/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/vue/translation.manager.js index f1f373d..3981736 100644 --- a/app/src/vue/translation.manager.js +++ b/app/src/vue/translation.manager.js @@ -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]; @@ -127,6 +131,7 @@ export class TranslationManager { throw Error(`${language} not found`); } } + /** * @returns string[] * get user's preferred languages diff --git a/app/src/vue/view.manager.js b/app/src/vue/view.manager.js index 5cb0091..c16456e 100644 --- a/app/src/vue/view.manager.js +++ b/app/src/vue/view.manager.js @@ -10,8 +10,18 @@ import { TranslationManager } from "./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.DebugManager = DebugManager; this.ErrorManager = ErrorManager; @@ -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,25 @@ 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 +135,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 +175,24 @@ export default class ViewManager { $next.disabled = !nextStep.needUser;*/ } } + + /** + * Extension point called when a step fails. + * + * @returns {void} + */ onStepFailed() {} // /BUTTON EVENTS - /* - * STEP 1 : Connect + /** + * 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} */ - onADBConnect() { - this.DebugManager.log(`Device connected !`); - } - - async onWaiting() { - this.DebugManager.log(`.`); - } - onDownloading(name, loaded, total) { this.showProgressAreaIfHidden(); const v = Math.round((loaded / total) * 100); @@ -177,6 +213,14 @@ export default class ViewManager { ); } + /** + * 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); @@ -197,6 +241,14 @@ export default class ViewManager { ); } + /** + * 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); @@ -216,6 +268,12 @@ export default class ViewManager { `Unzipping-${name}`, ); } + + /** + * Finalizes the download phase display and shows the ready state. + * + * @returns {void} + */ onDownloadingEnd() { this.showProgressAreaIfHidden(); let $progressBar = document.querySelector( @@ -246,6 +304,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); @@ -264,6 +330,14 @@ export default class ViewManager { `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.DebugManager.log($subscribers); @@ -278,6 +352,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"); @@ -352,6 +433,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 || @@ -361,6 +448,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 || @@ -370,31 +463,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"); - } -}; -- GitLab From 90851099694cad5cf19ce28924f8ac835e379725 Mon Sep 17 00:00:00 2001 From: Paula Date: Tue, 31 Mar 2026 15:47:57 +0200 Subject: [PATCH 3/6] remove License text from erro.manager.js, it's already described in LICENSE file --- app/src/vue/error.manager.js | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/app/src/vue/error.manager.js b/app/src/vue/error.manager.js index d55b7ba..58cda78 100644 --- a/app/src/vue/error.manager.js +++ b/app/src/vue/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 -- GitLab From 35cb7c725aae3ed71926a397420f889d6fc74343 Mon Sep 17 00:00:00 2001 From: Paula Date: Tue, 31 Mar 2026 16:13:38 +0200 Subject: [PATCH 4/6] add agents instruction & remove old comment --- AGENTS.md | 60 +++++++++++++++++++++++++++++++++++++ app/src/vue/view.manager.js | 3 -- 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b05075b --- /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/src/vue/view.manager.js b/app/src/vue/view.manager.js index c16456e..5650592 100644 --- a/app/src/vue/view.manager.js +++ b/app/src/vue/view.manager.js @@ -105,7 +105,6 @@ export default class ViewManager { const $total = document.getElementById("total-step"); $total.innerText = total; } - // BUTTON EVENTS /** * Executes a user step and manages the visual state of its button. @@ -183,8 +182,6 @@ export default class ViewManager { */ onStepFailed() {} - // /BUTTON EVENTS - /** * Updates the download progress in the UI. * -- GitLab From f83d166d80461be70d397602eaf0643355530b6c Mon Sep 17 00:00:00 2001 From: Paula Date: Tue, 31 Mar 2026 17:01:59 +0200 Subject: [PATCH 5/6] renaming french folder name vue to view. French bad >:( --- app/index.html | 2 +- app/src/{vue => view}/error.manager.js | 0 app/src/{vue => view}/listener.manager.js | 0 app/src/{vue => view}/translation.manager.js | 0 app/src/{vue => view}/view.manager.js | 0 5 files changed, 1 insertion(+), 1 deletion(-) rename app/src/{vue => view}/error.manager.js (100%) rename app/src/{vue => view}/listener.manager.js (100%) rename app/src/{vue => view}/translation.manager.js (100%) rename app/src/{vue => view}/view.manager.js (100%) diff --git a/app/index.html b/app/index.html index 01c3b08..07dcf2d 100644 --- a/app/index.html +++ b/app/index.html @@ -10,7 +10,7 @@ /e/OS Installer - +
diff --git a/app/src/vue/error.manager.js b/app/src/view/error.manager.js similarity index 100% rename from app/src/vue/error.manager.js rename to app/src/view/error.manager.js diff --git a/app/src/vue/listener.manager.js b/app/src/view/listener.manager.js similarity index 100% rename from app/src/vue/listener.manager.js rename to app/src/view/listener.manager.js diff --git a/app/src/vue/translation.manager.js b/app/src/view/translation.manager.js similarity index 100% rename from app/src/vue/translation.manager.js rename to app/src/view/translation.manager.js diff --git a/app/src/vue/view.manager.js b/app/src/view/view.manager.js similarity index 100% rename from app/src/vue/view.manager.js rename to app/src/view/view.manager.js -- GitLab From 3213f0e55b3968c03f4c23eb3c51364046f85ddb Mon Sep 17 00:00:00 2001 From: Paula Date: Thu, 2 Apr 2026 09:53:02 +0200 Subject: [PATCH 6/6] use same string concatenations for constancy --- app/src/controller/controller.manager.js | 29 ++++--------------- app/src/controller/device.manager.js | 4 +-- app/src/controller/device/bootloader.class.js | 20 ++++--------- 3 files changed, 13 insertions(+), 40 deletions(-) diff --git a/app/src/controller/controller.manager.js b/app/src/controller/controller.manager.js index 2ba0f30..2d0a0a2 100644 --- a/app/src/controller/controller.manager.js +++ b/app/src/controller/controller.manager.js @@ -59,8 +59,7 @@ export class ControllerManager { if (next.mode) { const alreadyInMode = this.inInMode(next.mode); DebugManager.log( - `next() step="${next.name}" requires mode="${next.mode}", ` + - `alreadyInMode=${alreadyInMode}, needUserGesture=${next.needUserGesture}`, + `next() step="${next.name}" requires mode="${next.mode}", alreadyInMode=${alreadyInMode}, needUserGesture=${next.needUserGesture}`, ); //if next step require another mode [adb|fastboot|bootloader] if (!alreadyInMode) { @@ -142,10 +141,7 @@ export class ControllerManager { } } else { throw new Error( - "this is not the current step " + - current.name + - " is not equals to " + - stepName, + `this is not the current step ${current.name} is not equals to ${stepName}`, ); } } @@ -309,7 +305,7 @@ export class ControllerManager { } DebugManager.log( "ControllerManager unlock: ", - this.deviceManager.adb.getProductName() + " isUnlocked = " + isUnlocked, + `${this.deviceManager.adb.getProductName()} isUnlocked = ${isUnlocked}`, ); if (!isUnlocked) { try { @@ -333,9 +329,7 @@ export class ControllerManager { this.currentIndex++; DebugManager.log( "Bypass step", - this.steps[this.currentIndex].name + - " " + - (this.steps[this.currentIndex].name == gotoStep), + `${this.steps[this.currentIndex].name} ${this.steps[this.currentIndex].name == gotoStep}`, ); } while (!(this.steps[this.currentIndex].name == gotoStep)); this.currentIndex--; @@ -507,18 +501,7 @@ export class ControllerManager { } 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; + 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 { @@ -556,7 +539,7 @@ export class ControllerManager { } } catch (e) { resources = null; - DebugManager.log("getResources Error: " + e); + DebugManager.log(`getResources Error: ${e}`); throw Error("device-model-not-supported"); } diff --git a/app/src/controller/device.manager.js b/app/src/controller/device.manager.js index bfd5673..c84d417 100644 --- a/app/src/controller/device.manager.js +++ b/app/src/controller/device.manager.js @@ -367,9 +367,7 @@ export class DeviceManager { const elapsed = Date.now() - startTime; const d = event.device; DebugManager.log( - `waitForDeviceOnBus: USB connect event after ${elapsed}ms - ` + - `vendorId=${d.vendorId} productId=${d.productId} ` + - `productName="${d.productName}" serialNumber="${d.serialNumber}"`, + `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); diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index 2d74680..8c281d9 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -70,8 +70,7 @@ export class Bootloader extends Device { const connectStart = Date.now(); DebugManager.log( - `Bootloader.connect() starting, maxAttempts=${MAX_CONNECT_ATTEMPTS}, ` + - `retryDelay=${CONNECT_RETRY_DELAY}ms`, + `Bootloader.connect() starting, maxAttempts=${MAX_CONNECT_ATTEMPTS}, retryDelay=${CONNECT_RETRY_DELAY}ms`, ); for (let attempt = 1; attempt <= MAX_CONNECT_ATTEMPTS; attempt++) { @@ -79,8 +78,7 @@ export class Bootloader extends Device { // Log paired devices before each attempt for debugging const pairedDevices = await navigator.usb.getDevices(); DebugManager.log( - `Bootloader.connect() attempt ${attempt}/${MAX_CONNECT_ATTEMPTS}: ` + - `${pairedDevices.length} paired USB device(s)`, + `Bootloader.connect() attempt ${attempt}/${MAX_CONNECT_ATTEMPTS}: ${pairedDevices.length} paired USB device(s)`, pairedDevices.map( (d) => `${d.vendorId}:${d.productId} "${d.productName}"`, ), @@ -134,10 +132,7 @@ export class Bootloader extends Device { // 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}`, ); } @@ -233,8 +228,7 @@ export class Bootloader extends Device { try { DebugManager.log( - `flashBlob: ${partition} (${(blob.size / 1024 / 1024).toFixed(1)} MB), ` + - `attempt ${attempt}/${MAX_ATTEMPTS}`, + `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); @@ -247,8 +241,7 @@ export class Bootloader extends Device { if (e instanceof TimeoutError) { const elapsed = Date.now() - flashStart; DebugManager.log( - `flashBlob: timeout on ${partition} after ${elapsed}ms ` + - `(attempt ${attempt}/${MAX_ATTEMPTS})`, + `flashBlob: timeout on ${partition} after ${elapsed}ms (attempt ${attempt}/${MAX_ATTEMPTS})`, ); if (attempt < MAX_ATTEMPTS) { DebugManager.log( @@ -287,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); -- GitLab