diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000000000000000000000000000000000000..b05075b98d89751159b522c5181ab4f9fabc7f70
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,60 @@
+# Agent Instructions
+
+## Language
+
+- Write all code and comments in English
+
+## Naming conventions
+
+- `my-class` — CSS classes use kebab-case
+- `camelCase` — variables
+- `$variableName` — DOM Node variables are prefixed with `$`
+- `MAJUSCULE` — global constants use UPPERCASE
+
+## File conventions
+
+- `object.manager.js` — directive class coordinating sub-classes or views
+- `object.class.js` — single-responsibility class used by a manager
+
+## Function & JSDoc conventions
+
+- Add a blank line between each function
+- Any function created by an agent must include a JSDoc block with:
+ - A function description
+ - Parameters with their types
+ - Return value with its type
+- Use JSDoc for all functions with this format:
+ - `/** Description. */`
+ - `@param {Type} paramName Description.`
+ - `@returns {Type} Description.`
+- For functions returning nothing, use `@returns {void}`
+- For optional parameters, use `@param {Type} [paramName] Description.`
+
+## Async conventions
+
+- Do not leave floating promises
+- Prefer `async/await` over chained `.then()` calls
+
+## Import conventions
+
+- Group imports in this order:
+ - External dependencies
+ - Internal modules
+ - Styles and assets
+- Keep imports sorted within each group
+
+## UI text conventions
+
+- Do not hardcode user-facing strings in views
+- Use translation keys and language files in `app/public/assets/languages/*.json`
+
+## Validation conventions
+
+Run the formatter from the `app/` directory before finishing:
+
+- After code changes, run:
+ - `cd app && npm run format`
+ - `cd app && npm run check`
+
+## Canary test
+- If the user writes "canary", answer exactly: "AGENTS_OK"
\ No newline at end of file
diff --git a/app/index.html b/app/index.html
index a8b9110fc9dde64a0f238acd8ef3ffb1a0bf33ad..07dcf2d06b9a2eea2cf788946846f6d876b93a50 100644
--- a/app/index.html
+++ b/app/index.html
@@ -10,8 +10,7 @@
/e/OS Installer
-
-
+
diff --git a/app/src/before-leave-app.js b/app/src/before-leave-app.js
deleted file mode 100644
index 04b72ff150ed86e40da007f740c4998db6d258e2..0000000000000000000000000000000000000000
--- a/app/src/before-leave-app.js
+++ /dev/null
@@ -1,3 +0,0 @@
-window.addEventListener("beforeunload", function (event) {
- event.preventDefault();
-});
diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js
deleted file mode 100644
index 484460a7d882508390826c42cccd4f0d44eee25f..0000000000000000000000000000000000000000
--- a/app/src/controller.manager.js
+++ /dev/null
@@ -1,478 +0,0 @@
-import { DeviceManager } from "./controller/device.manager.js";
-import { Command } from "./controller/device/command.class.js";
-import { Step } from "./controller/utils/step.class.js";
-import { WDebug } from "./debug.js";
-/*
- * Class to manage process
- * Check and display the steps, interact with deviceManager
- */
-export class Controller {
- constructor() {
- this.steps = [
- new Step("let-s-get-started", undefined, true),
- new Step("connect-your-phone", undefined, true),
- new Step("activate-developer-options", undefined, true),
- new Step("activate-oem-unlock", undefined, true),
- new Step("activate-usb-debugging", undefined, true),
- new Step("enable-usb-file-transfer", undefined, true),
- new Step("device-detection", "connect adb", true),
- ];
- this.currentIndex = 0;
- this.downloadChoiceEnabled = false;
- }
-
- async init(view) {
- this.deviceManager = new DeviceManager();
- await this.deviceManager.init();
- this.view = view;
- }
-
- setDownloadChoiceEnabled(enabled) {
- this.downloadChoiceEnabled = enabled;
- }
-
- async next() {
- let current = this.steps[this.currentIndex];
- let next = this.steps[this.currentIndex + 1];
-
- WDebug.log("Controller Manager Next", next);
-
- if (next) {
- if (next.mode) {
- const alreadyInMode = this.inInMode(next.mode);
- WDebug.log(
- `next() step="${next.name}" requires mode="${next.mode}", ` +
- `alreadyInMode=${alreadyInMode}, needUserGesture=${next.needUserGesture}`,
- );
- //if next step require another mode [adb|fastboot|bootloader]
- if (!alreadyInMode) {
- //we need reboot
- WDebug.log(`next() rebooting to ${next.mode}...`);
- await this.deviceManager.reboot(next.mode);
- WDebug.log(`next() reboot to ${next.mode} completed`);
- }
- if (next.needUserGesture) {
- // Wait for the device to appear on the USB bus before showing the
- // step. Some host controllers (AMD Ryzen) are slow to re-enumerate
- // devices after a mode switch. The actual connect happens via
- // executeStep when the user clicks (WebUSB requestDevice() requires
- // a user gesture).
- WDebug.log(
- `next() waiting for device on USB bus (needUserGesture=true, deferring connect)...`,
- );
- await this.deviceManager.waitForDeviceOnBus();
- WDebug.log(`next() device wait complete, showing step to user`);
- } else {
- WDebug.log(`next() connecting to ${next.mode} automatically...`);
- await this.deviceManager.connect(next.mode);
- WDebug.log(`next() auto-connect to ${next.mode} completed`);
- }
- }
- this.currentIndex++;
- current = this.steps[this.currentIndex];
- WDebug.log(
- `next() advancing to step="${current.name}", needUserGesture=${current.needUserGesture}`,
- );
- this.view.onStepStarted(this.currentIndex, current);
- if (!current.needUserGesture) {
- await this.executeStep(current.name);
- }
- }
- }
-
- async executeStep(stepName, loader) {
- const current = this.steps[this.currentIndex];
- let this_command;
- WDebug.log("ControllerManager Execute step", current);
- document.getElementById("error-message-state").style.display = "none";
- if (current.name === stepName) {
- let res = true;
- let i;
- try {
- for (i = 0; i < current.commands.length && res; i++) {
- this_command = current.commands[i];
- res = await this.runCommand(this_command, loader);
- WDebug.log("run command > ", this_command, "returns ", res);
- }
- const next = this.steps[this.currentIndex + 1];
- let previous = this.steps[this.currentIndex - 1];
- if (res) {
- if (next) {
- this.view.onStepFinished(current, next);
- await this.next();
- }
- } else {
- this.view.onStepFailed(current, previous);
- if (!current.needUserGesture) {
- this.currentIndex--;
- }
- throw new Error("command failed");
- }
- } catch (e) {
- throw new Error(
- `Cannot execute command ${this_command.command} ${e.message || e}`,
- );
- }
- } else {
- throw new Error(
- "this is not the current step " +
- current.name +
- " is not equals to " +
- stepName,
- );
- }
- }
-
- /**
- *
- * @param mode
- * @returns {boolean}
- * Check if device is connected to a mode
- */
- inInMode(mode) {
- return this.deviceManager.isInMode(mode);
- }
-
- /*
- * run a command
- this throw new error if something went wwrong.
- error should contain a proposal to solve the issue.
- */
- async runCommand(cmd, loader) {
- WDebug.log("ControllerManager run command:", cmd);
- switch (cmd.type) {
- case Command.CMD_TYPE.download:
- try {
- await this.deviceManager.downloadAll(
- (loaded, total, name) => {
- this.view.onDownloading(name, loaded, total);
- },
- (loaded, total, name) => {
- this.view.onUnzip(name, loaded, total);
- },
- (loaded, total, name) => {
- this.view.onVerify(name, loaded, total);
- },
- );
- this.view.onDownloadingEnd();
- return true;
- } catch (e) {
- const proposal = "Proposal: Retry by refreshing this page.";
- throw new Error(
- `Cannot download ${e.message || e} ${proposal}`,
- );
- }
- case Command.CMD_TYPE.reboot:
- try {
- await this.deviceManager.reboot(cmd.mode);
- return true;
- } catch (e) {
- throw new Error(`Reboot to ${cmd.mode} failed: ${e.message || e}`);
- }
- case Command.CMD_TYPE.connect: {
- const proposal =
- "Proposal: Check connection and that no other program is using the phone and retry.";
- try {
- await this.deviceManager.connect(cmd.mode);
- await this.onDeviceConnected();
- if (loader) {
- loader.style.display = "none";
- }
- return true;
- } catch (e) {
- throw new Error(
- `The device is not connected ${e.message || e} ${proposal}`,
- );
- }
- }
- case Command.CMD_TYPE.erase:
- return this.deviceManager.erase(cmd.partition);
- case Command.CMD_TYPE.flash: {
- const FLASH_COOLDOWN_MS = this.resources?.flash_cooldown_ms ?? 2500;
- const result = await this.deviceManager.flash(
- cmd.file,
- cmd.partition,
- (done, total) => {
- this.view.onInstalling(cmd.file, done, total);
- },
- );
- // Small delay between flash operations to prevent overwhelming the device
- WDebug.log(
- `Flash cooldown: waiting ${FLASH_COOLDOWN_MS}ms before next operation`,
- );
- await new Promise((resolve) => setTimeout(resolve, FLASH_COOLDOWN_MS));
- return result;
- }
- case Command.CMD_TYPE.unlock: {
- //check if unlocked to avoid unnecessary command
- let isUnlocked = false;
- let gotoStep = "";
- if (cmd.partition) {
- if (cmd.partition.startsWith("goto_")) {
- gotoStep = cmd.partition.substring(5);
- WDebug.log("goto step", gotoStep);
- isUnlocked = await this.deviceManager.getUnlocked("unlocked");
- } else {
- isUnlocked = await this.deviceManager.getUnlocked(cmd.partition);
- }
- }
- WDebug.log(
- "ControllerManager unlock: ",
- this.deviceManager.adb.getProductName() +
- " isUnlocked = " +
- isUnlocked,
- );
- if (!isUnlocked) {
- try {
- await this.deviceManager.unlock(cmd.command);
- } catch (e) {
- //on some device, check unlocked does not work but when we try the command, it throws an error with "already unlocked"
- if (e.bootloaderMessage?.includes("already")) {
- WDebug.log("device already unlocked");
- } else if (e.bootloaderMessage?.includes("not allowed")) {
- WDebug.log("device unlock is not allowed");
- throw new Error(`Unlock not allowed: ${e.message || e}`);
- } else {
- throw e;
- }
- }
- } else {
- WDebug.log("The phone is not locked - bypass lock process");
- if (gotoStep == "") {
- // Goto the next step.
- this.currentIndex++;
- } else {
- // Goto the maned step.
- do {
- this.currentIndex++;
- WDebug.log(
- "Bypass step",
- this.steps[this.currentIndex].name +
- " " +
- (this.steps[this.currentIndex].name == gotoStep),
- );
- } while (!(this.steps[this.currentIndex].name == gotoStep));
- this.currentIndex--;
- }
- }
- return true;
- }
- case Command.CMD_TYPE.lock: {
- let isLocked = false;
- if (cmd.partition) {
- isLocked = !(await this.deviceManager.getUnlocked(cmd.partition));
- }
- if (!isLocked) {
- try {
- await this.deviceManager.lock(cmd.command);
- isLocked = true;
- } catch (e) {
- //on some device, check unlocked does not work but when we try the command, it throws an error with "already locked"
- if (e.bootloaderMessage?.includes("already")) {
- WDebug.log("device already locked");
- isLocked = true;
- } else {
- throw new Error(`Lock failed: ${e.message || e}`);
- }
- }
- }
- return true;
- }
- case Command.CMD_TYPE.sideload:
- try {
- await this.deviceManager.connect("recovery");
- await this.deviceManager.sideload(cmd.file);
- return true;
- } catch (e) {
- throw new Error(`Sideload ${cmd.file} failed: ${e.message || e}`);
- }
- case Command.CMD_TYPE.format:
- try {
- return this.deviceManager.format(cmd.partition);
- } catch (e) {
- throw new Error(`Format ${cmd.partition} failed: ${e.message || e}`);
- }
- case Command.CMD_TYPE.delay:
- await new Promise((resolve) => setTimeout(resolve, cmd.partition));
- return true;
-
- default:
- WDebug.log(`try unknown command ${cmd.command}`);
- await this.deviceManager.runCommand(cmd.command);
- return true;
- }
- }
-
- async onDeviceConnected() {
- const productName = this.deviceManager.getProductName();
- if (this.deviceManager.isFirstConnection()) {
- this.deviceManager.markAsConnected();
- this.view.updateData("product-name", productName);
- this.model = productName;
- WDebug.log("ControllerManager Model:", this.model);
- try {
- const resources = await this.getResources();
-
- if (resources.android) {
- this.view.updateData("android-version-required", resources.android);
- await this.checkAndroidVersion(resources.android);
- }
- this.setResources(resources);
- } catch (e) {
- this.steps.push(new Step(e.message));
- this.view.updateTotalStep(this.steps.length);
- // Don not throw this error, as it is handled by the UI directly.
- }
- }
- }
- async checkAndroidVersion(versionRequired) {
- const android = await this.deviceManager.getAndroidVersion();
- WDebug.log("current android version:", android);
- if (android) {
- this.view.updateData("android-version", android);
- if (android < versionRequired) {
- throw Error("android-version-not-supported");
- }
- }
- }
- async getResources() {
- let resources = null;
- try {
- let current_security_path_level = null;
- try {
- const security_patch = await this.deviceManager.adb.getProp(
- "ro.build.version.security_patch",
- );
- //WDebug.log('security_patch', security_patch)
- current_security_path_level = parseInt(
- security_patch.replace(/-/g, ""),
- 10,
- );
- WDebug.log("current_security_path_level", current_security_path_level);
- } catch {
- WDebug.log("Security patch Error");
- current_security_path_level = null;
- }
- let this_model = this.deviceManager.adb.banner.device;
- // https://gitlab.e.foundation/e/os/backlog/-/issues/2604#note_609234
- const model = this.deviceManager.adb.banner.model;
- if (model.includes("Teracube") && model.includes("2e")) {
- try {
- const serial = await this.deviceManager.adb.getSerialNumber();
- WDebug.log("serial numer:", serial);
- if (serial.startsWith("2021")) {
- this_model = "emerald";
- } else if (serial.startsWith("2020")) {
- this_model = "Teracube_2e";
- } else {
- const id =
- "model " +
- this.deviceManager.adb.banner.model +
- " " +
- "product " +
- this.deviceManager.adb.banner.product +
- " " +
- "name " +
- this.deviceManager.adb.getProductName() +
- " " +
- "device " +
- this.deviceManager.adb.banner.device;
- throw new Error("Cannot find device resource", id);
- }
- } catch {
- const id =
- "model " +
- this.deviceManager.adb.banner.model +
- " " +
- "product " +
- this.deviceManager.adb.banner.product +
- " " +
- "name " +
- this.deviceManager.adb.getProductName() +
- " " +
- "device " +
- this.deviceManager.adb.banner.device;
- throw new Error("Error on getting device resource", id);
- }
- }
-
- if (model.includes("A015")) {
- try {
- this_model = "tetris";
- } catch {
- const id =
- "model " +
- this.deviceManager.adb.banner.model +
- " " +
- "product " +
- this.deviceManager.adb.banner.product +
- " " +
- "name " +
- this.deviceManager.adb.getProductName() +
- " " +
- "device " +
- this.deviceManager.adb.banner.device;
- throw new Error("Error on getting devcice resource", id);
- }
- }
-
- resources = await (await fetch(`resources/${this_model}.json`)).json();
- if (
- current_security_path_level != null &&
- typeof resources.security_patch_level != "undefined"
- ) {
- WDebug.log(`EOS Rom has security patch ${current_security_path_level}`);
- const new_security_path_level = parseInt(
- resources.security_patch_level.replace(/-/g, ""),
- 10,
- );
- WDebug.log(`New security patch ${new_security_path_level}`);
- if (current_security_path_level > new_security_path_level) {
- WDebug.log(
- "Bypass lock procedure",
- `resources/${this_model}-safe.json`,
- );
- resources = await (
- await fetch(`resources/${this_model}-safe.json`)
- ).json();
- }
- }
- } catch (e) {
- resources = null;
- WDebug.log("getResources Error: " + e);
- throw Error("device-model-not-supported");
- }
-
- return resources;
- }
-
- setResources(resources) {
- this.resources = resources;
- if (this.resources.steps) {
- const needsUserGesture = this.downloadChoiceEnabled;
- this.steps.push(new Step("downloading", "download", needsUserGesture));
- this.steps.push(
- ...this.resources.steps.map((step) => {
- return new Step(
- step.id,
- step.command,
- step.needUserGesture ?? false,
- step.mode,
- );
- }),
- );
- this.view.updateTotalStep(this.steps.length);
- }
- this.deviceManager.setResources(this.resources.folder, this.steps, {
- skipClearHalt: this.resources.skip_clear_halt,
- });
- }
-
- setLocalZip(file) {
- this.deviceManager.setLocalZipFile(file);
- }
-
- clearLocalZip() {
- this.deviceManager.clearLocalZipFile();
- }
-}
diff --git a/app/src/controller/controller.manager.js b/app/src/controller/controller.manager.js
new file mode 100644
index 0000000000000000000000000000000000000000..2d0a0a2bd3131c6da5d04145e82f78dab1c89947
--- /dev/null
+++ b/app/src/controller/controller.manager.js
@@ -0,0 +1,595 @@
+import { DeviceManager } from "./device.manager.js";
+import { COMMAND } from "./enums/command.enum.js";
+import { Step } from "./utils/step.class.js";
+import { DebugManager } from "./debug.manager.js";
+
+/*
+ * Class to manage process
+ * Check and display the steps, interact with deviceManager
+ */
+export class ControllerManager {
+ constructor() {
+ this.steps = [
+ new Step("let-s-get-started", undefined, true),
+ new Step("connect-your-phone", undefined, true),
+ new Step("activate-developer-options", undefined, true),
+ new Step("activate-oem-unlock", undefined, true),
+ new Step("activate-usb-debugging", undefined, true),
+ new Step("enable-usb-file-transfer", undefined, true),
+ new Step("device-detection", "connect adb", true),
+ ];
+ this.currentIndex = 0;
+ this.downloadChoiceEnabled = false;
+ }
+
+ /**
+ * Initializes controller dependencies and binds the view.
+ *
+ * @param {object} view UI view manager that receives state updates.
+ * @returns {Promise} Resolves when dependencies are initialized.
+ */
+ async init(view) {
+ this.deviceManager = new DeviceManager();
+ await this.deviceManager.init();
+ this.view = view;
+ }
+
+ /**
+ * Enables or disables manual download choice.
+ *
+ * @param {boolean} enabled Whether user-driven download selection is enabled.
+ * @returns {void}
+ */
+ setDownloadChoiceEnabled(enabled) {
+ this.downloadChoiceEnabled = enabled;
+ }
+
+ /**
+ * Advances to the next installer step, handling mode changes and connections.
+ *
+ * @returns {Promise} Resolves when the next step is started.
+ */
+ async next() {
+ let current = this.steps[this.currentIndex];
+ let next = this.steps[this.currentIndex + 1];
+
+ DebugManager.log("Controller Manager Next", next);
+
+ if (next) {
+ if (next.mode) {
+ const alreadyInMode = this.inInMode(next.mode);
+ DebugManager.log(
+ `next() step="${next.name}" requires mode="${next.mode}", alreadyInMode=${alreadyInMode}, needUserGesture=${next.needUserGesture}`,
+ );
+ //if next step require another mode [adb|fastboot|bootloader]
+ if (!alreadyInMode) {
+ //we need reboot
+ DebugManager.log(`next() rebooting to ${next.mode}...`);
+ await this.deviceManager.reboot(next.mode);
+ DebugManager.log(`next() reboot to ${next.mode} completed`);
+ }
+ if (next.needUserGesture) {
+ // Wait for the device to appear on the USB bus before showing the
+ // step. Some host controllers (AMD Ryzen) are slow to re-enumerate
+ // devices after a mode switch. The actual connect happens via
+ // executeStep when the user clicks (WebUSB requestDevice() requires
+ // a user gesture).
+ DebugManager.log(
+ `next() waiting for device on USB bus (needUserGesture=true, deferring connect)...`,
+ );
+ await this.deviceManager.waitForDeviceOnBus();
+ DebugManager.log(`next() device wait complete, showing step to user`);
+ } else {
+ DebugManager.log(
+ `next() connecting to ${next.mode} automatically...`,
+ );
+ await this.deviceManager.connect(next.mode);
+ DebugManager.log(`next() auto-connect to ${next.mode} completed`);
+ }
+ }
+ this.currentIndex++;
+ current = this.steps[this.currentIndex];
+ DebugManager.log(
+ `next() advancing to step="${current.name}", needUserGesture=${current.needUserGesture}`,
+ );
+ this.view.onStepStarted(this.currentIndex, current);
+ if (!current.needUserGesture) {
+ await this.executeStep(current.name);
+ }
+ }
+ }
+
+ /**
+ * Executes commands for the current step.
+ *
+ * @param {string} stepName Step identifier expected to be active.
+ * @param {HTMLElement} [loader] Loader element hidden after successful connect.
+ * @returns {Promise} Resolves when the step has been processed.
+ */
+ async executeStep(stepName, loader) {
+ const current = this.steps[this.currentIndex];
+ let this_command;
+ DebugManager.log("ControllerManager Execute step", current);
+ document.getElementById("error-message-state").style.display = "none";
+ if (current.name === stepName) {
+ let res = true;
+ let i;
+ try {
+ for (i = 0; i < current.commands.length && res; i++) {
+ this_command = current.commands[i];
+ res = await this.runCommand(this_command, loader);
+ DebugManager.log("run command > ", this_command, "returns ", res);
+ }
+ const next = this.steps[this.currentIndex + 1];
+ let previous = this.steps[this.currentIndex - 1];
+ if (res) {
+ if (next) {
+ this.view.onStepFinished(current, next);
+ await this.next();
+ }
+ } else {
+ this.view.onStepFailed(current, previous);
+ if (!current.needUserGesture) {
+ this.currentIndex--;
+ }
+ throw new Error("command failed");
+ }
+ } catch (e) {
+ throw new Error(
+ `Cannot execute command ${this_command.command} ${e.message || e}`,
+ );
+ }
+ } else {
+ throw new Error(
+ `this is not the current step ${current.name} is not equals to ${stepName}`,
+ );
+ }
+ }
+
+ /**
+ * Checks whether the connected device already matches a mode.
+ *
+ * @param {string} mode Target device mode.
+ * @returns {boolean} True when device is already in the requested mode.
+ */
+ inInMode(mode) {
+ return this.deviceManager.isInMode(mode);
+ }
+
+ /**
+ * Runs a parsed command by dispatching to the appropriate handler.
+ *
+ * @param {import("./device/command.class.js").Command} cmd Command metadata.
+ * @param {HTMLElement} [loader] Optional loading element used by connect step.
+ * @returns {Promise} True when the command succeeds.
+ */
+ async runCommand(cmd, loader) {
+ DebugManager.log("ControllerManager run command:", cmd);
+ switch (cmd.type) {
+ case COMMAND.download:
+ return this.runDownloadCommand();
+ case COMMAND.reboot:
+ return this.runRebootCommand(cmd);
+ case COMMAND.connect: {
+ return this.runConnectCommand(cmd, loader);
+ }
+ case COMMAND.erase:
+ return this.deviceManager.erase(cmd.partition);
+ case COMMAND.flash: {
+ return this.runFlashCommand(cmd);
+ }
+ case COMMAND.unlock: {
+ return this.runUnlockCommand(cmd);
+ }
+ case COMMAND.lock: {
+ return this.runLockCommand(cmd);
+ }
+ case COMMAND.sideload:
+ return this.runSideloadCommand(cmd);
+ case COMMAND.format:
+ return this.runFormatCommand(cmd);
+ case COMMAND.delay:
+ return this.runDelayCommand(cmd);
+
+ default:
+ return this.runUnknownCommand(cmd);
+ }
+ }
+
+ /**
+ * Runs the download step, triggering file download progress callbacks.
+ *
+ * @returns {Promise} True when all files are downloaded successfully.
+ */
+ async runDownloadCommand() {
+ try {
+ await this.deviceManager.downloadAll(
+ (loaded, total, name) => {
+ this.view.onDownloading(name, loaded, total);
+ },
+ (loaded, total, name) => {
+ this.view.onUnzip(name, loaded, total);
+ },
+ (loaded, total, name) => {
+ this.view.onVerify(name, loaded, total);
+ },
+ );
+ this.view.onDownloadingEnd();
+ return true;
+ } catch (e) {
+ const proposal = "Proposal: Retry by refreshing this page.";
+ throw new Error(
+ `Cannot download ${e.message || e} ${proposal}`,
+ );
+ }
+ }
+
+ /**
+ * Reboots the device to the mode specified in the command.
+ *
+ * @param {import("./device/command.class.js").Command} cmd Reboot command.
+ * @returns {Promise} True when reboot command succeeds.
+ */
+ async runRebootCommand(cmd) {
+ try {
+ await this.deviceManager.reboot(cmd.mode);
+ return true;
+ } catch (e) {
+ throw new Error(`Reboot to ${cmd.mode} failed: ${e.message || e}`);
+ }
+ }
+
+ /**
+ * Connects to the device mode requested by the command.
+ *
+ * @param {import("./device/command.class.js").Command} cmd Connect command.
+ * @param {HTMLElement} [loader] Optional loading element hidden after connect.
+ * @returns {Promise} True when connection succeeds.
+ */
+ async runConnectCommand(cmd, loader) {
+ const proposal =
+ "Proposal: Check connection and that no other program is using the phone and retry.";
+ try {
+ await this.deviceManager.connect(cmd.mode);
+ await this.onDeviceConnected();
+ if (loader) {
+ loader.style.display = "none";
+ }
+ return true;
+ } catch (e) {
+ throw new Error(
+ `The device is not connected ${e.message || e} ${proposal}`,
+ );
+ }
+ }
+
+ /**
+ * Flashes one file to one partition and applies a post-flash cooldown.
+ *
+ * @param {import("./device/command.class.js").Command} cmd Flash command.
+ * @returns {Promise} True when flashing succeeds.
+ */
+ async runFlashCommand(cmd) {
+ const FLASH_COOLDOWN_MS = this.resources?.flash_cooldown_ms ?? 2500;
+ const result = await this.deviceManager.flash(
+ cmd.file,
+ cmd.partition,
+ (done, total) => {
+ this.view.onInstalling(cmd.file, done, total);
+ },
+ );
+ DebugManager.log(
+ `Flash cooldown: waiting ${FLASH_COOLDOWN_MS}ms before next operation`,
+ );
+ await new Promise((resolve) => setTimeout(resolve, FLASH_COOLDOWN_MS));
+ return result;
+ }
+
+ /**
+ * Unlocks the bootloader if needed and can skip ahead for goto steps.
+ *
+ * @param {import("./device/command.class.js").Command} cmd Unlock command.
+ * @returns {Promise} True when unlock flow completes.
+ */
+ async runUnlockCommand(cmd) {
+ let isUnlocked = false;
+ let gotoStep = "";
+ if (cmd.partition) {
+ if (cmd.partition.startsWith("goto_")) {
+ gotoStep = cmd.partition.substring(5);
+ DebugManager.log("goto step", gotoStep);
+ isUnlocked = await this.deviceManager.getUnlocked("unlocked");
+ } else {
+ isUnlocked = await this.deviceManager.getUnlocked(cmd.partition);
+ }
+ }
+ DebugManager.log(
+ "ControllerManager unlock: ",
+ `${this.deviceManager.adb.getProductName()} isUnlocked = ${isUnlocked}`,
+ );
+ if (!isUnlocked) {
+ try {
+ await this.deviceManager.unlock(cmd.command);
+ } catch (e) {
+ if (e.bootloaderMessage?.includes("already")) {
+ DebugManager.log("device already unlocked");
+ } else if (e.bootloaderMessage?.includes("not allowed")) {
+ DebugManager.log("device unlock is not allowed");
+ throw new Error(`Unlock not allowed: ${e.message || e}`);
+ } else {
+ throw e;
+ }
+ }
+ } else {
+ DebugManager.log("The phone is not locked - bypass lock process");
+ if (gotoStep == "") {
+ this.currentIndex++;
+ } else {
+ do {
+ this.currentIndex++;
+ DebugManager.log(
+ "Bypass step",
+ `${this.steps[this.currentIndex].name} ${this.steps[this.currentIndex].name == gotoStep}`,
+ );
+ } while (!(this.steps[this.currentIndex].name == gotoStep));
+ this.currentIndex--;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Locks the bootloader if currently unlocked.
+ *
+ * @param {import("./device/command.class.js").Command} cmd Lock command.
+ * @returns {Promise} True when lock flow completes.
+ */
+ async runLockCommand(cmd) {
+ let isLocked = false;
+ if (cmd.partition) {
+ isLocked = !(await this.deviceManager.getUnlocked(cmd.partition));
+ }
+ if (!isLocked) {
+ try {
+ await this.deviceManager.lock(cmd.command);
+ isLocked = true;
+ } catch (e) {
+ if (e.bootloaderMessage?.includes("already")) {
+ DebugManager.log("device already locked");
+ isLocked = true;
+ } else {
+ throw new Error(`Lock failed: ${e.message || e}`);
+ }
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Connects to recovery and performs sideload.
+ *
+ * @param {import("./device/command.class.js").Command} cmd Sideload command.
+ * @returns {Promise} True when sideload succeeds.
+ */
+ async runSideloadCommand(cmd) {
+ try {
+ await this.deviceManager.connect("recovery");
+ await this.deviceManager.sideload(cmd.file);
+ return true;
+ } catch (e) {
+ throw new Error(`Sideload ${cmd.file} failed: ${e.message || e}`);
+ }
+ }
+
+ /**
+ * Formats a partition requested by the command.
+ *
+ * @param {import("./device/command.class.js").Command} cmd Format command.
+ * @returns {Promise} True when format command succeeds.
+ */
+ async runFormatCommand(cmd) {
+ try {
+ return this.deviceManager.format(cmd.partition);
+ } catch (e) {
+ throw new Error(`Format ${cmd.partition} failed: ${e.message || e}`);
+ }
+ }
+
+ /**
+ * Waits for a command-defined delay.
+ *
+ * @param {import("./device/command.class.js").Command} cmd Delay command.
+ * @returns {Promise} True after delay completes.
+ */
+ async runDelayCommand(cmd) {
+ await new Promise((resolve) => setTimeout(resolve, cmd.partition));
+ return true;
+ }
+
+ /**
+ * Forwards unknown commands to the active device implementation.
+ *
+ * @param {import("./device/command.class.js").Command} cmd Unknown command.
+ * @returns {Promise} True when forwarded command finishes.
+ */
+ async runUnknownCommand(cmd) {
+ DebugManager.log(`try unknown command ${cmd.command}`);
+ await this.deviceManager.runCommand(cmd.command);
+ return true;
+ }
+
+ /**
+ * Handles device connection: updates UI, loads resources and validates version.
+ *
+ * @returns {Promise} Resolves when device data is loaded.
+ */
+ async onDeviceConnected() {
+ const productName = this.deviceManager.getProductName();
+ if (this.deviceManager.isFirstConnection()) {
+ this.deviceManager.markAsConnected();
+ this.view.updateData("product-name", productName);
+ this.model = productName;
+ DebugManager.log("ControllerManager Model:", this.model);
+ try {
+ const resources = await this.getResources();
+
+ if (resources.android) {
+ this.view.updateData("android-version-required", resources.android);
+ await this.checkAndroidVersion(resources.android);
+ }
+ this.setResources(resources);
+ } catch (e) {
+ this.steps.push(new Step(e.message));
+ this.view.updateTotalStep(this.steps.length);
+ // Don not throw this error, as it is handled by the UI directly.
+ }
+ }
+ }
+
+ /**
+ * Validates the connected Android version against required version.
+ *
+ * @param {string|number} versionRequired Minimum required Android version.
+ * @returns {Promise} Resolves when version is valid.
+ */
+ async checkAndroidVersion(versionRequired) {
+ const android = await this.deviceManager.getAndroidVersion();
+ DebugManager.log("current android version:", android);
+ if (android) {
+ this.view.updateData("android-version", android);
+ if (android < versionRequired) {
+ throw Error("android-version-not-supported");
+ }
+ }
+ }
+
+ /**
+ * Fetches the device resource JSON based on the connected device model.
+ *
+ * @returns {Promise