From 321262eb1b06296404f5a685c54d4cc24ff0eb6f Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 12:56:52 +0530 Subject: [PATCH 01/41] Fix typo in format command handler The format command handler calls this.deviceManager.for() which does not exist, causing TypeError at runtime. The method should be format(). --- app/src/controller.manager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index bfe32a7..5d408d3 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -251,7 +251,7 @@ export class Controller { } case Command.CMD_TYPE.format: try { - this.deviceManager.for(cmd.partition); + this.deviceManager.format(cmd.partition); } catch (e) { console.error(e); // K1ZFP TODO } -- GitLab From 776891af08c35451b445a1f543d16325a46fcbae Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 12:59:17 +0530 Subject: [PATCH 02/41] Fix infinite recursion in `flashBlob` on timeout flashBlob() recursively calls itself on TimeoutError without any retry limit. If the device consistently times out, this leads to stack overflow or indefinite hangs. Add a retry counter with a maximum of 3 attempts. After exhausting retries, throw an error with a descriptive message indicating the partition and retry count. --- app/src/controller/device/bootloader.class.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index 5c34fd3..cde9d48 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -72,7 +72,8 @@ export class Bootloader extends Device { ); } - async flashBlob(partition, blob, onProgress) { + async flashBlob(partition, blob, onProgress, retryCount = 0) { + const MAX_RETRIES = 3; try { await this.device.flashBlob(partition, blob, (progress) => { onProgress(progress * blob.size, blob.size, partition); @@ -81,8 +82,11 @@ export class Bootloader extends Device { return true; } catch (e) { if (e instanceof TimeoutError) { - WDebug.log("Timeout on flashblob >" + partition); - return await this.flashBlob(partition, blob, onProgress); + WDebug.log(`Timeout on flashblob > ${partition} (attempt ${retryCount + 1}/${MAX_RETRIES})`); + if (retryCount < MAX_RETRIES) { + return await this.flashBlob(partition, blob, onProgress, retryCount + 1); + } + throw new Error(`Bootloader timeout: flashing ${partition} failed after ${MAX_RETRIES} retries`); } else { console.log("flashBlob error", e); throw new Error(`Bootloader error: ${e.message || e}`); -- GitLab From 3cc1fa0b27c860e8777c23389e54ad9cb4a48615 Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 13:05:18 +0530 Subject: [PATCH 03/41] Propagate format command errors instead of swallowing The format command handler catches exceptions but always returns true, masking failures from the caller. This causes the installation process to continue even when formatting fails, potentially leading to corrupt or incomplete installs. Return the actual result from deviceManager.format() and throw a descriptive error on failure so the user is notified and the process halts appropriately. --- app/src/controller.manager.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index 5d408d3..d7f0b4f 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -251,11 +251,10 @@ export class Controller { } case Command.CMD_TYPE.format: try { - this.deviceManager.format(cmd.partition); + return this.deviceManager.format(cmd.partition); } catch (e) { - console.error(e); // K1ZFP TODO + throw new Error(`Format ${cmd.partition} failed: ${e.message || e}`); } - return true; case Command.CMD_TYPE.delay: await new Promise((resolve) => setTimeout(resolve, cmd.partition)); return true; -- GitLab From b1ff5d627586a4183fe9b1e92d189b834490d9ac Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 13:18:54 +0530 Subject: [PATCH 04/41] Await unlock/lock operations to fix broken error handling The unlock and lock handlers deliberately avoided await with the comment "Do not await thus display unlocking screen". However, this approach is flawed: a try-catch block cannot catch rejections from a non-awaited promise. The errors silently become unhandled promise rejections, masking failures during critical bootloader operations. The original intent appears to be non-blocking UI updates, but: 1. The subsequent step has needUserGesture=true, creating a natural pause for user confirmation anyway 2. Returning true immediately signals success before the operation completes, causing the process to proceed on a potentially still- locked bootloader 3. Unlock/lock failures during flashing are critical and should halt the process, not be silently ignored Add await to ensure errors are caught and propagated. Re-throw unexpected errors while preserving handling for "already unlocked/ locked" responses that some devices return. --- app/src/controller.manager.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index d7f0b4f..9394777 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -191,13 +191,16 @@ export class Controller { ); if (!isUnlocked) { try { - this.deviceManager.unlock(cmd.command); // Do not await thus display unlocking screen + 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"); + WDebug.log("device unlock is not allowed"); + throw new Error(`Unlock not allowed: ${e.message || e}`); + } else { + throw e; } } } else { @@ -228,13 +231,15 @@ export class Controller { } if (!isLocked) { try { - this.deviceManager.lock(cmd.command); // Do not await thus display unlocking screen + 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 { - console.error(e); //K1ZFP TODO + throw new Error(`Lock failed: ${e.message || e}`); } } } -- GitLab From b48586250f61522658fb26da764988ef006a1540 Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 13:27:06 +0530 Subject: [PATCH 05/41] Fix null reference in `getProductName` and `getSerialNumber` getProductName() and getSerialNumber() access this.webusb which is declared in the constructor but never assigned during connect(). This causes TypeError when these methods are called. The connect() method stores the USB device in this.adbDaemonWebUsbDevice, not this.webusb. Fix the getters to use the correct property and access the standard WebUSB device attributes (productName, serialNumber). Also remove unused webusb and adbWebBackend properties from constructor, and initialize adbDaemonWebUsbDevice to null for clarity. --- app/src/controller/device/recovery.class.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/controller/device/recovery.class.js b/app/src/controller/device/recovery.class.js index 6746c61..285371e 100644 --- a/app/src/controller/device/recovery.class.js +++ b/app/src/controller/device/recovery.class.js @@ -8,9 +8,8 @@ import { ADB } from "./adb.class.js"; export class Recovery extends Device { constructor(device) { super(device); - this.webusb = null; this.count = 0; - this.adbWebBackend = null; + this.adbDaemonWebUsbDevice = null; } async isConnected() { @@ -211,11 +210,11 @@ export class Recovery extends Device { } getProductName() { - return this.webusb.name; + return this.adbDaemonWebUsbDevice?.productName; } getSerialNumber() { - return this.webusb.product; + return this.adbDaemonWebUsbDevice?.serialNumber; } async adbOpen(blob) { -- GitLab From 7e6a96c7ebb42ca9eea5b419b4ab4ca28df222b4 Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 13:34:01 +0530 Subject: [PATCH 06/41] Split `wasAlreadyConnected` into query and command wasAlreadyConnected() both checks state and mutates it, violating command-query separation. The function name implies a pure query but has a hidden side effect of marking the device as connected. This pattern is error-prone and makes the code harder to reason about. Split into two explicit methods: - isFirstConnection(): pure query, returns true if not yet connected - markAsConnected(): explicit state mutation Update controller.manager.js to call both methods explicitly, making the control flow clear at the call site. --- app/src/controller.manager.js | 4 ++-- app/src/controller/device.manager.js | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index 9394777..aa4546a 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -273,8 +273,8 @@ export class Controller { async onDeviceConnected() { const productName = this.deviceManager.getProductName(); - const wasAlreadyConnected = this.deviceManager.wasAlreadyConnected(); - if (!wasAlreadyConnected) { + if (this.deviceManager.isFirstConnection()) { + this.deviceManager.markAsConnected(); this.view.updateData("product-name", productName); this.model = productName; WDebug.log("ControllerManager Model:", this.model); diff --git a/app/src/controller/device.manager.js b/app/src/controller/device.manager.js index 74aff73..cb0188f 100644 --- a/app/src/controller/device.manager.js +++ b/app/src/controller/device.manager.js @@ -34,12 +34,12 @@ export class DeviceManager { await this.downloader.init(); } - wasAlreadyConnected() { - if (this.wasConnected == false) { - this.wasConnected = true; - return false; - } - return true; + isFirstConnection() { + return !this.wasConnected; + } + + markAsConnected() { + this.wasConnected = true; } setResources(folder, steps) { -- GitLab From a20bc7550f72d832a209b4f62660ad1e3f0ec187 Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 15:15:49 +0530 Subject: [PATCH 07/41] Rename inner loop variable to avoid shadowing The nested loop in downloadAndUnzipFolder() reuses variable name 'i' for both the outer folder iteration and inner zip entries iteration. While JavaScript's block scoping with 'let' makes this technically correct, it's confusing during debugging and prone to copy-paste errors. Rename inner loop variable from 'i' to 'j' for clarity. --- app/src/controller/downloader.manager.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/controller/downloader.manager.js b/app/src/controller/downloader.manager.js index 4a2937c..5099193 100644 --- a/app/src/controller/downloader.manager.js +++ b/app/src/controller/downloader.manager.js @@ -80,15 +80,15 @@ export class Downloader { if (file.unzip) { const zipReader = new ZipReader(new BlobReader(blob)); const filesEntries = await zipReader.getEntries(); - for (let i = 0; i < filesEntries.length; i++) { + for (let j = 0; j < filesEntries.length; j++) { const unzippedEntry = await this.getFileFromZip( - filesEntries[i], + filesEntries[j], (value, total) => { - onUnzipProgress(value, total, filesEntries[i].filename); + onUnzipProgress(value, total, filesEntries[j].filename); }, ); let filename = this.getMappedName( - filesEntries[i].filename, + filesEntries[j].filename, file.mapping, ); if (filesRequired.includes(filename)) { -- GitLab From 5533692aa2c2e2d39d2457cd2918a23f8af0dc3a Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 15:16:47 +0530 Subject: [PATCH 08/41] Fix duplicate condition, add missing protocolCode check getInOutEndpoints() checks interfaceSubclass twice instead of checking all three USB interface attributes. The WebUsbDeviceFilter defines classCode, subclassCode, and protocolCode, but protocolCode was never validated. Replace the duplicate interfaceSubclass check with interfaceProtocol to properly filter USB interfaces matching the ADB protocol (class=0xff, subclass=0x42, protocol=1). --- app/src/controller/device/recovery.class.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/controller/device/recovery.class.js b/app/src/controller/device/recovery.class.js index 285371e..d84916b 100644 --- a/app/src/controller/device/recovery.class.js +++ b/app/src/controller/device/recovery.class.js @@ -100,9 +100,9 @@ export class Recovery extends Device { for (const interface_ of configuration.interfaces) { for (const alternate of interface_.alternates) { if ( - alternate.interfaceSubclass === WebUsbDeviceFilter.subclassCode && alternate.interfaceClass === WebUsbDeviceFilter.classCode && - alternate.interfaceSubclass === WebUsbDeviceFilter.subclassCode + alternate.interfaceSubclass === WebUsbDeviceFilter.subclassCode && + alternate.interfaceProtocol === WebUsbDeviceFilter.protocolCode ) { if ( ((_a = this.adbDaemonWebUsbDevice.configuration) === null || -- GitLab From 30e11e7816be2409abd1db93f325c1ee376e4d0d Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 15:20:03 +0530 Subject: [PATCH 09/41] Resolve incomplete error handling (K1ZFP TODOs) Several error handlers logged errors but returned false or swallowed exceptions, masking failures from callers. This caused silent failures during critical operations like reboot and sideload. Changes: - reboot: throw descriptive error instead of returning false - sideload: throw descriptive error instead of returning false - bootloader isUnlocked/isLocked: improve error log messages - bootloader unlock/lock: improve error messages with context - Remove stale K1ZFP TODO comments throughout Errors now propagate to the UI layer where they can be displayed to users, allowing them to understand and potentially retry failed operations. --- app/src/controller.manager.js | 10 +++------- app/src/controller/device/bootloader.class.js | 8 ++++---- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index aa4546a..50a3bf1 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -33,7 +33,6 @@ export class Controller { WDebug.log("Controller Manager Next", next); if (next) { - //K1ZFP check this if (next.mode) { //if next step require another mode [adb|fastboot|bootloader] if (this.deviceManager.isConnected() && !this.inInMode(next.mode)) { @@ -138,12 +137,10 @@ export class Controller { case Command.CMD_TYPE.reboot: try { await this.deviceManager.reboot(cmd.mode); + return true; } catch (e) { - console.error(e); - //K1ZFP TODO - return false; + throw new Error(`Reboot to ${cmd.mode} failed: ${e.message || e}`); } - return true; case Command.CMD_TYPE.connect: { const proposal = "Proposal: Check connection and that no other program is using the phone and retry."; @@ -251,8 +248,7 @@ export class Controller { await this.deviceManager.sideload(cmd.file); return true; } catch (e) { - console.error(e); // K1ZFP TODO - return false; + throw new Error(`Sideload ${cmd.file} failed: ${e.message || e}`); } case Command.CMD_TYPE.format: try { diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index cde9d48..cb4919e 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -104,7 +104,7 @@ export class Bootloader extends Device { const unlocked = await this.device.getVariable(variable); return !(!unlocked || unlocked === "no"); } catch (e) { - console.error(e); // K1ZFP TODO + console.error("isUnlocked check failed:", e); throw e; } } @@ -117,7 +117,7 @@ export class Bootloader extends Device { const unlocked = await this.device.getVariable(variable); return !unlocked || unlocked === "no"; } catch (e) { - console.error(e); //K1ZFP TODO + console.error("isLocked check failed:", e); throw e; } } @@ -128,7 +128,7 @@ export class Bootloader extends Device { if (command) { await this.device.runCommand(command); } else { - throw Error("no unlock command configured"); //K1ZFP TODO + throw new Error("No unlock command configured for this device"); } } @@ -137,7 +137,7 @@ export class Bootloader extends Device { await this.device.runCommand(command); return !(await this.isUnlocked()); } else { - throw Error("no lock command configured"); //K1ZFP TODO + throw new Error("No lock command configured for this device"); } } } -- GitLab From 7d0eb743090826df958e3e8378d347529a5be9b7 Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 15:21:30 +0530 Subject: [PATCH 10/41] Fix isConnected to be sync and return boolean isConnected() in ADB and Recovery classes was marked async but never awaited anything, and returned the device object directly instead of a boolean. This caused two issues: 1. Unnecessary async overhead for a synchronous check 2. Return type inconsistency - callers expect boolean, got object Remove async keyword to match base Device class and Bootloader implementation. Use double-negation (!!) to coerce getDevice() result to boolean explicitly. --- app/src/controller/device/adb.class.js | 4 ++-- app/src/controller/device/recovery.class.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/controller/device/adb.class.js b/app/src/controller/device/adb.class.js index 04be405..1a18a24 100644 --- a/app/src/controller/device/adb.class.js +++ b/app/src/controller/device/adb.class.js @@ -13,12 +13,12 @@ export class ADB extends Device { this.webusb = null; } - async isConnected() { + isConnected() { if (!this.device) { return false; } try { - return this.device.getDevice(); + return !!this.device.getDevice(); } catch { return false; } diff --git a/app/src/controller/device/recovery.class.js b/app/src/controller/device/recovery.class.js index d84916b..85c4bde 100644 --- a/app/src/controller/device/recovery.class.js +++ b/app/src/controller/device/recovery.class.js @@ -12,12 +12,12 @@ export class Recovery extends Device { this.adbDaemonWebUsbDevice = null; } - async isConnected() { + isConnected() { if (!this.device) { return false; } try { - return this.device.getDevice(); + return !!this.device.getDevice(); } catch { return false; } -- GitLab From e50a8ff2d9e80548341b5a5ded04cbb8ac38dd0a Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 15:22:39 +0530 Subject: [PATCH 11/41] Remove unused quota variable this.quota is assigned from navigator.storage.estimate() during init but never read anywhere in the codebase. Remove the dead code. Note: If storage quota checking is desired before downloading large firmware files, this should be implemented as a proper pre-download validation with user feedback, not just a stored variable. --- app/src/controller/downloader.manager.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/controller/downloader.manager.js b/app/src/controller/downloader.manager.js index 5099193..385272c 100644 --- a/app/src/controller/downloader.manager.js +++ b/app/src/controller/downloader.manager.js @@ -22,7 +22,6 @@ export class Downloader { this.db = await this.openDBStore(); await this.clearDBStore(); - this.quota = await navigator.storage.estimate(); } /* -- GitLab From 79661ebf1cac2cfae1e39a3d7cfba2b66e9db768 Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 16:33:50 +0530 Subject: [PATCH 12/41] Improve flash reliability with delays and checks Flash operations can timeout due to USB instability or device not being ready. The current retry logic retries immediately, which often fails again if the device needs time to stabilize. bootloader.class.js: - Add pre-flash device connection check - Add 1 second delay between retries to let device recover - Check device connection before each retry attempt - Log flash size for debugging - Improve error messages with troubleshooting hints controller.manager.js: - Add 500ms cooldown after each flash operation to prevent overwhelming the device with rapid successive writes These changes reduce timeout likelihood by giving the device time to process each operation. The root cause (library timeout threshold) may still need investigation in @e/fastboot, but these mitigations improve real-world reliability. --- app/src/controller.manager.js | 9 ++++++-- app/src/controller/device/bootloader.class.js | 22 ++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index 50a3bf1..bfde75a 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -159,14 +159,19 @@ export class Controller { } case Command.CMD_TYPE.erase: return this.deviceManager.erase(cmd.partition); - case Command.CMD_TYPE.flash: - return this.deviceManager.flash( + case Command.CMD_TYPE.flash: { + const FLASH_COOLDOWN_MS = 500; // Brief pause after flash to let device stabilize + 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 + 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; diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index cb4919e..3068fc9 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -74,7 +74,15 @@ export class Bootloader extends Device { async flashBlob(partition, blob, onProgress, retryCount = 0) { const MAX_RETRIES = 3; + const RETRY_DELAY_MS = 1000; // Wait before retry to let device stabilize + + // Pre-flash check: ensure device is still connected + if (!this.device.isConnected) { + throw new Error(`Device disconnected before flashing ${partition}`); + } + try { + WDebug.log(`Flashing ${partition} (${(blob.size / 1024 / 1024).toFixed(1)} MB)...`); await this.device.flashBlob(partition, blob, (progress) => { onProgress(progress * blob.size, blob.size, partition); }); @@ -84,9 +92,21 @@ export class Bootloader extends Device { if (e instanceof TimeoutError) { WDebug.log(`Timeout on flashblob > ${partition} (attempt ${retryCount + 1}/${MAX_RETRIES})`); if (retryCount < MAX_RETRIES) { + // Wait before retry to allow device to recover + WDebug.log(`Waiting ${RETRY_DELAY_MS}ms before retry...`); + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS)); + + // Check if device is still connected before retry + if (!this.device.isConnected) { + throw new Error(`Device disconnected during flash of ${partition}. Please reconnect and try again.`); + } + return await this.flashBlob(partition, blob, onProgress, retryCount + 1); } - throw new Error(`Bootloader timeout: flashing ${partition} failed after ${MAX_RETRIES} retries`); + throw new Error( + `Bootloader timeout: flashing ${partition} failed after ${MAX_RETRIES} retries. ` + + `Try using a different USB port or cable.` + ); } else { console.log("flashBlob error", e); throw new Error(`Bootloader error: ${e.message || e}`); -- GitLab From cdf956dec11b56232502ee4f7d5fdbb70785fa6f Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 16:49:39 +0530 Subject: [PATCH 13/41] Increase retry and flash cooldown delays --- app/src/controller.manager.js | 2 +- app/src/controller/device/bootloader.class.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index bfde75a..301171d 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -160,7 +160,7 @@ export class Controller { case Command.CMD_TYPE.erase: return this.deviceManager.erase(cmd.partition); case Command.CMD_TYPE.flash: { - const FLASH_COOLDOWN_MS = 500; // Brief pause after flash to let device stabilize + const FLASH_COOLDOWN_MS = 5000; // Pause after flash to let device stabilize const result = await this.deviceManager.flash( cmd.file, cmd.partition, diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index 3068fc9..2f26180 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -74,7 +74,7 @@ export class Bootloader extends Device { async flashBlob(partition, blob, onProgress, retryCount = 0) { const MAX_RETRIES = 3; - const RETRY_DELAY_MS = 1000; // Wait before retry to let device stabilize + const RETRY_DELAY_MS = 3000; // Wait before retry to let device stabilize // Pre-flash check: ensure device is still connected if (!this.device.isConnected) { -- GitLab From 3c5efcbf9120584b9cadd60d1735f3f460dd59e8 Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 16:58:56 +0530 Subject: [PATCH 14/41] Fix off-by-one error in flash retry counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry logic used a 0-indexed retryCount but compared against MAX_RETRIES with < operator, causing 4 attempts instead of 3: [DEBUG] Timeout on flashblob > boot_a (attempt 1/3) [DEBUG] Timeout on flashblob > boot_a (attempt 2/3) [DEBUG] Timeout on flashblob > boot_a (attempt 3/3) [DEBUG] Timeout on flashblob > boot_a (attempt 4/3) <-- should not happen The issue: - retryCount=0: "1/3", 0 < 3 true → retry - retryCount=1: "2/3", 1 < 3 true → retry - retryCount=2: "3/3", 2 < 3 true → retry - retryCount=3: "4/3", 3 < 3 false → throw Rename retryCount to attempt (1-indexed) so the counter matches the displayed value and MAX_ATTEMPTS reflects actual attempt count. --- app/src/controller/device/bootloader.class.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index 2f26180..7230bc1 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -72,8 +72,8 @@ export class Bootloader extends Device { ); } - async flashBlob(partition, blob, onProgress, retryCount = 0) { - const MAX_RETRIES = 3; + async flashBlob(partition, blob, onProgress, attempt = 1) { + const MAX_ATTEMPTS = 3; const RETRY_DELAY_MS = 3000; // Wait before retry to let device stabilize // Pre-flash check: ensure device is still connected @@ -90,8 +90,8 @@ export class Bootloader extends Device { return true; } catch (e) { if (e instanceof TimeoutError) { - WDebug.log(`Timeout on flashblob > ${partition} (attempt ${retryCount + 1}/${MAX_RETRIES})`); - if (retryCount < MAX_RETRIES) { + WDebug.log(`Timeout on flashblob > ${partition} (attempt ${attempt}/${MAX_ATTEMPTS})`); + if (attempt < MAX_ATTEMPTS) { // Wait before retry to allow device to recover WDebug.log(`Waiting ${RETRY_DELAY_MS}ms before retry...`); await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS)); @@ -101,10 +101,10 @@ export class Bootloader extends Device { throw new Error(`Device disconnected during flash of ${partition}. Please reconnect and try again.`); } - return await this.flashBlob(partition, blob, onProgress, retryCount + 1); + return await this.flashBlob(partition, blob, onProgress, attempt + 1); } throw new Error( - `Bootloader timeout: flashing ${partition} failed after ${MAX_RETRIES} retries. ` + + `Bootloader timeout: flashing ${partition} failed after ${MAX_ATTEMPTS} attempts. ` + `Try using a different USB port or cable.` ); } else { -- GitLab From e36b96f4a94798b2ef2c218948f5e8f7c8e433af Mon Sep 17 00:00:00 2001 From: "manu.suresh" Date: Mon, 12 Jan 2026 13:05:14 +0530 Subject: [PATCH 15/41] web-installer: npm run format --- app/src/controller.manager.js | 2 +- app/src/controller/device/bootloader.class.js | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index 301171d..6c10606 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -169,7 +169,7 @@ export class Controller { }, ); // Small delay between flash operations to prevent overwhelming the device - await new Promise(resolve => setTimeout(resolve, FLASH_COOLDOWN_MS)); + await new Promise((resolve) => setTimeout(resolve, FLASH_COOLDOWN_MS)); return result; } case Command.CMD_TYPE.unlock: { diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index 7230bc1..eb36dc0 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -82,7 +82,9 @@ export class Bootloader extends Device { } try { - WDebug.log(`Flashing ${partition} (${(blob.size / 1024 / 1024).toFixed(1)} MB)...`); + WDebug.log( + `Flashing ${partition} (${(blob.size / 1024 / 1024).toFixed(1)} MB)...`, + ); await this.device.flashBlob(partition, blob, (progress) => { onProgress(progress * blob.size, blob.size, partition); }); @@ -90,22 +92,26 @@ export class Bootloader extends Device { return true; } catch (e) { if (e instanceof TimeoutError) { - WDebug.log(`Timeout on flashblob > ${partition} (attempt ${attempt}/${MAX_ATTEMPTS})`); + WDebug.log( + `Timeout on flashblob > ${partition} (attempt ${attempt}/${MAX_ATTEMPTS})`, + ); if (attempt < MAX_ATTEMPTS) { // Wait before retry to allow device to recover WDebug.log(`Waiting ${RETRY_DELAY_MS}ms before retry...`); - await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS)); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); // Check if device is still connected before retry if (!this.device.isConnected) { - throw new Error(`Device disconnected during flash of ${partition}. Please reconnect and try again.`); + throw new Error( + `Device disconnected during flash of ${partition}. Please reconnect and try again.`, + ); } 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.` + `Try using a different USB port or cable.`, ); } else { console.log("flashBlob error", e); -- GitLab From 5004d7ad9d217c3efd96dc7c594c5fc88a757bb4 Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Mon, 12 Jan 2026 15:04:49 +0530 Subject: [PATCH 16/41] Implement retry loop for bootloader connection --- app/src/controller/device/bootloader.class.js | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index eb36dc0..d8ecb93 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -44,10 +44,34 @@ export class Bootloader extends Device { } async connect() { - try { - await this.device.connect(); - } catch (e) { - throw new Error("Cannot connect Bootloader", `${e.message || e}`); + const MAX_CONNECT_ATTEMPTS = 3; + const CONNECT_RETRY_DELAY = 2000; // 2 seconds + + for (let attempt = 1; attempt <= MAX_CONNECT_ATTEMPTS; attempt++) { + try { + WDebug.log(`Connecting to bootloader (attempt ${attempt}/${MAX_CONNECT_ATTEMPTS})...`); + await this.device.connect(); + WDebug.log(`Successfully connected to bootloader on attempt ${attempt}`); + return; + } catch (e) { + const errorMsg = e.message || String(e); + WDebug.log(`Bootloader connection attempt ${attempt} failed: ${errorMsg}`); + + // If this is the last attempt, throw the error + if (attempt === MAX_CONNECT_ATTEMPTS) { + throw new Error( + `Cannot connect to bootloader after ${MAX_CONNECT_ATTEMPTS} attempts. ` + + `The device may not be in bootloader mode yet. ` + + `Please ensure the device is in bootloader/fastboot mode and try again. ` + + `Error: ${errorMsg}` + ); + } + + // Wait before retry, with increasing delay + const delay = CONNECT_RETRY_DELAY * attempt; + WDebug.log(`Waiting ${delay}ms before retry...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } } } -- GitLab From efb1e77775ee69cfeb3a3a5b3cfafdc6f0fb74bc Mon Sep 17 00:00:00 2001 From: "manu.suresh" Date: Fri, 9 Jan 2026 20:52:35 +0530 Subject: [PATCH 17/41] [DNM]: Use custom fastboot.js branch Usage: cd app && rm -rf node_modules && npm i && npm run build-fastboot && npm run dev --- app/package-lock.json | 7 ++++--- app/package.json | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index b075615..ed5307f 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -9,7 +9,7 @@ "version": "0.13.4", "license": "GPLv3", "dependencies": { - "@e/fastboot": "1.1.4", + "@e/fastboot": "https://gitlab.e.foundation/e/tools/fastboot.js/-/archive/1594devices_eos_installer-code-safety/fastboot.js-1594devices_eos_installer-code-safety.tar", "@yume-chan/adb": "1.1.0", "@yume-chan/adb-credential-web": "1.1.0", "@yume-chan/adb-daemon-webusb": "1.1.0", @@ -30,8 +30,9 @@ }, "node_modules/@e/fastboot": { "version": "1.1.4", - "resolved": "https://gitlab.e.foundation/api/v4/projects/1751/packages/npm/@e/fastboot/-/@e/fastboot-1.1.4.tgz", - "integrity": "sha1-aJPGtwFhrnnf0ktIJH47kVvlV+k=", + "resolved": "https://gitlab.e.foundation/e/tools/fastboot.js/-/archive/1594devices_eos_installer-code-safety/fastboot.js-1594devices_eos_installer-code-safety.tar", + "integrity": "sha512-TzVhaeOoe+nfowWZIuh6GWuJoeEv8WSAmoEissv0XA9dghDTS4tIXWO8mcCCyfL++gFqxiARHCWNZHUmIiYSkQ==", + "license": "MIT", "dependencies": { "@zip.js/zip.js": "^2.7.6", "pako": "^2.1.0" diff --git a/app/package.json b/app/package.json index 35808c1..2cadfac 100644 --- a/app/package.json +++ b/app/package.json @@ -4,6 +4,7 @@ "version": "0.13.4", "type": "module", "scripts": { + "build-fastboot": "cd node_modules/@e/fastboot && npm install && npm run build", "dev": "vite", "build": "vite build", "preview": "vite preview", @@ -19,14 +20,14 @@ "vite-plugin-static-copy": "^2.2.0" }, "dependencies": { - "@e/fastboot": "1.1.4", - "hash-wasm": "^4.11.0", + "@e/fastboot": "https://gitlab.e.foundation/e/tools/fastboot.js/-/archive/1594devices_eos_installer-code-safety/fastboot.js-1594devices_eos_installer-code-safety.tar", "@yume-chan/adb": "1.1.0", - "@yume-chan/adb-daemon-webusb": "1.1.0", "@yume-chan/adb-credential-web": "1.1.0", + "@yume-chan/adb-daemon-webusb": "1.1.0", "@yume-chan/stream-extra": "1.0.0", "@yume-chan/struct": "1.0.0", "@zip.js/zip.js": "^2.7.54", + "hash-wasm": "^4.11.0", "ky": "^1.7.4" } } -- GitLab From 5e8b705a317412f96f9e304aede8aa2566a19ae6 Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Fri, 9 Jan 2026 18:05:18 +0530 Subject: [PATCH 18/41] Call resetDevice() before retry after timeout When flash times out, attempt USB device reset before retrying to clear any stale hardware state that may be causing communication issues. --- app/src/controller/device/bootloader.class.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index d8ecb93..d0b6ac8 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -124,6 +124,12 @@ export class Bootloader extends Device { WDebug.log(`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 + if (typeof this.device.resetDevice === 'function') { + WDebug.log("Attempting USB device reset..."); + await this.device.resetDevice(); + } + // Check if device is still connected before retry if (!this.device.isConnected) { throw new Error( -- GitLab From ffb4020d37a23b2ad52f998b3b300db3d285782e Mon Sep 17 00:00:00 2001 From: "manu.suresh" Date: Mon, 26 Jan 2026 04:19:52 +0530 Subject: [PATCH 19/41] web-installer: do not depend on undefined behavior within Controller.next() This code does not work the way it was intended to. isConnected() depends on getDevice() which is not implemented anywhere. It thus evaluates to `undefined`. !undefined is `true` and !!undefined is `false`. Consider following code: if (this.deviceManager.isConnected() && !this.inInMode(next.mode)) { When code was working this would evaluate to: if (undefined && !this.inInMode(next.mode)) { When we changed isConnected to return boolean with !! trick it evaluated !!undefined which is false. So the code became if (false && !this.inInMode(next.mode)) { It short-circuts and never evaluates right hand side and breaks mode switching. Remove isConnected() entirely and restore working logic. --- app/src/controller.manager.js | 6 ++---- app/src/controller/device.manager.js | 19 +++++++------------ app/src/controller/device/adb.class.js | 11 ----------- app/src/controller/device/bootloader.class.js | 4 ---- app/src/controller/device/device.class.js | 4 ---- app/src/controller/device/recovery.class.js | 11 ----------- 6 files changed, 9 insertions(+), 46 deletions(-) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index 6c10606..26cbfeb 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -35,13 +35,11 @@ export class Controller { if (next) { if (next.mode) { //if next step require another mode [adb|fastboot|bootloader] - if (this.deviceManager.isConnected() && !this.inInMode(next.mode)) { + if (!this.inInMode(next.mode)) { //we need reboot await this.deviceManager.reboot(next.mode); } - if (!this.deviceManager.isConnected()) { - await this.deviceManager.connect(next.mode); - } + await this.deviceManager.connect(next.mode); } this.currentIndex++; current = this.steps[this.currentIndex]; diff --git a/app/src/controller/device.manager.js b/app/src/controller/device.manager.js index cb0188f..1139985 100644 --- a/app/src/controller/device.manager.js +++ b/app/src/controller/device.manager.js @@ -98,24 +98,19 @@ export class DeviceManager { } } - isConnected() { - return this.device.isConnected(); - } /** * @param mode * @returns {boolean} * */ isInMode(mode) { - if (this.isConnected()) { - switch (mode) { - case "bootloader": - return this.device.isBootloader(); - case "adb": - return this.device.isADB(); - case "recovery": - return this.device.isRecovery(); - } + switch (mode) { + case "bootloader": + return this.device.isBootloader(); + case "adb": + return this.device.isADB(); + case "recovery": + return this.device.isRecovery(); } return false; } diff --git a/app/src/controller/device/adb.class.js b/app/src/controller/device/adb.class.js index 1a18a24..80c8f6b 100644 --- a/app/src/controller/device/adb.class.js +++ b/app/src/controller/device/adb.class.js @@ -13,17 +13,6 @@ export class ADB extends Device { this.webusb = null; } - isConnected() { - if (!this.device) { - return false; - } - try { - return !!this.device.getDevice(); - } catch { - return false; - } - } - isADB() { return true; } diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index d0b6ac8..fd8d975 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -35,10 +35,6 @@ export class Bootloader extends Device { return this.device.runCommand(command); } - isConnected() { - return this.device.isConnected; - } - isBootloader() { return true; } diff --git a/app/src/controller/device/device.class.js b/app/src/controller/device/device.class.js index 10bcdb9..61a50db 100644 --- a/app/src/controller/device/device.class.js +++ b/app/src/controller/device/device.class.js @@ -7,10 +7,6 @@ export class Device { async connect() {} - isConnected() { - return false; - } - isADB() { return false; } diff --git a/app/src/controller/device/recovery.class.js b/app/src/controller/device/recovery.class.js index 85c4bde..316701d 100644 --- a/app/src/controller/device/recovery.class.js +++ b/app/src/controller/device/recovery.class.js @@ -12,17 +12,6 @@ export class Recovery extends Device { this.adbDaemonWebUsbDevice = null; } - isConnected() { - if (!this.device) { - return false; - } - try { - return !!this.device.getDevice(); - } catch { - return false; - } - } - isRecovery() { return true; } -- GitLab From 7211163ff11e7a2228e7bba8961e2f500565426b Mon Sep 17 00:00:00 2001 From: "manu.suresh" Date: Mon, 26 Jan 2026 04:39:49 +0530 Subject: [PATCH 20/41] web-installer: npm run format --- app/src/controller/device/bootloader.class.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index fd8d975..26b95dc 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -45,21 +45,27 @@ export class Bootloader extends Device { for (let attempt = 1; attempt <= MAX_CONNECT_ATTEMPTS; attempt++) { try { - WDebug.log(`Connecting to bootloader (attempt ${attempt}/${MAX_CONNECT_ATTEMPTS})...`); + WDebug.log( + `Connecting to bootloader (attempt ${attempt}/${MAX_CONNECT_ATTEMPTS})...`, + ); await this.device.connect(); - WDebug.log(`Successfully connected to bootloader on attempt ${attempt}`); + WDebug.log( + `Successfully connected to bootloader on attempt ${attempt}`, + ); return; } catch (e) { const errorMsg = e.message || String(e); - WDebug.log(`Bootloader connection attempt ${attempt} failed: ${errorMsg}`); + WDebug.log( + `Bootloader connection attempt ${attempt} failed: ${errorMsg}`, + ); // If this is the last attempt, throw the error if (attempt === MAX_CONNECT_ATTEMPTS) { throw new Error( `Cannot connect to bootloader after ${MAX_CONNECT_ATTEMPTS} attempts. ` + - `The device may not be in bootloader mode yet. ` + - `Please ensure the device is in bootloader/fastboot mode and try again. ` + - `Error: ${errorMsg}` + `The device may not be in bootloader mode yet. ` + + `Please ensure the device is in bootloader/fastboot mode and try again. ` + + `Error: ${errorMsg}`, ); } -- GitLab From 7c3eb260ea86657b2018c5f3835335f2c97d5beb Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Mon, 26 Jan 2026 17:32:04 +0530 Subject: [PATCH 21/41] Log flash cooldown delay for debugging --- app/src/controller.manager.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index 26cbfeb..6e122eb 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -167,6 +167,7 @@ export class Controller { }, ); // 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; } -- GitLab From 78f2ed36169b4ec28a58d79aac30ee5b009666e0 Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Mon, 2 Feb 2026 13:05:54 +0530 Subject: [PATCH 22/41] Defer bootloader connect for steps requiring user gesture WebUSB requestDevice() can only be called from a user-initiated event. When a step has needUserGesture set, skip the automatic connect in next() and let executeStep handle it when the user clicks. Also add resetDevice() call before bootloader connect retries to clear stale USB state, consistent with the existing flashBlob retry logic. --- app/src/controller.manager.js | 7 ++++++- app/src/controller/device/bootloader.class.js | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index 6e122eb..bd7bea5 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -39,7 +39,12 @@ export class Controller { //we need reboot await this.deviceManager.reboot(next.mode); } - await this.deviceManager.connect(next.mode); + // Skip connect if the step requires a user gesture, since WebUSB + // requestDevice() can only be called from a user-initiated event. + // The connect will happen via executeStep when the user clicks. + if (!next.needUserGesture) { + await this.deviceManager.connect(next.mode); + } } this.currentIndex++; current = this.steps[this.currentIndex]; diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index 26b95dc..cf38fa5 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -73,6 +73,12 @@ export class Bootloader extends Device { const delay = CONNECT_RETRY_DELAY * attempt; WDebug.log(`Waiting ${delay}ms before retry...`); await new Promise((resolve) => setTimeout(resolve, delay)); + + // Try to reset USB device to clear stale state + if (typeof this.device.resetDevice === 'function') { + WDebug.log("Attempting USB device reset before reconnect..."); + await this.device.resetDevice(); + } } } } -- GitLab From ee0f29a41f89eb6d3acef83579d9190499b46a8f Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Mon, 2 Feb 2026 15:02:54 +0530 Subject: [PATCH 23/41] Wait for USB enumeration before bootloader connect step After rebooting to bootloader mode, some USB host controllers (notably AMD Ryzen) are slow to re-enumerate Mediatek devices. Add a waitForDeviceOnBus() helper that listens for the navigator.usb connect event before presenting the step UI, so the device is on the bus by the time the user clicks and triggers requestDevice(). Add detailed debug logging across the bootloader connect flow: paired device VID/PID before each attempt, elapsed time on success/failure, USB bus wait event details, and resilient resetDevice() error handling. --- app/src/controller.manager.js | 30 ++++++++-- app/src/controller/device.manager.js | 55 +++++++++++++++++++ app/src/controller/device/bootloader.class.js | 38 +++++++++++-- 3 files changed, 112 insertions(+), 11 deletions(-) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index bd7bea5..802484b 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -34,20 +34,40 @@ export class Controller { 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 (!this.inInMode(next.mode)) { + 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`); } - // Skip connect if the step requires a user gesture, since WebUSB - // requestDevice() can only be called from a user-initiated event. - // The connect will happen via executeStep when the user clicks. - if (!next.needUserGesture) { + 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); diff --git a/app/src/controller/device.manager.js b/app/src/controller/device.manager.js index 1139985..add2f39 100644 --- a/app/src/controller/device.manager.js +++ b/app/src/controller/device.manager.js @@ -3,6 +3,7 @@ import { Downloader } 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", @@ -180,6 +181,60 @@ export class DeviceManager { } } + /** + * Wait for a USB device to appear on the bus. + * 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). + */ + waitForDeviceOnBus(timeoutMs = 30000) { + const startTime = Date.now(); + return new Promise((resolve) => { + const devices = navigator.usb.getDevices(); + devices.then((list) => { + WDebug.log( + `waitForDeviceOnBus: getDevices() returned ${list.length} device(s)`, + list.map((d) => `${d.vendorId}:${d.productId} "${d.productName}"`), + ); + if (list.length > 0) { + WDebug.log("waitForDeviceOnBus: device already visible, no wait needed"); + resolve(); + return; + } + + WDebug.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( + `waitForDeviceOnBus: timeout after ${elapsed}ms, no device appeared. Proceeding anyway.`, + ); + resolve(); + }, timeoutMs); + + const onConnect = (event) => { + const elapsed = Date.now() - startTime; + const d = event.device; + WDebug.log( + `waitForDeviceOnBus: USB connect event after ${elapsed}ms - ` + + `vendorId=${d.vendorId} productId=${d.productId} ` + + `productName="${d.productName}" serialNumber="${d.serialNumber}"`, + ); + clearTimeout(timeout); + navigator.usb.removeEventListener("connect", onConnect); + // Small delay to let the device fully initialize after enumeration + WDebug.log("waitForDeviceOnBus: waiting 1000ms for device to stabilize..."); + setTimeout(resolve, 1000); + }; + + navigator.usb.addEventListener("connect", onConnect); + }); + }); + } + async downloadAll(onProgress, onUnzip, onVerify) { try { await this.downloader.downloadAndUnzipFolder( diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index cf38fa5..1f15cee 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -42,21 +42,38 @@ export class Bootloader extends Device { async connect() { const MAX_CONNECT_ATTEMPTS = 3; const CONNECT_RETRY_DELAY = 2000; // 2 seconds + const connectStart = Date.now(); + + WDebug.log( + `Bootloader.connect() starting, maxAttempts=${MAX_CONNECT_ATTEMPTS}, ` + + `retryDelay=${CONNECT_RETRY_DELAY}ms, ` + + `device.isConnected=${this.device.isConnected}`, + ); for (let attempt = 1; attempt <= MAX_CONNECT_ATTEMPTS; attempt++) { try { + // Log paired devices before each attempt for debugging + const pairedDevices = await navigator.usb.getDevices(); WDebug.log( - `Connecting to bootloader (attempt ${attempt}/${MAX_CONNECT_ATTEMPTS})...`, + `Bootloader.connect() attempt ${attempt}/${MAX_CONNECT_ATTEMPTS}: ` + + `${pairedDevices.length} paired USB device(s)`, + pairedDevices.map( + (d) => `${d.vendorId}:${d.productId} "${d.productName}"`, + ), ); + await this.device.connect(); + + const elapsed = Date.now() - connectStart; WDebug.log( - `Successfully connected to bootloader on attempt ${attempt}`, + `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( - `Bootloader connection attempt ${attempt} failed: ${errorMsg}`, + `Bootloader.connect() attempt ${attempt} failed after ${elapsed}ms: ${errorMsg}`, ); // If this is the last attempt, throw the error @@ -71,13 +88,22 @@ export class Bootloader extends Device { // Wait before retry, with increasing delay const delay = CONNECT_RETRY_DELAY * attempt; - WDebug.log(`Waiting ${delay}ms before retry...`); + WDebug.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 (typeof this.device.resetDevice === 'function') { - WDebug.log("Attempting USB device reset before reconnect..."); - await this.device.resetDevice(); + WDebug.log("Bootloader.connect() attempting USB device reset..."); + try { + await this.device.resetDevice(); + WDebug.log("Bootloader.connect() USB device reset succeeded"); + } catch (resetErr) { + WDebug.log( + `Bootloader.connect() USB device reset failed: ${resetErr.message || resetErr}`, + ); + } } } } -- GitLab From 30aa023cf7c453e87e59c7b52508e6ab0a4c5ec6 Mon Sep 17 00:00:00 2001 From: Daniel Jacob Chittoor Date: Mon, 2 Feb 2026 15:21:51 +0530 Subject: [PATCH 24/41] Reconnect USB session on flash timeout before retry After a flash timeout, resetDevice() alone may not be enough to recover a degraded USB session (seen on AMD Ryzen with Mediatek devices). Add reconnectDevice() which fully tears down the USB session (releaseInterface + close) then re-establishes the connection, giving a clean pipe for the retry. Also increase flash retry delay from 3s to 5s and add detailed logging with elapsed times throughout the flash retry flow. --- app/src/controller/device/bootloader.class.js | 75 +++++++++++++++++-- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index 1f15cee..78eb670 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -130,9 +130,49 @@ export class Bootloader extends Device { ); } + /** + * Close the USB device and re-establish a fresh connection. + * This is more thorough than resetDevice() and helps recover from + * degraded USB sessions (e.g., AMD Ryzen + Mediatek). + */ + async reconnectDevice() { + const usbDevice = this.device.device; + if (!usbDevice) { + WDebug.log("reconnectDevice: no USB device reference, skipping"); + return; + } + + // Release interface and close the device + try { + if (usbDevice.opened) { + WDebug.log("reconnectDevice: releasing interface and closing device..."); + await usbDevice.releaseInterface(0); + await usbDevice.close(); + WDebug.log("reconnectDevice: device closed"); + } + } catch (e) { + WDebug.log(`reconnectDevice: close failed: ${e.message || e}`); + } + + // Wait for USB bus to stabilize after close + const RECONNECT_SETTLE_MS = 2000; + WDebug.log( + `reconnectDevice: waiting ${RECONNECT_SETTLE_MS}ms for USB bus to settle...`, + ); + await new Promise((resolve) => setTimeout(resolve, RECONNECT_SETTLE_MS)); + + // Re-establish connection (will find paired device via getDevices()) + WDebug.log("reconnectDevice: re-establishing connection..."); + await this.device.connect(); + WDebug.log( + `reconnectDevice: connection re-established, isConnected=${this.device.isConnected}`, + ); + } + async flashBlob(partition, blob, onProgress, attempt = 1) { const MAX_ATTEMPTS = 3; - const RETRY_DELAY_MS = 3000; // Wait before retry to let device stabilize + const RETRY_DELAY_MS = 5000; // Wait before retry to let device stabilize + const flashStart = Date.now(); // Pre-flash check: ensure device is still connected if (!this.device.isConnected) { @@ -141,27 +181,48 @@ export class Bootloader extends Device { try { WDebug.log( - `Flashing ${partition} (${(blob.size / 1024 / 1024).toFixed(1)} MB)...`, + `flashBlob: ${partition} (${(blob.size / 1024 / 1024).toFixed(1)} MB), ` + + `attempt ${attempt}/${MAX_ATTEMPTS}`, ); await this.device.flashBlob(partition, blob, (progress) => { onProgress(progress * blob.size, blob.size, partition); }); onProgress(blob.size, blob.size, partition); + const elapsed = Date.now() - flashStart; + WDebug.log(`flashBlob: ${partition} succeeded in ${elapsed}ms`); return true; } catch (e) { if (e instanceof TimeoutError) { + const elapsed = Date.now() - flashStart; WDebug.log( - `Timeout on flashblob > ${partition} (attempt ${attempt}/${MAX_ATTEMPTS})`, + `flashBlob: timeout on ${partition} after ${elapsed}ms ` + + `(attempt ${attempt}/${MAX_ATTEMPTS})`, ); if (attempt < MAX_ATTEMPTS) { - // Wait before retry to allow device to recover - WDebug.log(`Waiting ${RETRY_DELAY_MS}ms before retry...`); + WDebug.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 if (typeof this.device.resetDevice === 'function') { - WDebug.log("Attempting USB device reset..."); - await this.device.resetDevice(); + WDebug.log("flashBlob: attempting USB device reset..."); + try { + await this.device.resetDevice(); + WDebug.log("flashBlob: USB device reset succeeded"); + } catch (resetErr) { + WDebug.log( + `flashBlob: USB device reset failed: ${resetErr.message || resetErr}`, + ); + } + } + + // Reconnect for a fresh USB session + WDebug.log("flashBlob: reconnecting for fresh USB session..."); + try { + await this.reconnectDevice(); + } catch (reconnErr) { + WDebug.log( + `flashBlob: reconnect failed: ${reconnErr.message || reconnErr}`, + ); } // Check if device is still connected before retry -- GitLab From ad4048440dc740065c1b819208b27b17cbbc80fe Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Fri, 6 Feb 2026 17:28:26 +0530 Subject: [PATCH 25/41] Replace external USB libraries with built-in WebUSB implementation Remove @e/fastboot and all @yume-chan/* packages, replacing them with a custom TypeScript library at app/src/lib/ that implements both the Fastboot and ADB protocols directly over WebUSB. Key changes: - New transport layer with buffered receives and endpoint halt clearing to handle device quirks (16KB USB transfer buffer, clearHalt on open) - Fastboot protocol: command/response, chunked data transfer, sparse image detection and splitting for max-download-size - ADB protocol: CNXN/AUTH handshake with RSA-2048 key generation via Web Crypto API, shell/getProp commands, sideload-host block protocol - Header and payload sent as separate USB bulk transfers per ADB spec - Rewrite bootloader.class.js, adb.class.js, recovery.class.js to use the new library (recovery reduced from 290 to 50 lines) - Update controller.manager.js and device.manager.js for new API - Remove vite-plugin-static-copy (no longer needed without pako) - Add TypeScript config for strict type checking of lib/ sources Signed-off-by: Jackeagle --- app/package-lock.json | 495 +----------------- app/package.json | 12 +- app/src/controller.manager.js | 34 +- app/src/controller/device.manager.js | 8 +- app/src/controller/device/adb.class.js | 69 +-- app/src/controller/device/bootloader.class.js | 128 ++--- app/src/controller/device/recovery.class.js | 267 +--------- app/src/lib/adb/adb-auth.ts | 320 +++++++++++ app/src/lib/adb/adb-device.ts | 376 +++++++++++++ app/src/lib/adb/adb-packet.ts | 154 ++++++ app/src/lib/adb/adb-sideload.ts | 108 ++++ app/src/lib/adb/adb-stream.ts | 151 ++++++ app/src/lib/adb/index.ts | 13 + app/src/lib/adb/types.ts | 58 ++ app/src/lib/fastboot/fastboot-device.ts | 279 ++++++++++ app/src/lib/fastboot/fastboot-protocol.ts | 204 ++++++++ app/src/lib/fastboot/index.ts | 9 + app/src/lib/fastboot/sparse-image.ts | 235 +++++++++ app/src/lib/fastboot/types.ts | 65 +++ app/src/lib/index.ts | 26 + app/src/lib/transport/types.ts | 29 + app/src/lib/transport/webusb.ts | 481 +++++++++++++++++ app/src/lib/types.ts | 104 ++++ app/tsconfig.json | 20 + app/vite.config.js | 15 - 25 files changed, 2758 insertions(+), 902 deletions(-) create mode 100644 app/src/lib/adb/adb-auth.ts create mode 100644 app/src/lib/adb/adb-device.ts create mode 100644 app/src/lib/adb/adb-packet.ts create mode 100644 app/src/lib/adb/adb-sideload.ts create mode 100644 app/src/lib/adb/adb-stream.ts create mode 100644 app/src/lib/adb/index.ts create mode 100644 app/src/lib/adb/types.ts create mode 100644 app/src/lib/fastboot/fastboot-device.ts create mode 100644 app/src/lib/fastboot/fastboot-protocol.ts create mode 100644 app/src/lib/fastboot/index.ts create mode 100644 app/src/lib/fastboot/sparse-image.ts create mode 100644 app/src/lib/fastboot/types.ts create mode 100644 app/src/lib/index.ts create mode 100644 app/src/lib/transport/types.ts create mode 100644 app/src/lib/transport/webusb.ts create mode 100644 app/src/lib/types.ts create mode 100644 app/tsconfig.json diff --git a/app/package-lock.json b/app/package-lock.json index ed5307f..eac8acf 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -9,33 +9,18 @@ "version": "0.13.4", "license": "GPLv3", "dependencies": { - "@e/fastboot": "https://gitlab.e.foundation/e/tools/fastboot.js/-/archive/1594devices_eos_installer-code-safety/fastboot.js-1594devices_eos_installer-code-safety.tar", - "@yume-chan/adb": "1.1.0", - "@yume-chan/adb-credential-web": "1.1.0", - "@yume-chan/adb-daemon-webusb": "1.1.0", - "@yume-chan/stream-extra": "1.0.0", - "@yume-chan/struct": "1.0.0", "@zip.js/zip.js": "^2.7.54", "hash-wasm": "^4.11.0", "ky": "^1.7.4" }, "devDependencies": { "@eslint/js": "^9.17.0", + "@types/w3c-web-usb": "^1.0.10", "eslint": "^9.17.0", "globals": "^15.14.0", "prettier": "3.4.2", - "vite": "^6.0.5", - "vite-plugin-static-copy": "^2.2.0" - } - }, - "node_modules/@e/fastboot": { - "version": "1.1.4", - "resolved": "https://gitlab.e.foundation/e/tools/fastboot.js/-/archive/1594devices_eos_installer-code-safety/fastboot.js-1594devices_eos_installer-code-safety.tar", - "integrity": "sha512-TzVhaeOoe+nfowWZIuh6GWuJoeEv8WSAmoEissv0XA9dghDTS4tIXWO8mcCCyfL++gFqxiARHCWNZHUmIiYSkQ==", - "license": "MIT", - "dependencies": { - "@zip.js/zip.js": "^2.7.6", - "pako": "^2.1.0" + "typescript": "^5.7.0", + "vite": "^6.0.5" } }, "node_modules/@esbuild/aix-ppc64": { @@ -669,44 +654,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.29.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.29.1.tgz", @@ -991,84 +938,9 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.10.tgz", "integrity": "sha512-CHgUI5kTc/QLMP8hODUHhge0D4vx+9UiAwIGiT0sTy/B2XpdX1U5rJt6JSISgr6ikRT7vxV9EVAFeYZqUnl1gQ==", + "dev": true, "license": "MIT" }, - "node_modules/@yume-chan/adb": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yume-chan/adb/-/adb-1.1.0.tgz", - "integrity": "sha512-AC2HhTtxvEPrAQfMP9qDC3FI5Uc6U8j4oH+WMOQ+PKqzI4eme1X3V7OXgPNkrLTQ9SUWgLRw+lgzpvyTvNYpng==", - "license": "MIT", - "dependencies": { - "@yume-chan/async": "^4.0.2", - "@yume-chan/event": "^1.0.0", - "@yume-chan/no-data-view": "^1.0.0", - "@yume-chan/stream-extra": "^1.0.0", - "@yume-chan/struct": "^1.0.0" - } - }, - "node_modules/@yume-chan/adb-credential-web": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yume-chan/adb-credential-web/-/adb-credential-web-1.1.0.tgz", - "integrity": "sha512-jdg0JTZ1Z82gPoxtc29511aPVKPQyXRx5Nf2uRy7UXRmg5oeH6dqO5a45Li1yRo1dwAxZHShxIt90RnP7zDH0g==", - "license": "MIT", - "dependencies": { - "@yume-chan/adb": "^1.1.0" - } - }, - "node_modules/@yume-chan/adb-daemon-webusb": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yume-chan/adb-daemon-webusb/-/adb-daemon-webusb-1.1.0.tgz", - "integrity": "sha512-Q0jkEX/V/PTMZov3udN0gR4uwxfJz0EmKBmRqdJl619rXGC8OfkqlnbrOI4aOjCebm2HCc6d3jVAmjo5sIB7OQ==", - "license": "MIT", - "dependencies": { - "@types/w3c-web-usb": "^1.0.10", - "@yume-chan/adb": "^1.1.0", - "@yume-chan/event": "^1.0.0", - "@yume-chan/stream-extra": "^1.0.0", - "@yume-chan/struct": "^1.0.0" - } - }, - "node_modules/@yume-chan/async": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@yume-chan/async/-/async-4.0.2.tgz", - "integrity": "sha512-YP5Hg4DZoq6CXzeTsiOu6rDNUaWw8SMiM4cB2rHam4zRTatgUHCWpSKMawQt0+nUro/+IeNTZLh2QpIFyxuGzg==", - "license": "MIT" - }, - "node_modules/@yume-chan/event": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@yume-chan/event/-/event-1.0.0.tgz", - "integrity": "sha512-tr4V34WQ5dz2UDMQl4ekj2zGLqwzmclOJpJL+9s2LJpURHw+Szy5g4gi4j86M+5epMFD8dpT9ym/wXHiUdtpsg==", - "license": "MIT", - "dependencies": { - "@yume-chan/async": "^4.0.2" - } - }, - "node_modules/@yume-chan/no-data-view": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@yume-chan/no-data-view/-/no-data-view-1.0.0.tgz", - "integrity": "sha512-KrkXhJJQiCFFXb/eeHB++HCfKuwwiI7RVzHR7X/0XiwjQouxBpNpRFjEO25458Q5p/EPGprGWQ7BsHrmV3mkZQ==", - "license": "MIT" - }, - "node_modules/@yume-chan/stream-extra": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@yume-chan/stream-extra/-/stream-extra-1.0.0.tgz", - "integrity": "sha512-xltJYD5txn63e0jm7bHExmULowJTgjbsC205DN0GCxfdfrZIl6adKVheQNh1yOuOKV5Ok5luWNVSBp7Y2OVffA==", - "license": "MIT", - "dependencies": { - "@yume-chan/async": "^4.0.2", - "@yume-chan/struct": "^1.0.0" - } - }, - "node_modules/@yume-chan/struct": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@yume-chan/struct/-/struct-1.0.0.tgz", - "integrity": "sha512-PQWUjgITlZstIkLD6ouRDwmR35Z9OJZ9daOQ6ZipzQ/mCnHTeoJf2v8x2+fmGyVrrHf9oaCWe8U/XW65onRlGg==", - "license": "MIT", - "dependencies": { - "@yume-chan/async": "^4.0.2", - "@yume-chan/no-data-view": "^1.0.0" - } - }, "node_modules/@zip.js/zip.js": { "version": "2.7.54", "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.54.tgz", @@ -1136,20 +1008,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1164,19 +1022,6 @@ "dev": true, "license": "MIT" }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1188,19 +1033,6 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1228,44 +1060,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1548,36 +1342,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -1592,16 +1356,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", - "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -1615,19 +1369,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1666,21 +1407,6 @@ "dev": true, "license": "ISC" }, - "node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1722,13 +1448,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1782,19 +1501,6 @@ "node": ">=0.8.19" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1818,16 +1524,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1869,19 +1565,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -1941,30 +1624,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2011,16 +1670,6 @@ "dev": true, "license": "MIT" }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2071,12 +1720,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2117,19 +1760,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/postcss": { "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", @@ -2195,40 +1825,6 @@ "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2239,17 +1835,6 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { "version": "4.29.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.29.1.tgz", @@ -2289,30 +1874,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2372,19 +1933,6 @@ "node": ">=8" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2398,14 +1946,18 @@ "node": ">= 0.8.0" } }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": ">= 10.0.0" + "node": ">=14.17" } }, "node_modules/uri-js": { @@ -2490,25 +2042,6 @@ } } }, - "node_modules/vite-plugin-static-copy": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-2.2.0.tgz", - "integrity": "sha512-ytMrKdR9iWEYHbUxs6x53m+MRl4SJsOSoMu1U1+Pfg0DjPeMlsRVx3RR5jvoonineDquIue83Oq69JvNsFSU5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.3", - "fast-glob": "^3.2.11", - "fs-extra": "^11.1.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/app/package.json b/app/package.json index 2cadfac..cc37b65 100644 --- a/app/package.json +++ b/app/package.json @@ -4,7 +4,6 @@ "version": "0.13.4", "type": "module", "scripts": { - "build-fastboot": "cd node_modules/@e/fastboot && npm install && npm run build", "dev": "vite", "build": "vite build", "preview": "vite preview", @@ -13,19 +12,14 @@ }, "devDependencies": { "@eslint/js": "^9.17.0", + "@types/w3c-web-usb": "^1.0.10", "eslint": "^9.17.0", "globals": "^15.14.0", "prettier": "3.4.2", - "vite": "^6.0.5", - "vite-plugin-static-copy": "^2.2.0" + "typescript": "^5.7.0", + "vite": "^6.0.5" }, "dependencies": { - "@e/fastboot": "https://gitlab.e.foundation/e/tools/fastboot.js/-/archive/1594devices_eos_installer-code-safety/fastboot.js-1594devices_eos_installer-code-safety.tar", - "@yume-chan/adb": "1.1.0", - "@yume-chan/adb-credential-web": "1.1.0", - "@yume-chan/adb-daemon-webusb": "1.1.0", - "@yume-chan/stream-extra": "1.0.0", - "@yume-chan/struct": "1.0.0", "@zip.js/zip.js": "^2.7.54", "hash-wasm": "^4.11.0", "ky": "^1.7.4" diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index 802484b..fae74c7 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -192,7 +192,9 @@ export class Controller { }, ); // Small delay between flash operations to prevent overwhelming the device - WDebug.log(`Flash cooldown: waiting ${FLASH_COOLDOWN_MS}ms before next operation`); + WDebug.log( + `Flash cooldown: waiting ${FLASH_COOLDOWN_MS}ms before next operation`, + ); await new Promise((resolve) => setTimeout(resolve, FLASH_COOLDOWN_MS)); return result; } @@ -333,7 +335,7 @@ export class Controller { try { let current_security_path_level = null; try { - const security_patch = await this.deviceManager.adb.webusb.getProp( + const security_patch = await this.deviceManager.adb.getProp( "ro.build.version.security_patch", ); //WDebug.log('security_patch', security_patch) @@ -346,9 +348,9 @@ export class Controller { WDebug.log("Security patch Error"); current_security_path_level = null; } - let this_model = this.deviceManager.adb.webusb.transport.banner.device; + let this_model = this.deviceManager.adb.banner.device; // https://gitlab.e.foundation/e/os/backlog/-/issues/2604#note_609234 - const model = this.deviceManager.adb.webusb.transport.banner.model; + const model = this.deviceManager.adb.banner.model; if (model.includes("Teracube") && model.includes("2e")) { try { const serial = await this.deviceManager.adb.getSerialNumber(); @@ -360,31 +362,31 @@ export class Controller { } else { const id = "model " + - this.deviceManager.adb.webusb.transport.banner.model + + this.deviceManager.adb.banner.model + " " + "product " + - this.deviceManager.adb.webusb.transport.banner.product + + this.deviceManager.adb.banner.product + " " + "name " + - this.deviceManager.adb.device.name + + this.deviceManager.adb.getProductName() + " " + "device " + - this.deviceManager.adb.webusb.transport.banner.device; + this.deviceManager.adb.banner.device; throw new Error("Cannot find device resource", id); } } catch { const id = "model " + - this.deviceManager.adb.webusb.transport.banner.model + + this.deviceManager.adb.banner.model + " " + "product " + - this.deviceManager.adb.webusb.transport.banner.product + + this.deviceManager.adb.banner.product + " " + "name " + - this.deviceManager.adb.device.name + + this.deviceManager.adb.getProductName() + " " + "device " + - this.deviceManager.adb.webusb.transport.banner.device; + this.deviceManager.adb.banner.device; throw new Error("Error on getting device resource", id); } } @@ -395,16 +397,16 @@ export class Controller { } catch { const id = "model " + - this.deviceManager.adb.webusb.transport.banner.model + + this.deviceManager.adb.banner.model + " " + "product " + - this.deviceManager.adb.webusb.transport.banner.product + + this.deviceManager.adb.banner.product + " " + "name " + - this.deviceManager.adb.device.name + + this.deviceManager.adb.getProductName() + " " + "device " + - this.deviceManager.adb.webusb.transport.banner.device; + this.deviceManager.adb.banner.device; throw new Error("Error on getting devcice resource", id); } } diff --git a/app/src/controller/device.manager.js b/app/src/controller/device.manager.js index add2f39..9dc3c12 100644 --- a/app/src/controller/device.manager.js +++ b/app/src/controller/device.manager.js @@ -197,7 +197,9 @@ export class DeviceManager { list.map((d) => `${d.vendorId}:${d.productId} "${d.productName}"`), ); if (list.length > 0) { - WDebug.log("waitForDeviceOnBus: device already visible, no wait needed"); + WDebug.log( + "waitForDeviceOnBus: device already visible, no wait needed", + ); resolve(); return; } @@ -226,7 +228,9 @@ export class DeviceManager { clearTimeout(timeout); navigator.usb.removeEventListener("connect", onConnect); // Small delay to let the device fully initialize after enumeration - WDebug.log("waitForDeviceOnBus: waiting 1000ms for device to stabilize..."); + WDebug.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 80c8f6b..bb6875c 100644 --- a/app/src/controller/device/adb.class.js +++ b/app/src/controller/device/adb.class.js @@ -1,16 +1,11 @@ import { Device } from "./device.class.js"; import { WDebug } from "../../debug.js"; - -import { AdbDaemonWebUsbDeviceManager } from "@yume-chan/adb-daemon-webusb"; -import { Adb, AdbDaemonTransport } from "@yume-chan/adb"; -import AdbWebCredentialStore from "@yume-chan/adb-credential-web"; +import { AdbDevice } from "../../lib/index.ts"; export class ADB extends Device { - static Manager = AdbDaemonWebUsbDeviceManager.BROWSER; - constructor(device) { super(device); - this.webusb = null; + this._adbDevice = null; } isADB() { @@ -21,41 +16,18 @@ export class ADB extends Device { try { console.log("debug adb connect"); - let adbDaemonWebUsbDevice = - await ADB.Manager.requestDevice(); /*AdbDaemonWebUsbDevice*/ - if (typeof adbDaemonWebUsbDevice == "undefined") { - throw new Error("No device connected (1)"); - } - - let connection; - try { - connection = - await adbDaemonWebUsbDevice.connect(); /*AdbDaemonWebUsbConnection*/ - } catch (err) { - console.error(err); - const devices = await ADB.Manager.getDevices(); - if (!devices.length) { - throw new Error("No device connected (2)"); - } - adbDaemonWebUsbDevice = devices[0]; /*AdbDaemonWebUsbDevice*/ - } - - const credentialStore = new AdbWebCredentialStore(); - const transport = await AdbDaemonTransport.authenticate({ - serial: connection.deserial, - connection, - credentialStore: credentialStore, - }); - const adb = new Adb(transport); + // Try to find a paired device first, then request if needed + this._adbDevice = await AdbDevice.requestDevice(); + await this._adbDevice.connect(); - this.device = adbDaemonWebUsbDevice; - this.webusb = adb; /*Adb*/ + this.device = { name: this._adbDevice.usbDevice.productName }; + const banner = this._adbDevice.banner; WDebug.log("----------------------------------"); - WDebug.log("Model", adb.transport.banner.model); - WDebug.log("product", adb.transport.banner.product); - WDebug.log("Name", adbDaemonWebUsbDevice.name); - WDebug.log(">Device (codename)", adb.transport.banner.device); // codemane + 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("----------------------------------"); } catch (e) { console.error(e); @@ -65,24 +37,31 @@ export class ADB extends Device { } getProductName() { - return this.device.name; + return this._adbDevice?.usbDevice?.productName; + } + + get banner() { + return this._adbDevice?.banner || { device: "", model: "", product: "" }; + } + + async getProp(name) { + return this._adbDevice.getProp(name); } async getAndroidVersion() { - return this.webusb.getProp("ro.build.version.release"); + return this._adbDevice.getProp("ro.build.version.release"); } async getSerialNumber() { - return this.webusb.getProp("ro.boot.serialno"); + return this._adbDevice.getProp("ro.boot.serialno"); } async runCommand(cmd) { WDebug.log("ADB Run command>", cmd); - return await this.webusb.exec(cmd); + return await this._adbDevice.shell(cmd); } async reboot(mode) { - const res = await this.webusb.power.reboot(mode); - return res; + 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 78eb670..8ee0c67 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -1,10 +1,9 @@ import { - configureZip, FastbootDevice, - setDebugLevel, TimeoutError, - USER_ACTION_MAP, -} from "@e/fastboot"; + setLogLevel, + LogLevel, +} from "../../lib/index.ts"; import { Device } from "./device.class.js"; import { WDebug } from "../../debug.js"; @@ -13,26 +12,20 @@ import { WDebug } from "../../debug.js"; * */ export class Bootloader extends Device { constructor() { - super(new FastbootDevice()); + super(null); + this.fastboot = null; } async init() { - //await this.blobStore.init(); - configureZip({ - workerScripts: { - inflate: ["/vendor/z-worker-pako.js", "pako_inflate.min.js"], - }, - }); - // Enable verbose debug logging - setDebugLevel(2); + setLogLevel(LogLevel.Debug); } reboot(mode) { - return this.device.reboot(mode); + return this.fastboot.reboot(mode); } runCommand(command) { - return this.device.runCommand(command); + return this.fastboot.runCommand(command); } isBootloader() { @@ -46,8 +39,7 @@ export class Bootloader extends Device { WDebug.log( `Bootloader.connect() starting, maxAttempts=${MAX_CONNECT_ATTEMPTS}, ` + - `retryDelay=${CONNECT_RETRY_DELAY}ms, ` + - `device.isConnected=${this.device.isConnected}`, + `retryDelay=${CONNECT_RETRY_DELAY}ms`, ); for (let attempt = 1; attempt <= MAX_CONNECT_ATTEMPTS; attempt++) { @@ -62,7 +54,11 @@ export class Bootloader extends Device { ), ); - await this.device.connect(); + // On first attempt or after a failed reconnect, create a new device + if (!this.fastboot) { + this.fastboot = await FastbootDevice.requestDevice(); + } + await this.fastboot.connect(); const elapsed = Date.now() - connectStart; WDebug.log( @@ -94,15 +90,16 @@ export class Bootloader extends Device { await new Promise((resolve) => setTimeout(resolve, delay)); // Try to reset USB device to clear stale state - if (typeof this.device.resetDevice === 'function') { + if (this.fastboot) { WDebug.log("Bootloader.connect() attempting USB device reset..."); try { - await this.device.resetDevice(); + await this.fastboot.resetDevice(); WDebug.log("Bootloader.connect() USB device reset succeeded"); } catch (resetErr) { WDebug.log( `Bootloader.connect() USB device reset failed: ${resetErr.message || resetErr}`, ); + this.fastboot = null; // Force new device on next attempt } } } @@ -110,24 +107,11 @@ export class Bootloader extends Device { } getProductName() { - return this.device.device.productName; + return this.fastboot?.usbDevice?.productName; } getSerialNumber() { - return this.device.device.serialNumber; - } - - async flashFactoryZip(blob, onProgress, onReconnect) { - await this.device.flashFactoryZip( - blob, - false, - onReconnect, - // Progress callback - (action, item, progress) => { - let userAction = USER_ACTION_MAP[action]; - onProgress(userAction, item, progress); - }, - ); + return this.fastboot?.usbDevice?.serialNumber; } /** @@ -136,37 +120,21 @@ export class Bootloader extends Device { * degraded USB sessions (e.g., AMD Ryzen + Mediatek). */ async reconnectDevice() { - const usbDevice = this.device.device; - if (!usbDevice) { - WDebug.log("reconnectDevice: no USB device reference, skipping"); + if (!this.fastboot) { + WDebug.log("reconnectDevice: no fastboot device reference, skipping"); return; } - // Release interface and close the device + WDebug.log("reconnectDevice: reconnecting USB session..."); try { - if (usbDevice.opened) { - WDebug.log("reconnectDevice: releasing interface and closing device..."); - await usbDevice.releaseInterface(0); - await usbDevice.close(); - WDebug.log("reconnectDevice: device closed"); - } + await this.fastboot.reconnect(); + WDebug.log( + `reconnectDevice: connection re-established, isConnected=${this.fastboot.isConnected}`, + ); } catch (e) { - WDebug.log(`reconnectDevice: close failed: ${e.message || e}`); + WDebug.log(`reconnectDevice: reconnect failed: ${e.message || e}`); + throw e; } - - // Wait for USB bus to stabilize after close - const RECONNECT_SETTLE_MS = 2000; - WDebug.log( - `reconnectDevice: waiting ${RECONNECT_SETTLE_MS}ms for USB bus to settle...`, - ); - await new Promise((resolve) => setTimeout(resolve, RECONNECT_SETTLE_MS)); - - // Re-establish connection (will find paired device via getDevices()) - WDebug.log("reconnectDevice: re-establishing connection..."); - await this.device.connect(); - WDebug.log( - `reconnectDevice: connection re-established, isConnected=${this.device.isConnected}`, - ); } async flashBlob(partition, blob, onProgress, attempt = 1) { @@ -175,7 +143,7 @@ export class Bootloader extends Device { const flashStart = Date.now(); // Pre-flash check: ensure device is still connected - if (!this.device.isConnected) { + if (!this.fastboot?.isConnected) { throw new Error(`Device disconnected before flashing ${partition}`); } @@ -184,8 +152,8 @@ export class Bootloader extends Device { `flashBlob: ${partition} (${(blob.size / 1024 / 1024).toFixed(1)} MB), ` + `attempt ${attempt}/${MAX_ATTEMPTS}`, ); - await this.device.flashBlob(partition, blob, (progress) => { - onProgress(progress * blob.size, blob.size, partition); + await this.fastboot.flashBlob(partition, blob, (sent, total) => { + onProgress(sent, total, partition); }); onProgress(blob.size, blob.size, partition); const elapsed = Date.now() - flashStart; @@ -203,16 +171,14 @@ export class Bootloader extends Device { await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); // Try to reset USB device to clear stale state - if (typeof this.device.resetDevice === 'function') { - WDebug.log("flashBlob: attempting USB device reset..."); - try { - await this.device.resetDevice(); - WDebug.log("flashBlob: USB device reset succeeded"); - } catch (resetErr) { - WDebug.log( - `flashBlob: USB device reset failed: ${resetErr.message || resetErr}`, - ); - } + WDebug.log("flashBlob: attempting USB device reset..."); + try { + await this.fastboot.resetDevice(); + WDebug.log("flashBlob: USB device reset succeeded"); + } catch (resetErr) { + WDebug.log( + `flashBlob: USB device reset failed: ${resetErr.message || resetErr}`, + ); } // Reconnect for a fresh USB session @@ -226,7 +192,7 @@ export class Bootloader extends Device { } // Check if device is still connected before retry - if (!this.device.isConnected) { + if (!this.fastboot?.isConnected) { throw new Error( `Device disconnected during flash of ${partition}. Please reconnect and try again.`, ); @@ -246,13 +212,13 @@ export class Bootloader extends Device { } bootBlob(blob) { - return this.device.bootBlob(blob); + return this.fastboot.bootBlob(blob); } async isUnlocked(variable) { - if (this.device && this.device.isConnected) { + if (this.fastboot?.isConnected) { try { - const unlocked = await this.device.getVariable(variable); + const unlocked = await this.fastboot.getVariable(variable); return !(!unlocked || unlocked === "no"); } catch (e) { console.error("isUnlocked check failed:", e); @@ -263,9 +229,9 @@ export class Bootloader extends Device { } async isLocked(variable) { - if (this.device && this.device.isConnected) { + if (this.fastboot?.isConnected) { try { - const unlocked = await this.device.getVariable(variable); + const unlocked = await this.fastboot.getVariable(variable); return !unlocked || unlocked === "no"; } catch (e) { console.error("isLocked check failed:", e); @@ -277,7 +243,7 @@ export class Bootloader extends Device { async unlock(command) { if (command) { - await this.device.runCommand(command); + await this.fastboot.runCommand(command); } else { throw new Error("No unlock command configured for this device"); } @@ -285,7 +251,7 @@ export class Bootloader extends Device { async lock(command) { if (command) { - await this.device.runCommand(command); + await this.fastboot.runCommand(command); return !(await this.isUnlocked()); } else { throw new Error("No lock command configured for this device"); diff --git a/app/src/controller/device/recovery.class.js b/app/src/controller/device/recovery.class.js index 316701d..fae5d33 100644 --- a/app/src/controller/device/recovery.class.js +++ b/app/src/controller/device/recovery.class.js @@ -1,15 +1,11 @@ -import { AdbCommand, calculateChecksum } from "@yume-chan/adb"; -import { Consumable } from "@yume-chan/stream-extra"; -import { EmptyUint8Array, decodeUtf8, encodeUtf8 } from "@yume-chan/struct"; import { Device } from "./device.class.js"; import { WDebug } from "../../debug.js"; -import { ADB } from "./adb.class.js"; +import { AdbDevice } from "../../lib/index.ts"; export class Recovery extends Device { constructor(device) { super(device); - this.count = 0; - this.adbDaemonWebUsbDevice = null; + this._adbDevice = null; } isRecovery() { @@ -18,272 +14,37 @@ export class Recovery extends Device { async connect() { try { - if (this.device && this.device.isConnected) { - WDebug.log("Connect recovery the device is connected"); - } else { - let adbDaemonWebUsbDevice = await ADB.Manager.requestDevice(); - if (typeof adbDaemonWebUsbDevice == "undefined") { - throw new Error("No device connected (1)"); - } - - try { - this.connection = await adbDaemonWebUsbDevice.connect(); - } catch (err) { - console.error(err); - const devices = await ADB.Manager.getDevices(); - if (!devices.length) { - throw new Error("No device connected (2)"); - } - adbDaemonWebUsbDevice = devices[0]; // Assume one device is connected - } - - this.adbDaemonWebUsbDevice = adbDaemonWebUsbDevice.raw; - - // Filter to identify Android device in adb mode. - const WebUsbDeviceFilter = { - classCode: 0xff, - subclassCode: 0x42, - protocolCode: 1, - }; - - await this.getInOutEndpoints(WebUsbDeviceFilter); - - const version = 0x01000001; - const maxPayloadSize = 0x100000; - await this.sendPacket( - AdbCommand.Connect, - version, - maxPayloadSize, - "host::\0", - ); - const r = await this.readOnDevice(); - - if (r.value.command == AdbCommand.Connect) { - //All is fine - } else { - throw new Error("Adb sideload connection error"); - } - } + this._adbDevice = await AdbDevice.requestDevice(); + await this._adbDevice.connect(); + this.device = { name: this._adbDevice.usbDevice.productName }; + WDebug.log("Recovery connected:", this._adbDevice.usbDevice.productName); } catch (e) { this.device = null; throw new Error(`Cannot connect Recovery ${e.message || e}`); } } - /** - * Finds and selects the input and output endpoints of a USB device matching a given filter. - * - * @async - * @param {Object} WebUsbDeviceFilter - Filter defining the criteria for selecting USB interfaces. - * @returns void. - * - * @description - * This function iterates through the configurations of the attached USB device (`adbDaemonWebUsbDevice`) - * to identify an interface that matches the `WebUsbDeviceFilter` criteria and exits - * as soon as both endpoints are found (in & out). - */ - async getInOutEndpoints(WebUsbDeviceFilter) { - let _a; - outerLoop: for (const configuration of this.adbDaemonWebUsbDevice - .configurations) { - for (const interface_ of configuration.interfaces) { - for (const alternate of interface_.alternates) { - if ( - alternate.interfaceClass === WebUsbDeviceFilter.classCode && - alternate.interfaceSubclass === WebUsbDeviceFilter.subclassCode && - alternate.interfaceProtocol === WebUsbDeviceFilter.protocolCode - ) { - if ( - ((_a = this.adbDaemonWebUsbDevice.configuration) === null || - _a === void 0 - ? void 0 - : _a.configurationValue) !== configuration.configurationValue - ) { - await this.adbDaemonWebUsbDevice.selectConfiguration( - configuration.configurationValue, - ); - } - if (!interface_.claimed) { - await this.adbDaemonWebUsbDevice.claimInterface( - interface_.interfaceNumber, - ); - } - if ( - interface_.alternate.alternateSetting !== - alternate.alternateSetting - ) { - await this.adbDaemonWebUsbDevice.selectAlternateInterface( - interface_.interfaceNumber, - alternate.alternateSetting, - ); - } - for (const endpoint of alternate.endpoints) { - switch (endpoint.direction) { - case "in": - this._inEndpointNumber = endpoint.endpointNumber; - if (this._outEndpointNumber !== undefined) { - break outerLoop; - } - break; - case "out": - this._outEndpointNumber = endpoint.endpointNumber; - if (this._inEndpointNumber !== undefined) { - break outerLoop; - } - break; - } - } - } - } - } - } - } - - async readOnDevice() { - const reader = await this.connection?.readable?.getReader(); - if (!reader) { - throw new Error("readOnDevice() : Unable to read on device"); - } - const r = await reader.read(); - reader.releaseLock(); - return r; - } - - async sendPacket(command, arg0, arg1, payload) { - const writer = this.connection?.writable?.getWriter(); - if (!writer) { - throw new Error("sendPacket() : Unable to write on device"); - } - - if (typeof payload === "string") { - payload = encodeUtf8(payload); - } - - const checksum = payload ? calculateChecksum(payload) : 0; - const magic = command ^ 0xffffffff; - await Consumable.WritableStream.write(writer, { - command: command, - arg0: arg0, - arg1: arg1, - payload: payload, - checksum: checksum, - magic: magic, - }); - writer.releaseLock(); - } - - async createStream(service) { - const localId = 1; // Assume one device is connected - service += "\0"; - let remoteId; - await this.sendPacket(AdbCommand.Open, localId, 0, service); - const r = await this.readOnDevice(); - if (r.value.command == AdbCommand.Okay) { - remoteId = r.value.arg0; - return { localId: localId, remoteId: remoteId }; - } else { - throw new Error("Adb sideload create stream error"); - } - } - async sideload(blob) { try { - await this.adbOpen(blob); + await this._adbDevice.sideload(blob, (block, totalBlocks) => { + if (block % 10 === 0) { + WDebug.log(`Sideloading block ${block}/${totalBlocks}`); + } + }); } catch (e) { throw new Error(`Sideload fails ${e.message || e}`); } } async reboot(mode) { - return await this.device.shell(`reboot ${mode}`); + return await this._adbDevice.reboot(mode); } getProductName() { - return this.adbDaemonWebUsbDevice?.productName; + return this._adbDevice?.usbDevice?.productName; } getSerialNumber() { - return this.adbDaemonWebUsbDevice?.serialNumber; - } - - async adbOpen(blob) { - const MAX_PAYLOAD = 0x10000; - const fileSize = blob.size; - const service = `sideload-host:${fileSize}:${MAX_PAYLOAD}`; //sideload-host:1381604186:262144 - - this.stream = await this.createStream(service); // Send Open message and receive OKAY. - - const localId = this.stream.localId; - const remoteId = this.stream.remoteId; - - let message; - await this.sendPacket(AdbCommand.Okay, localId, remoteId, EmptyUint8Array); - const r = await this.readOnDevice(); - - if (r.value.command == AdbCommand.Write) { - message = { - data: r.value.payload, - }; - } else { - throw new Error("Write OKAY Failed (init)"); - } - - while (true) { - const res = decodeUtf8(message.data); - const block = Number(res); - - if (isNaN(block) && res === "DONEDONE") { - WDebug.log("DONEDONE"); - break; - } else { - if (block % 10 == 0) { - WDebug.log("Sideloading " + block); - } - } - - const offset = block * MAX_PAYLOAD; - if (offset >= fileSize) { - throw new Error(`adb: failed to read block ${block} past end`); - } - - let to_write = MAX_PAYLOAD; - if (offset + MAX_PAYLOAD > fileSize) { - to_write = fileSize - offset; - } - - let slice = blob.slice(offset, offset + to_write); - let buff = await slice.arrayBuffer(); - - await this.sendPacket( - AdbCommand.Write, - localId, - remoteId, - new Uint8Array(buff), - ); - const r = await this.readOnDevice(); - - if (r.value.command == AdbCommand.Okay) { - await this.sendPacket( - AdbCommand.Okay, - localId, - remoteId, - EmptyUint8Array, - ); - const r = await this.readOnDevice(); - - if (r.value.command == AdbCommand.Write) { - message = { - data: r.value.payload, - }; - } else { - console.error("Error sideload (A)", r); - throw new Error(`WRTE Failed ${block}`); - } - } else { - console.error("Error sideload (B)", r); - throw new Error("Write OKAY Failed (init)"); - } - } - return true; + return this._adbDevice?.usbDevice?.serialNumber; } } diff --git a/app/src/lib/adb/adb-auth.ts b/app/src/lib/adb/adb-auth.ts new file mode 100644 index 0000000..94de09d --- /dev/null +++ b/app/src/lib/adb/adb-auth.ts @@ -0,0 +1,320 @@ +/** + * ADB authentication using Web Crypto API. + * + * Handles RSA-2048 key generation, token signing, and key storage + * in browser IndexedDB. Exports public keys in the Android-specific + * format expected by adbd. + * + * Android RSA public key format (serialized struct): + * - uint32: key size in 32-bit words (64 for 2048-bit) + * - uint32: n0inv (Montgomery parameter: -n^-1 mod 2^32) + * - uint8[256]: modulus (little-endian) + * - uint8[256]: rr (R^2 mod n, for Montgomery multiplication) + * - uint32: exponent (65537) + * + * The entire struct is base64-encoded with a trailing " user@host\0". + */ + +import { log, logError } from "../types.js"; +import { + ADB_CREDENTIAL_STORE_NAME, + ADB_CREDENTIAL_DB_VERSION, +} from "./types.js"; + +const KEY_ALGORITHM: RsaHashedKeyGenParams = { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537 + hash: "SHA-1", +}; + +export interface AdbCredentialStore { + /** Get all stored key pairs for signing */ + getKeys(): Promise; + /** Generate a new key pair and store it */ + generateKey(): Promise; +} + +/** + * Browser IndexedDB-backed ADB credential store. + * Stores RSA-2048 key pairs for ADB authentication. + */ +export class BrowserAdbCredentialStore implements AdbCredentialStore { + private _dbName: string; + private _db: IDBDatabase | null = null; + + constructor(dbName: string = ADB_CREDENTIAL_STORE_NAME) { + this._dbName = dbName; + } + + async getKeys(): Promise { + const db = await this.openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction("keys", "readonly"); + const store = tx.objectStore("keys"); + const req = store.getAll(); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); + } + + async generateKey(): Promise { + const keyPair = await crypto.subtle.generateKey(KEY_ALGORITHM, true, [ + "sign", + ]); + + const db = await this.openDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction("keys", "readwrite"); + const store = tx.objectStore("keys"); + const req = store.add(keyPair); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + + log("Generated and stored new ADB RSA key pair"); + return keyPair; + } + + private async openDB(): Promise { + if (this._db) return this._db; + + return new Promise((resolve, reject) => { + const req = indexedDB.open(this._dbName, ADB_CREDENTIAL_DB_VERSION); + req.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains("keys")) { + db.createObjectStore("keys", { autoIncrement: true }); + } + }; + req.onsuccess = (event) => { + this._db = (event.target as IDBOpenDBRequest).result; + resolve(this._db); + }; + req.onerror = () => reject(req.error); + }); + } +} + +/** + * Sign an ADB authentication token with a private key. + * Returns the PKCS#1 v1.5 signature (256 bytes for RSA-2048). + */ +export async function signToken( + privateKey: CryptoKey, + token: Uint8Array, +): Promise { + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + privateKey, + token as BufferSource, + ); + return new Uint8Array(signature); +} + +/** + * Export a public key in the Android ADB RSA format. + * + * The Android format is: + * base64(struct) + " " + user@host + "\0" + * + * Where struct is: + * uint32_le numWords (64 for 2048-bit) + * uint32_le n0inv (-n^-1 mod 2^32) + * byte[256] n (modulus, little-endian) + * byte[256] rr (R^2 mod n, little-endian) + * uint32_le exponent (65537) + */ +export async function exportPublicKey( + publicKey: CryptoKey, +): Promise { + // Export as JWK to get the modulus directly (avoids fragile ASN.1 parsing) + const jwk = await crypto.subtle.exportKey("jwk", publicKey); + if (!jwk.n) { + throw new Error("JWK export missing modulus (n)"); + } + const modulusBE = base64urlToUint8Array(jwk.n); + + // Build the Android struct + const struct = buildAndroidRsaStruct(modulusBE); + + // Base64-encode and add the user@host suffix + const b64 = uint8ArrayToBase64(struct); + const suffix = " adb@browser\0"; + const encoder = new TextEncoder(); + const suffixBytes = encoder.encode(suffix); + + const b64Bytes = encoder.encode(b64); + const result = new Uint8Array(b64Bytes.byteLength + suffixBytes.byteLength); + result.set(b64Bytes, 0); + result.set(suffixBytes, b64Bytes.byteLength); + + return result; +} + +// ---- Internal Helpers ---- + +/** + * Decode a base64url string (as used in JWK) to a Uint8Array. + */ +function base64urlToUint8Array(b64url: string): Uint8Array { + // base64url → standard base64 + const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Build the Android RSA public key struct from a modulus. + * + * struct RSAPublicKey { + * uint32_t numWords; // 64 (2048 / 32) + * uint32_t n0inv; // -n^-1 mod 2^32 + * uint8_t n[256]; // modulus (little-endian) + * uint8_t rr[256]; // R^2 mod n (little-endian) + * uint32_t exponent; // 65537 + * } + */ +function buildAndroidRsaStruct(modulusBE: Uint8Array): Uint8Array { + const keySize = 2048; + const numWords = keySize / 32; // 64 + + // Convert modulus from big-endian to little-endian + const modulusLE = new Uint8Array(256); + for (let i = 0; i < 256; i++) { + modulusLE[i] = modulusBE[255 - i]; + } + + // Compute n0inv: -n^-1 mod 2^32 + const n0inv = computeN0inv(modulusLE); + + // Compute rr: R^2 mod n where R = 2^2048 + const rr = computeRR(modulusBE); + + // Build the struct (4 + 4 + 256 + 256 + 4 = 524 bytes) + const struct = new Uint8Array(524); + const view = new DataView(struct.buffer); + + view.setUint32(0, numWords, true); + view.setUint32(4, n0inv, true); + struct.set(modulusLE, 8); + struct.set(rr, 264); + view.setUint32(520, 65537, true); + + return struct; +} + +/** + * Compute n0inv = -n^-1 mod 2^32. + * Uses the modulus in little-endian format. + */ +function computeN0inv(modulusLE: Uint8Array): number { + // Extract lowest 32 bits of n + const n0 = + modulusLE[0] | + (modulusLE[1] << 8) | + (modulusLE[2] << 16) | + (modulusLE[3] << 24); + + // Extended Euclidean algorithm for modular inverse mod 2^32 + // Using Newton's method: x = x * (2 - n * x) mod 2^32 + let inv = n0; // n is odd, so n itself is a starting approximation + for (let i = 0; i < 5; i++) { + inv = Math.imul(inv, 2 - Math.imul(n0, inv)) | 0; + } + + // We want -n^-1 mod 2^32 + return (-inv) >>> 0; +} + +/** + * Compute R^2 mod n where R = 2^2048. + * Returns 256 bytes in little-endian. + * + * Uses BigInt for the computation since we need 2048-bit arithmetic. + */ +function computeRR(modulusBE: Uint8Array): Uint8Array { + // Convert modulus to BigInt + let n = 0n; + for (let i = 0; i < modulusBE.byteLength; i++) { + n = (n << 8n) | BigInt(modulusBE[i]); + } + + // R = 2^2048 + const r = 1n << 2048n; + + // rr = R^2 mod n + const rr = (r * r) % n; + + // Convert to 256 bytes little-endian + const rrBytes = new Uint8Array(256); + let val = rr; + for (let i = 0; i < 256; i++) { + rrBytes[i] = Number(val & 0xffn); + val >>= 8n; + } + + return rrBytes; +} + +/** + * Convert a Uint8Array to a base64 string. + */ +function uint8ArrayToBase64(data: Uint8Array): string { + let binary = ""; + for (let i = 0; i < data.byteLength; i++) { + binary += String.fromCharCode(data[i]); + } + return btoa(binary); +} + +/** + * Attempt authentication with stored keys, then generate a new key if needed. + * + * ADB auth flow: + * 1. Device sends AUTH TOKEN (20 random bytes) + * 2. Host signs token with each stored private key, sends AUTH SIGNATURE + * 3. If no stored key works, generate new key, send AUTH RSAPUBLICKEY + * 4. User must approve the key on the device screen + * 5. Device sends CNXN on success + * + * @param token - The 20-byte random token from the device + * @param store - The credential store for key management + * @param sendSignature - Callback to send AUTH SIGNATURE and check if accepted + * @param sendPublicKey - Callback to send AUTH RSAPUBLICKEY + */ +export async function authenticate( + token: Uint8Array, + store: AdbCredentialStore, + sendSignature: (signature: Uint8Array) => Promise, + sendPublicKey: (publicKey: Uint8Array) => Promise, +): Promise { + // Try signing with existing keys + const keys = await store.getKeys(); + for (const keyPair of keys) { + try { + const signature = await signToken(keyPair.privateKey, token); + const accepted = await sendSignature(signature); + if (accepted) { + log("Authenticated with existing key"); + return; + } + } catch (e) { + logError("Key signing failed:", e); + } + } + + // No existing key worked — generate a new one + log("No existing key accepted, generating new key pair..."); + const newKeyPair = await store.generateKey(); + const publicKeyBytes = await exportPublicKey(newKeyPair.publicKey); + + // Send the public key (user must approve on device) + await sendPublicKey(publicKeyBytes); + log("Public key sent — waiting for user approval on device"); +} diff --git a/app/src/lib/adb/adb-device.ts b/app/src/lib/adb/adb-device.ts new file mode 100644 index 0000000..a3ec3bc --- /dev/null +++ b/app/src/lib/adb/adb-device.ts @@ -0,0 +1,376 @@ +/** + * High-level ADB device interface. + * + * Provides the public API for connecting to a device in ADB mode, + * executing shell commands, reading properties, and performing sideloads. + * Handles the full CNXN/AUTH handshake including RSA key management. + */ + +import { + ProtocolError, + UsbError, + type DeviceBanner, + type SideloadProgressCallback, + log, + logError, +} from "../types.js"; +import { WebUsbTransport } from "../transport/webusb.js"; +import { ADB_USB_FILTER } from "../transport/types.js"; +import { DEFAULT_TIMEOUT_MS } from "../transport/types.js"; +import { + AdbCommand, + ADB_VERSION, + ADB_MAX_PAYLOAD, + AdbAuthType, + type AdbPacket, +} from "./types.js"; +import { + writePacket, + readPacket, + decodeUtf8, +} from "./adb-packet.js"; +import { + BrowserAdbCredentialStore, + signToken, + exportPublicKey, + type AdbCredentialStore, +} from "./adb-auth.js"; +import { AdbStream } from "./adb-stream.js"; +import { sideload as performSideload } from "./adb-sideload.js"; + +export class AdbDevice { + private _transport: WebUsbTransport; + private _connected = false; + private _banner: DeviceBanner = { device: "", model: "", product: "" }; + private _credentialStore: AdbCredentialStore; + + private constructor( + transport: WebUsbTransport, + credentialStore?: AdbCredentialStore, + ) { + this._transport = transport; + this._credentialStore = + credentialStore ?? new BrowserAdbCredentialStore(); + } + + // ---- Static Factory Methods ---- + + /** + * Prompt the user to select an ADB device. + * Requires a user gesture (click/tap). + */ + static async requestDevice( + credentialStore?: AdbCredentialStore, + ): Promise { + const transport = await WebUsbTransport.requestDevice(ADB_USB_FILTER); + return new AdbDevice(transport, credentialStore); + } + + /** + * Find an already-paired ADB device without user gesture. + * Returns null if no paired ADB device is found. + */ + static async findDevice( + credentialStore?: AdbCredentialStore, + ): Promise { + const transport = await WebUsbTransport.findDevice(ADB_USB_FILTER); + if (!transport) return null; + return new AdbDevice(transport, credentialStore); + } + + // ---- Connection ---- + + /** + * Open the USB connection and perform the ADB handshake (CNXN + AUTH). + */ + async connect(): Promise { + if (this._connected) return; + + await this._transport.open(); + + // Send CNXN (header + payload as separate USB transfers) + const sendFn = (data: Uint8Array) => + this._transport.sendWithTimeout(data, DEFAULT_TIMEOUT_MS); + await writePacket( + sendFn, + AdbCommand.Connect, + ADB_VERSION, + ADB_MAX_PAYLOAD, + `host::\0`, + ); + + log("ADB CNXN sent, waiting for response..."); + + // Read response + const response = await this.receivePacket(); + + if (response.command === AdbCommand.Connect) { + // Direct connect (no auth required — device already trusts us) + this.parseBanner(response.payload); + this._connected = true; + log("ADB connected (no auth required)", this._banner); + return; + } + + if (response.command === AdbCommand.Auth) { + // Auth required — handle token challenge + await this.handleAuth(response); + this._connected = true; + log("ADB connected (authenticated)", this._banner); + return; + } + + throw new ProtocolError( + `Unexpected ADB response: command=0x${response.command.toString(16)}`, + ); + } + + /** + * Disconnect from the device. + */ + async disconnect(): Promise { + if (!this._connected) return; + await this._transport.close(); + this._connected = false; + } + + // ---- Commands ---- + + /** + * Execute a shell command and return stdout as a string. + */ + async shell(command: string): Promise { + this.ensureConnected(); + + const stream = await AdbStream.open( + this._transport, + `shell:${command}`, + () => this.receivePacket(), + ); + + let output = ""; + + try { + // Read all data until stream closes + // eslint-disable-next-line no-constant-condition + while (true) { + const packet = await this.receivePacket(); + + if (packet.command === AdbCommand.Write) { + output += decodeUtf8(packet.payload); + // Send OKAY to acknowledge + await stream.sendOkay(); + } else if (packet.command === AdbCommand.Close) { + break; + } else if (packet.command === AdbCommand.Okay) { + // Flow control, continue reading + continue; + } else { + log( + `Shell: unexpected command 0x${packet.command.toString(16)}`, + ); + break; + } + } + } finally { + await stream.close(); + } + + return output.trim(); + } + + /** + * Get a system property value (equivalent to `getprop `). + */ + async getProp(name: string): Promise { + return this.shell(`getprop ${name}`); + } + + /** + * Get the device serial number. + */ + async getSerialNumber(): Promise { + return this.getProp("ro.boot.serialno"); + } + + /** + * Reboot the device. + * @param mode - "" for normal, "bootloader", "recovery", "fastboot", etc. + */ + async reboot(mode?: string): Promise { + this.ensureConnected(); + + const service = mode ? `reboot:${mode}` : "reboot:"; + + try { + const stream = await AdbStream.open( + this._transport, + service, + () => this.receivePacket(), + ); + await stream.close(); + } catch { + // Reboot causes USB disconnect, so errors are expected + log(`Reboot command sent (${mode || "normal"})`); + } + + this._connected = false; + } + + /** + * Sideload a file to the device (must be in recovery mode). + */ + async sideload( + blob: Blob, + onProgress?: SideloadProgressCallback, + ): Promise { + this.ensureConnected(); + await performSideload( + this._transport, + blob, + () => this.receivePacket(), + undefined, + onProgress, + ); + } + + // ---- Getters ---- + + get banner(): DeviceBanner { + return { ...this._banner }; + } + + get isConnected(): boolean { + return this._connected; + } + + get usbDevice(): USBDevice { + return this._transport.device; + } + + // ---- Private ---- + + private ensureConnected(): void { + if (!this._connected) { + throw new UsbError("ADB device not connected"); + } + } + + /** + * Read a complete ADB packet from the transport. + */ + private async receivePacket(): Promise { + return readPacket((length) => + this._transport.receiveWithTimeout(length, DEFAULT_TIMEOUT_MS), + ); + } + + /** + * Handle the ADB authentication handshake. + * + * Flow: + * 1. Device sends AUTH with TOKEN type (20 random bytes) + * 2. Try signing with each stored key → send AUTH SIGNATURE + * 3. If accepted (CNXN), done + * 4. If not, generate new key → send AUTH RSAPUBLICKEY → wait for user approval + */ + private async handleAuth(authPacket: AdbPacket): Promise { + const token = authPacket.payload; + + // Try each stored key + const keys = await this._credentialStore.getKeys(); + const sendFn = (data: Uint8Array) => + this._transport.sendWithTimeout(data, DEFAULT_TIMEOUT_MS); + + for (const keyPair of keys) { + try { + const signature = await signToken(keyPair.privateKey, token); + await writePacket( + sendFn, + AdbCommand.Auth, + AdbAuthType.Signature, + 0, + signature, + ); + + const response = await this.receivePacket(); + if (response.command === AdbCommand.Connect) { + this.parseBanner(response.payload); + return; + } + // If AUTH again, try next key + } catch (e) { + logError("Auth signature attempt failed:", e); + } + } + + // No stored key worked — generate new key and send public key + log("No stored key accepted, generating new key pair..."); + const newKeyPair = await this._credentialStore.generateKey(); + const publicKeyBytes = await exportPublicKey(newKeyPair.publicKey); + + await writePacket( + sendFn, + AdbCommand.Auth, + AdbAuthType.RsaPublicKey, + 0, + publicKeyBytes, + ); + + log("Public key sent, waiting for user approval on device..."); + + // Wait for CNXN (user approves on device screen) + // Use a longer timeout since user needs to interact with the device + const response = await readPacket((length) => + this._transport.receiveWithTimeout(length, 60_000), + ); + + if (response.command !== AdbCommand.Connect) { + throw new ProtocolError( + "ADB authentication failed — user may have denied the connection on the device", + ); + } + + this.parseBanner(response.payload); + } + + /** + * Parse the device banner from a CNXN payload. + * + * Banner format: "device::ro.product.name=XXX;ro.product.model=YYY;..." + * Or simpler: "device::" + * The transport banner fields come from system properties sent during connect. + */ + private parseBanner(payload: Uint8Array): void { + const bannerStr = decodeUtf8(payload).replace(/\0/g, ""); + log(`ADB banner: "${bannerStr}"`); + + // Parse key-value pairs from the banner + // Format: "device::prop1=val1;prop2=val2;..." + const parts = bannerStr.split("::"); + const propsStr = parts.length > 1 ? parts[1] : ""; + + const props = new Map(); + for (const pair of propsStr.split(";")) { + const [key, value] = pair.split("=", 2); + if (key && value) { + props.set(key.trim(), value.trim()); + } + } + + this._banner = { + device: + props.get("ro.product.device") || + props.get("device") || + "", + model: + props.get("ro.product.model") || + props.get("model") || + "", + product: + props.get("ro.product.name") || + props.get("product") || + "", + }; + } +} diff --git a/app/src/lib/adb/adb-packet.ts b/app/src/lib/adb/adb-packet.ts new file mode 100644 index 0000000..5e86815 --- /dev/null +++ b/app/src/lib/adb/adb-packet.ts @@ -0,0 +1,154 @@ +/** + * ADB packet encoding and decoding. + * + * Each ADB message consists of a 24-byte header followed by an optional + * data payload. The header format (all little-endian uint32): + * + * [command][arg0][arg1][data_length][data_checksum][magic] + * + * Where magic = command ^ 0xFFFFFFFF. + */ + +import { ProtocolError } from "../types.js"; +import { ADB_HEADER_SIZE, type AdbPacket, type AdbCommand } from "./types.js"; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +/** + * Calculate the ADB checksum over a payload. + * This is a simple unsigned sum of all bytes, masked to 32 bits. + */ +export function calculateChecksum(data: Uint8Array): number { + let sum = 0; + for (let i = 0; i < data.byteLength; i++) { + sum = (sum + data[i]) >>> 0; + } + return sum; +} + +/** + * Encode an ADB packet header (24 bytes). + * + * IMPORTANT: ADB protocol requires the header and payload to be sent as + * separate USB bulk transfers. The device reads them with distinct read() + * calls on the USB endpoint. Use {@link writePacket} to send correctly. + */ +export function encodeHeader( + command: AdbCommand, + arg0: number, + arg1: number, + dataLength: number, + dataChecksum: number, +): Uint8Array { + const magic = (command ^ 0xffffffff) >>> 0; + const buf = new ArrayBuffer(ADB_HEADER_SIZE); + const view = new DataView(buf); + view.setUint32(0, command, true); + view.setUint32(4, arg0, true); + view.setUint32(8, arg1, true); + view.setUint32(12, dataLength, true); + view.setUint32(16, dataChecksum, true); + view.setUint32(20, magic, true); + return new Uint8Array(buf); +} + +/** + * Send an ADB packet over USB. + * + * Header and payload are written as **separate** USB bulk transfers, + * which is required by the ADB protocol (adbd reads them individually). + * + * @param send function that performs a single USB bulk OUT transfer + */ +export async function writePacket( + send: (data: Uint8Array) => Promise, + command: AdbCommand, + arg0: number, + arg1: number, + payload: Uint8Array | string = new Uint8Array(0), +): Promise { + const data = + typeof payload === "string" ? textEncoder.encode(payload) : payload; + const checksum = calculateChecksum(data); + const header = encodeHeader(command, arg0, arg1, data.byteLength, checksum); + + await send(header); + if (data.byteLength > 0) { + await send(data); + } +} + +/** + * Decode a 24-byte ADB packet header. + * Returns the parsed header fields. The payload should be read separately + * using the data_length field. + */ +export function decodeHeader( + data: Uint8Array, +): { command: AdbCommand; arg0: number; arg1: number; dataLength: number; checksum: number } { + if (data.byteLength < ADB_HEADER_SIZE) { + throw new ProtocolError( + `ADB header too short: ${data.byteLength} < ${ADB_HEADER_SIZE}`, + ); + } + + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const command = view.getUint32(0, true) as AdbCommand; + const magic = view.getUint32(20, true); + + // Validate magic + if (((command ^ magic) >>> 0) !== 0xffffffff) { + throw new ProtocolError( + `ADB packet magic mismatch: command=0x${command.toString(16)}, ` + + `magic=0x${magic.toString(16)}`, + ); + } + + return { + command, + arg0: view.getUint32(4, true), + arg1: view.getUint32(8, true), + dataLength: view.getUint32(12, true), + checksum: view.getUint32(16, true), + }; +} + +/** + * Read a complete ADB packet (header + payload) from a transport. + * Uses the provided receive function to get raw bytes. + */ +export async function readPacket( + receiveBytes: (length: number) => Promise, +): Promise { + // Read the 24-byte header + const headerBuf = await receiveBytes(ADB_HEADER_SIZE); + const header = decodeHeader(headerBuf); + + // Read payload if present + let payload: Uint8Array = new Uint8Array(0); + if (header.dataLength > 0) { + payload = await receiveBytes(header.dataLength) as Uint8Array; + } + + return { + command: header.command, + arg0: header.arg0, + arg1: header.arg1, + payload, + }; +} + +/** + * Encode a string payload to UTF-8 bytes. + */ +export function encodeUtf8(str: string): Uint8Array { + return textEncoder.encode(str); +} + +/** + * Decode UTF-8 bytes to a string. + */ +export function decodeUtf8(data: Uint8Array): string { + return textDecoder.decode(data); +} diff --git a/app/src/lib/adb/adb-sideload.ts b/app/src/lib/adb/adb-sideload.ts new file mode 100644 index 0000000..4fdbf0c --- /dev/null +++ b/app/src/lib/adb/adb-sideload.ts @@ -0,0 +1,108 @@ +/** + * ADB sideload protocol implementation. + * + * The sideload-host protocol transfers a file to a device in recovery mode. + * The device requests specific blocks by number, and the host sends each + * block's data in response. + * + * Protocol: + * 1. Open stream with service "sideload-host::" + * 2. Device sends WRTE with ASCII block number (or "DONEDONE") + * 3. Host sends OKAY, then WRTE with file data for that block + * 4. Repeat until "DONEDONE" + */ + +import { log } from "../types.js"; +import type { SideloadProgressCallback } from "../types.js"; +import { AdbCommand, SIDELOAD_MAX_PAYLOAD, type AdbPacket } from "./types.js"; +import { decodeUtf8 } from "./adb-packet.js"; +import { AdbStream } from "./adb-stream.js"; +import type { WebUsbTransport } from "../transport/webusb.js"; + +/** + * Perform an ADB sideload over an already-connected ADB transport. + * + * @param transport - The USB transport + * @param blob - The file to sideload + * @param receivePacket - Function to read ADB packets from the transport + * @param maxPayload - Maximum block size (default: 64KB) + * @param onProgress - Optional progress callback + */ +export async function sideload( + transport: WebUsbTransport, + blob: Blob, + receivePacket: () => Promise, + maxPayload: number = SIDELOAD_MAX_PAYLOAD, + onProgress?: SideloadProgressCallback, +): Promise { + const totalBlocks = Math.ceil(blob.size / maxPayload); + const service = `sideload-host:${blob.size}:${maxPayload}`; + + log(`Sideload: ${blob.size} bytes, ${totalBlocks} blocks, maxPayload=${maxPayload}`); + + // Open the sideload stream + const stream = await AdbStream.open(transport, service, receivePacket); + + // Send initial OKAY handshake + await stream.sendOkay(); + + try { + // Block request loop + // eslint-disable-next-line no-constant-condition + while (true) { + // Read block request from device (WRTE with ASCII block number) + const packet = await receivePacket(); + + if (packet.command === AdbCommand.Close) { + log("Sideload: device closed stream"); + break; + } + + if (packet.command !== AdbCommand.Write) { + log( + `Sideload: unexpected command 0x${packet.command.toString(16)}, skipping`, + ); + continue; + } + + const request = decodeUtf8(packet.payload).trim(); + + // Check for completion + if (request === "DONEDONE") { + log("Sideload: DONEDONE received, transfer complete"); + // Send final OKAY + await stream.sendOkay(); + break; + } + + // Parse block number + const blockNumber = parseInt(request, 10); + if (isNaN(blockNumber)) { + log(`Sideload: invalid block request "${request}", skipping`); + await stream.sendOkay(); + continue; + } + + // Calculate byte range for this block + const offset = blockNumber * maxPayload; + const end = Math.min(offset + maxPayload, blob.size); + + // Read the block from the blob + const slice = blob.slice(offset, end); + const blockData = new Uint8Array(await slice.arrayBuffer()); + + // Send OKAY to acknowledge the request + await stream.sendOkay(); + + // Send the block data via WRTE + await stream.write(blockData, receivePacket); + + // Report progress + onProgress?.(blockNumber + 1, totalBlocks); + } + } finally { + await stream.close(); + } + + log("Sideload complete"); +} diff --git a/app/src/lib/adb/adb-stream.ts b/app/src/lib/adb/adb-stream.ts new file mode 100644 index 0000000..2536300 --- /dev/null +++ b/app/src/lib/adb/adb-stream.ts @@ -0,0 +1,151 @@ +/** + * ADB stream management. + * + * An ADB stream is a logical bidirectional channel opened over the ADB + * connection for a specific service (e.g., "shell:ls", "sideload-host:..."). + * + * Stream lifecycle: + * 1. Host sends OPEN with local_id and service name + * 2. Device responds with OKAY (remote_id, local_id) on success + * 3. Data flows via WRTE/OKAY pairs + * 4. Either side sends CLSE to close + */ + +import { ProtocolError, log } from "../types.js"; +import { AdbCommand, type AdbPacket } from "./types.js"; +import { writePacket, encodeUtf8 } from "./adb-packet.js"; +import type { WebUsbTransport } from "../transport/webusb.js"; +import { DEFAULT_TIMEOUT_MS } from "../transport/types.js"; + +let nextLocalId = 1; + +export class AdbStream { + readonly localId: number; + remoteId: number; + private _transport: WebUsbTransport; + private _closed = false; + + constructor(transport: WebUsbTransport, localId: number, remoteId: number) { + this._transport = transport; + this.localId = localId; + this.remoteId = remoteId; + } + + /** + * Open a new stream for a given service. + * + * @param transport - The USB transport + * @param service - The ADB service string (e.g., "shell:ls", "sideload-host:1234:65536") + * @param receivePacket - Function to read a packet from the transport + * @returns A new AdbStream + */ + static async open( + transport: WebUsbTransport, + service: string, + receivePacket: () => Promise, + ): Promise { + const localId = nextLocalId++; + const sendFn = (data: Uint8Array) => + transport.sendWithTimeout(data, DEFAULT_TIMEOUT_MS); + + // Send OPEN (header + payload as separate USB transfers) + await writePacket( + sendFn, + AdbCommand.Open, + localId, + 0, + encodeUtf8(service + "\0"), + ); + + log(`Stream OPEN: localId=${localId}, service="${service}"`); + + // Read response — expect OKAY + const response = await receivePacket(); + + if (response.command === AdbCommand.Okay) { + const stream = new AdbStream(transport, localId, response.arg0); + log( + `Stream opened: localId=${localId}, remoteId=${stream.remoteId}`, + ); + return stream; + } + + if (response.command === AdbCommand.Close) { + throw new ProtocolError( + `ADB service "${service}" rejected (CLSE received)`, + ); + } + + throw new ProtocolError( + `Unexpected response to OPEN: command=0x${response.command.toString(16)}`, + ); + } + + /** + * Write data to the remote end. + * Sends WRTE and waits for OKAY acknowledgment. + */ + async write( + data: Uint8Array, + receivePacket: () => Promise, + ): Promise { + if (this._closed) { + throw new ProtocolError("Cannot write to closed stream"); + } + + await writePacket( + (d) => this._transport.sendWithTimeout(d, DEFAULT_TIMEOUT_MS), + AdbCommand.Write, + this.localId, + this.remoteId, + data, + ); + + // Wait for OKAY + const response = await receivePacket(); + if (response.command === AdbCommand.Close) { + this._closed = true; + throw new ProtocolError("Stream closed by device during write"); + } + if (response.command !== AdbCommand.Okay) { + throw new ProtocolError( + `Expected OKAY after WRTE, got 0x${response.command.toString(16)}`, + ); + } + } + + /** + * Send an OKAY acknowledgment to the device. + */ + async sendOkay(): Promise { + await writePacket( + (d) => this._transport.sendWithTimeout(d, DEFAULT_TIMEOUT_MS), + AdbCommand.Okay, + this.localId, + this.remoteId, + ); + } + + /** + * Close the stream. + */ + async close(): Promise { + if (this._closed) return; + + try { + await writePacket( + (d) => this._transport.sendWithTimeout(d, 5000), + AdbCommand.Close, + this.localId, + this.remoteId, + ); + } catch { + // Ignore errors when closing + } + this._closed = true; + } + + get isClosed(): boolean { + return this._closed; + } +} diff --git a/app/src/lib/adb/index.ts b/app/src/lib/adb/index.ts new file mode 100644 index 0000000..bdeac6f --- /dev/null +++ b/app/src/lib/adb/index.ts @@ -0,0 +1,13 @@ +export { AdbDevice } from "./adb-device.js"; +export { BrowserAdbCredentialStore, type AdbCredentialStore } from "./adb-auth.js"; +export { AdbStream } from "./adb-stream.js"; +export { AdbCommand, type AdbPacket, AdbAuthType } from "./types.js"; +export { + encodeHeader, + writePacket, + decodeHeader, + readPacket, + calculateChecksum, + encodeUtf8, + decodeUtf8, +} from "./adb-packet.js"; diff --git a/app/src/lib/adb/types.ts b/app/src/lib/adb/types.ts new file mode 100644 index 0000000..3722fc2 --- /dev/null +++ b/app/src/lib/adb/types.ts @@ -0,0 +1,58 @@ +/** + * ADB protocol type definitions. + */ + +/** + * ADB command codes (as little-endian uint32 from ASCII). + * The magic field of each packet is command ^ 0xFFFFFFFF. + */ +export enum AdbCommand { + /** Connection request/response */ + Connect = 0x4e584e43, // "CNXN" + /** Authentication challenge/response */ + Auth = 0x48545541, // "AUTH" + /** Open a new stream */ + Open = 0x4e45504f, // "OPEN" + /** Write data to stream */ + Write = 0x45545257, // "WRTE" + /** Close stream */ + Close = 0x45534c43, // "CLSE" + /** Acknowledge / ready for more data */ + Okay = 0x59414b4f, // "OKAY" +} + +/** ADB protocol version */ +export const ADB_VERSION = 0x01000001; + +/** Maximum data payload size (1 MB) */ +export const ADB_MAX_PAYLOAD = 0x100000; + +/** ADB auth types */ +export enum AdbAuthType { + /** Device sends a random token to sign */ + Token = 1, + /** Host sends back a signature */ + Signature = 2, + /** Host sends its RSA public key */ + RsaPublicKey = 3, +} + +/** ADB packet header size (24 bytes) */ +export const ADB_HEADER_SIZE = 24; + +/** Parsed ADB packet */ +export interface AdbPacket { + command: AdbCommand; + arg0: number; + arg1: number; + payload: Uint8Array; +} + +/** Default sideload block size (64 KB) */ +export const SIDELOAD_MAX_PAYLOAD = 0x10000; + +/** IndexedDB store name for ADB credentials */ +export const ADB_CREDENTIAL_STORE_NAME = "AdbCredentialStore"; + +/** IndexedDB database version */ +export const ADB_CREDENTIAL_DB_VERSION = 1; diff --git a/app/src/lib/fastboot/fastboot-device.ts b/app/src/lib/fastboot/fastboot-device.ts new file mode 100644 index 0000000..92d5041 --- /dev/null +++ b/app/src/lib/fastboot/fastboot-device.ts @@ -0,0 +1,279 @@ +/** + * High-level Fastboot device interface. + * + * Provides the public API for connecting to a device in bootloader/fastboot + * mode and performing operations like flashing, erasing, and rebooting. + * Handles sparse image detection and splitting transparently. + */ + +import { + type ProgressCallback, + UsbError, + log, +} from "../types.js"; +import { WebUsbTransport } from "../transport/webusb.js"; +import { FASTBOOT_USB_FILTER } from "../transport/types.js"; +import { + sendCommand, + downloadData, + flashPartition, + erasePartition, + getVariable as getVar, +} from "./fastboot-protocol.js"; +import { isSparseImage, splitSparseImage } from "./sparse-image.js"; +import { FASTBOOT_COMMAND_TIMEOUT_MS, FASTBOOT_FLASH_TIMEOUT_MS } from "./types.js"; + +export class FastbootDevice { + private _transport: WebUsbTransport; + private _connected = false; + private _maxDownloadSize: number | null = null; + + private constructor(transport: WebUsbTransport) { + this._transport = transport; + } + + // ---- Static Factory Methods ---- + + /** + * Prompt the user to select a fastboot device. + * Requires a user gesture (click/tap). + */ + static async requestDevice(): Promise { + const transport = await WebUsbTransport.requestDevice(FASTBOOT_USB_FILTER); + return new FastbootDevice(transport); + } + + /** + * Find an already-paired fastboot device without user gesture. + * Returns null if no paired fastboot device is found. + */ + static async findDevice(): Promise { + const transport = await WebUsbTransport.findDevice(FASTBOOT_USB_FILTER); + if (!transport) return null; + return new FastbootDevice(transport); + } + + // ---- Connection ---- + + /** + * Open the USB connection and verify the device speaks fastboot. + */ + async connect(): Promise { + if (this._connected) return; + + await this._transport.open(); + + // Verify fastboot protocol with a handshake + try { + const version = await getVar(this._transport, "version"); + log(`Fastboot connected, protocol version: ${version}`); + } catch { + // Some devices don't support getvar:version, that's okay + log("Fastboot connected (version query not supported)"); + } + + this._connected = true; + } + + /** + * Close the USB connection. + */ + async disconnect(): Promise { + if (!this._connected) return; + await this._transport.close(); + this._connected = false; + this._maxDownloadSize = null; + } + + // ---- Commands ---- + + /** + * Get a bootloader variable (e.g., "version", "product", "unlocked"). + */ + async getVariable(name: string): Promise { + this.ensureConnected(); + return getVar(this._transport, name); + } + + /** + * Run an arbitrary fastboot command and return the response message. + * Used for commands like "flashing unlock", "oem unlock", "flashing lock", etc. + */ + async runCommand(command: string): Promise { + this.ensureConnected(); + const result = await sendCommand(this._transport, command); + return result.message; + } + + /** + * Flash a blob to a partition. + * + * Automatically detects sparse images and splits them if they exceed + * the device's max-download-size. Reports progress via callback. + */ + async flashBlob( + partition: string, + blob: Blob, + onProgress?: ProgressCallback, + ): Promise { + this.ensureConnected(); + + // Read the first few bytes to check for sparse format + const headerBytes = new Uint8Array( + await blob.slice(0, 4).arrayBuffer(), + ); + + if (isSparseImage(headerBytes)) { + await this.flashSparseBlob(partition, blob, onProgress); + } else { + await this.flashRawBlob(partition, blob, onProgress); + } + } + + /** + * Erase a partition. + */ + async erase(partition: string): Promise { + this.ensureConnected(); + await erasePartition(this._transport, partition); + } + + /** + * Boot a blob without flashing (fastboot boot). + */ + async bootBlob(blob: Blob): Promise { + this.ensureConnected(); + const data = new Uint8Array(await blob.arrayBuffer()); + await downloadData(this._transport, data); + await sendCommand(this._transport, "boot"); + } + + /** + * Reboot the device. + * @param mode - "" for normal, "bootloader" for fastboot, "recovery", etc. + */ + async reboot(mode?: string): Promise { + this.ensureConnected(); + const command = mode ? `reboot-${mode}` : "reboot"; + try { + await sendCommand(this._transport, command, 5000); + } catch { + // Reboot often causes USB disconnect before response arrives + log(`Reboot command sent (${command}), device may have disconnected`); + } + this._connected = false; + } + + /** + * Reset the underlying USB device. + */ + async resetDevice(): Promise { + await this._transport.reset(); + } + + /** + * Close and re-open the USB connection for a fresh session. + */ + async reconnect(): Promise { + this._connected = false; + this._maxDownloadSize = null; + await this._transport.reconnect(); + this._connected = true; + } + + // ---- Getters ---- + + get isConnected(): boolean { + return this._connected; + } + + get usbDevice(): USBDevice { + return this._transport.device; + } + + // ---- Private Helpers ---- + + private ensureConnected(): void { + if (!this._connected) { + throw new UsbError("Fastboot device not connected"); + } + } + + /** + * Get and cache the device's max-download-size. + * Falls back to 512 MB if the variable is not available. + */ + private async getMaxDownloadSize(): Promise { + if (this._maxDownloadSize !== null) return this._maxDownloadSize; + + try { + const value = await getVar(this._transport, "max-download-size"); + this._maxDownloadSize = parseInt(value, 16) || parseInt(value, 10); + if (isNaN(this._maxDownloadSize) || this._maxDownloadSize <= 0) { + this._maxDownloadSize = 512 * 1024 * 1024; + } + } catch { + // Default to 512 MB + this._maxDownloadSize = 512 * 1024 * 1024; + } + + log(`Max download size: ${this._maxDownloadSize} bytes`); + return this._maxDownloadSize; + } + + /** + * Flash a raw (non-sparse) blob: download + flash. + */ + private async flashRawBlob( + partition: string, + blob: Blob, + onProgress?: ProgressCallback, + ): Promise { + const data = new Uint8Array(await blob.arrayBuffer()); + await downloadData(this._transport, data, onProgress, FASTBOOT_FLASH_TIMEOUT_MS); + await flashPartition(this._transport, partition, FASTBOOT_FLASH_TIMEOUT_MS); + } + + /** + * Flash a sparse blob: split if needed, then download + flash each sub-image. + */ + private async flashSparseBlob( + partition: string, + blob: Blob, + onProgress?: ProgressCallback, + ): Promise { + const maxSize = await this.getMaxDownloadSize(); + const subImages = await splitSparseImage(blob, maxSize); + + log( + `Flashing sparse image to ${partition}: ` + + `${subImages.length} sub-image(s), total ${blob.size} bytes`, + ); + + const totalSize = subImages.reduce((sum, img) => sum + img.size, 0); + let sentSoFar = 0; + + for (let i = 0; i < subImages.length; i++) { + const subImage = subImages[i]; + const data = new Uint8Array(await subImage.arrayBuffer()); + const subImageSize = data.byteLength; + + await downloadData( + this._transport, + data, + (sent) => { + onProgress?.(sentSoFar + sent, totalSize); + }, + FASTBOOT_FLASH_TIMEOUT_MS, + ); + + await flashPartition(this._transport, partition, FASTBOOT_FLASH_TIMEOUT_MS); + sentSoFar += subImageSize; + + log( + `Sparse sub-image ${i + 1}/${subImages.length} flashed ` + + `(${subImageSize} bytes)`, + ); + } + } +} diff --git a/app/src/lib/fastboot/fastboot-protocol.ts b/app/src/lib/fastboot/fastboot-protocol.ts new file mode 100644 index 0000000..654c75a --- /dev/null +++ b/app/src/lib/fastboot/fastboot-protocol.ts @@ -0,0 +1,204 @@ +/** + * Low-level fastboot protocol implementation. + * + * Handles sending commands, reading responses, and transferring data + * over the WebUSB transport layer using the fastboot protocol. + * + * Protocol: + * - Commands: ASCII string sent via bulk OUT (max 4096 bytes) + * - Responses: 4-byte status prefix (OKAY/FAIL/DATA/INFO) + message via bulk IN + * - Data transfer: download command → DATA response → raw bytes → OKAY + */ + +import { ProtocolError, type ProgressCallback, log } from "../types.js"; +import type { WebUsbTransport } from "../transport/webusb.js"; +import { + FastbootResponse, + type FastbootResult, + FASTBOOT_COMMAND_TIMEOUT_MS, +} from "./types.js"; + +const RESPONSE_PREFIX_LEN = 4; +const MAX_RESPONSE_SIZE = 4096; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +/** + * Send a fastboot command string to the device. + */ +export async function sendCommand( + transport: WebUsbTransport, + command: string, + timeoutMs: number = FASTBOOT_COMMAND_TIMEOUT_MS, +): Promise { + log(`fastboot > ${command}`); + + const encoded = textEncoder.encode(command); + await transport.sendWithTimeout(encoded, timeoutMs); + + return readResponse(transport, timeoutMs); +} + +/** + * Read a fastboot response, consuming any INFO messages along the way. + * Returns the final OKAY, FAIL, or DATA response. + */ +export async function readResponse( + transport: WebUsbTransport, + timeoutMs: number = FASTBOOT_COMMAND_TIMEOUT_MS, +): Promise { + // eslint-disable-next-line no-constant-condition + while (true) { + const data = await transport.readTransferWithTimeout( + MAX_RESPONSE_SIZE, + timeoutMs, + ); + + const result = parseResponse(data); + log(`fastboot < ${result.status} ${result.message}`); + + // INFO responses are intermediate status messages — log and continue + if (result.status === FastbootResponse.Info) { + continue; + } + + // FAIL responses become a ProtocolError + if (result.status === FastbootResponse.Fail) { + throw new ProtocolError(`Fastboot command failed: ${result.message}`, { + bootloaderMessage: result.message, + }); + } + + return result; + } +} + +/** + * Send raw data to the device in chunks with progress reporting. + * Used after receiving a DATA response to a download command. + */ +export async function sendData( + transport: WebUsbTransport, + data: Uint8Array, + onProgress?: ProgressCallback, + chunkSize: number = 512 * 1024, + timeoutMs: number = FASTBOOT_COMMAND_TIMEOUT_MS, +): Promise { + const total = data.byteLength; + let offset = 0; + + while (offset < total) { + const end = Math.min(offset + chunkSize, total); + const chunk = data.subarray(offset, end); + await transport.sendWithTimeout(chunk, timeoutMs); + offset = end; + onProgress?.(offset, total); + } +} + +/** + * Download a blob to the device memory (download command + data transfer). + * This does NOT flash — call flashPartition() after downloading. + */ +export async function downloadData( + transport: WebUsbTransport, + data: Uint8Array, + onProgress?: ProgressCallback, + timeoutMs: number = FASTBOOT_COMMAND_TIMEOUT_MS, +): Promise { + const sizeHex = data.byteLength.toString(16).padStart(8, "0"); + const command = `download:${sizeHex}`; + + log(`fastboot > ${command} (${data.byteLength} bytes)`); + const encoded = textEncoder.encode(command); + await transport.sendWithTimeout(encoded, timeoutMs); + + // Expect DATA response with the size + const response = await readResponse(transport, timeoutMs); + if (response.status !== FastbootResponse.Data) { + throw new ProtocolError( + `Expected DATA response for download, got ${response.status}: ${response.message}`, + ); + } + + // Send the raw data + await sendData(transport, data, onProgress, 512 * 1024, timeoutMs); + + // Read final OKAY + await readResponse(transport, timeoutMs); +} + +/** + * Flash the previously downloaded data to a partition. + */ +export async function flashPartition( + transport: WebUsbTransport, + partition: string, + timeoutMs: number = FASTBOOT_COMMAND_TIMEOUT_MS, +): Promise { + await sendCommand(transport, `flash:${partition}`, timeoutMs); +} + +/** + * Erase a partition. + */ +export async function erasePartition( + transport: WebUsbTransport, + partition: string, + timeoutMs: number = FASTBOOT_COMMAND_TIMEOUT_MS, +): Promise { + await sendCommand(transport, `erase:${partition}`, timeoutMs); +} + +/** + * Get a bootloader variable value. + */ +export async function getVariable( + transport: WebUsbTransport, + name: string, + timeoutMs: number = FASTBOOT_COMMAND_TIMEOUT_MS, +): Promise { + const result = await sendCommand( + transport, + `getvar:${name}`, + timeoutMs, + ); + return result.message; +} + +// ---- Internal Helpers ---- + +/** + * Parse a raw fastboot response buffer into a structured result. + */ +function parseResponse(data: Uint8Array): FastbootResult { + if (data.byteLength < RESPONSE_PREFIX_LEN) { + throw new ProtocolError( + `Fastboot response too short: ${data.byteLength} bytes`, + ); + } + + const prefix = textDecoder.decode(data.subarray(0, RESPONSE_PREFIX_LEN)); + const message = textDecoder.decode(data.subarray(RESPONSE_PREFIX_LEN)).trim(); + + switch (prefix) { + case FastbootResponse.Okay: + return { status: FastbootResponse.Okay, message }; + + case FastbootResponse.Fail: + return { status: FastbootResponse.Fail, message }; + + case FastbootResponse.Info: + return { status: FastbootResponse.Info, message }; + + case FastbootResponse.Data: { + // DATA response: the message is a hex string representing the data size + const dataSize = parseInt(message, 16); + return { status: FastbootResponse.Data, message, dataSize }; + } + + default: + throw new ProtocolError(`Unknown fastboot response prefix: "${prefix}"`); + } +} diff --git a/app/src/lib/fastboot/index.ts b/app/src/lib/fastboot/index.ts new file mode 100644 index 0000000..0af9c29 --- /dev/null +++ b/app/src/lib/fastboot/index.ts @@ -0,0 +1,9 @@ +export { FastbootDevice } from "./fastboot-device.js"; +export { isSparseImage, splitSparseImage } from "./sparse-image.js"; +export { + FastbootResponse, + type FastbootResult, + type SparseHeader, + FASTBOOT_COMMAND_TIMEOUT_MS, + FASTBOOT_FLASH_TIMEOUT_MS, +} from "./types.js"; diff --git a/app/src/lib/fastboot/sparse-image.ts b/app/src/lib/fastboot/sparse-image.ts new file mode 100644 index 0000000..313efb5 --- /dev/null +++ b/app/src/lib/fastboot/sparse-image.ts @@ -0,0 +1,235 @@ +/** + * Android sparse image format handling. + * + * Sparse images compress large partition images by omitting empty/dont-care + * regions. This module detects, parses, and splits sparse images for devices + * with limited download buffer sizes. + * + * Format: + * - 28-byte file header (magic, version, block/chunk counts) + * - Sequence of chunks, each with a 12-byte header + optional data + * - Chunk types: RAW (0xCAC1), FILL (0xCAC2), DONT_CARE (0xCAC3), CRC32 (0xCAC4) + */ + +import { + SPARSE_MAGIC, + SPARSE_HEADER_SIZE, + SPARSE_CHUNK_HEADER_SIZE, + SparseChunkType, + type SparseHeader, + type SparseChunkHeader, +} from "./types.js"; + +/** + * Check if a buffer starts with the sparse image magic number. + */ +export function isSparseImage(header: Uint8Array): boolean { + if (header.byteLength < 4) return false; + const view = new DataView( + header.buffer, + header.byteOffset, + header.byteLength, + ); + return view.getUint32(0, true) === SPARSE_MAGIC; +} + +/** + * Parse the 28-byte sparse image file header. + */ +export function parseSparseHeader(data: Uint8Array): SparseHeader { + if (data.byteLength < SPARSE_HEADER_SIZE) { + throw new Error( + `Sparse header too short: ${data.byteLength} < ${SPARSE_HEADER_SIZE}`, + ); + } + + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const magic = view.getUint32(0, true); + + if (magic !== SPARSE_MAGIC) { + throw new Error( + `Not a sparse image: magic=0x${magic.toString(16)}, expected 0x${SPARSE_MAGIC.toString(16)}`, + ); + } + + return { + magic, + majorVersion: view.getUint16(4, true), + minorVersion: view.getUint16(6, true), + fileHeaderSize: view.getUint16(8, true), + chunkHeaderSize: view.getUint16(10, true), + blockSize: view.getUint32(12, true), + totalBlocks: view.getUint32(16, true), + totalChunks: view.getUint32(20, true), + imageChecksum: view.getUint32(24, true), + }; +} + +/** + * Parse a 12-byte sparse chunk header. + */ +export function parseChunkHeader(data: Uint8Array): SparseChunkHeader { + if (data.byteLength < SPARSE_CHUNK_HEADER_SIZE) { + throw new Error( + `Chunk header too short: ${data.byteLength} < ${SPARSE_CHUNK_HEADER_SIZE}`, + ); + } + + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + + return { + type: view.getUint16(0, true) as SparseChunkType, + chunkBlocks: view.getUint32(4, true), + totalSize: view.getUint32(8, true), + }; +} + +/** + * Calculate the data size following a chunk header based on chunk type. + */ +function chunkDataSize(chunk: SparseChunkHeader, blockSize: number): number { + switch (chunk.type) { + case SparseChunkType.Raw: + return chunk.chunkBlocks * blockSize; + case SparseChunkType.Fill: + return 4; // 4-byte fill pattern + case SparseChunkType.DontCare: + return 0; + case SparseChunkType.Crc32: + return 4; // 4-byte CRC32 + default: + throw new Error(`Unknown sparse chunk type: 0x${(chunk.type as number).toString(16)}`); + } +} + +/** + * Build a sparse image file header from parameters. + */ +function buildSparseHeader( + blockSize: number, + totalBlocks: number, + totalChunks: number, +): Uint8Array { + const buf = new ArrayBuffer(SPARSE_HEADER_SIZE); + const view = new DataView(buf); + + view.setUint32(0, SPARSE_MAGIC, true); + view.setUint16(4, 1, true); // major version + view.setUint16(6, 0, true); // minor version + view.setUint16(8, SPARSE_HEADER_SIZE, true); // file header size + view.setUint16(10, SPARSE_CHUNK_HEADER_SIZE, true); // chunk header size + view.setUint32(12, blockSize, true); + view.setUint32(16, totalBlocks, true); + view.setUint32(20, totalChunks, true); + view.setUint32(24, 0, true); // checksum (unused) + + return new Uint8Array(buf); +} + +/** + * Split a sparse image into multiple sub-images, each smaller than maxSize. + * + * This is needed when the device's max-download-size is smaller than the + * sparse image. Each sub-image is a valid sparse image that can be downloaded + * and flashed independently (the device will write each to the correct offsets). + */ +export async function splitSparseImage( + blob: Blob, + maxSize: number, +): Promise { + const headerBuf = new Uint8Array(await blob.slice(0, SPARSE_HEADER_SIZE).arrayBuffer()); + const header = parseSparseHeader(headerBuf); + + // Parse all chunks to build an index + interface ChunkEntry { + header: SparseChunkHeader; + offset: number; // byte offset in the original blob (including chunk header) + blocks: number; + } + + const chunks: ChunkEntry[] = []; + let offset = header.fileHeaderSize; + + for (let i = 0; i < header.totalChunks; i++) { + const chunkHeaderBuf = new Uint8Array( + await blob.slice(offset, offset + SPARSE_CHUNK_HEADER_SIZE).arrayBuffer(), + ); + const chunkHeader = parseChunkHeader(chunkHeaderBuf); + const dataSize = chunkDataSize(chunkHeader, header.blockSize); + + chunks.push({ + header: chunkHeader, + offset, + blocks: chunkHeader.chunkBlocks, + }); + + offset += SPARSE_CHUNK_HEADER_SIZE + dataSize; + } + + // If the whole image fits, return it as-is + if (blob.size <= maxSize) { + return [blob]; + } + + // Group chunks into sub-images that fit within maxSize + const subImages: Blob[] = []; + let currentChunks: ChunkEntry[] = []; + let currentSize = SPARSE_HEADER_SIZE; + let currentBlocks = 0; + + for (const chunk of chunks) { + const dataSize = chunkDataSize(chunk.header, header.blockSize); + const chunkTotalSize = SPARSE_CHUNK_HEADER_SIZE + dataSize; + + // If adding this chunk would exceed maxSize, finalize current sub-image + if ( + currentChunks.length > 0 && + currentSize + chunkTotalSize > maxSize + ) { + subImages.push( + buildSubImage(blob, header.blockSize, currentBlocks, currentChunks), + ); + currentChunks = []; + currentSize = SPARSE_HEADER_SIZE; + currentBlocks = 0; + } + + currentChunks.push(chunk); + currentSize += chunkTotalSize; + currentBlocks += chunk.blocks; + } + + // Finalize the last sub-image + if (currentChunks.length > 0) { + subImages.push( + buildSubImage(blob, header.blockSize, currentBlocks, currentChunks), + ); + } + + return subImages; +} + +/** + * Build a sub-image Blob from a subset of chunks. + */ +function buildSubImage( + originalBlob: Blob, + blockSize: number, + totalBlocks: number, + chunks: Array<{ + header: SparseChunkHeader; + offset: number; + }>, +): Blob { + const newHeader = buildSparseHeader(blockSize, totalBlocks, chunks.length); + const parts: BlobPart[] = [newHeader as BlobPart]; + + for (const chunk of chunks) { + const dataSize = chunkDataSize(chunk.header, blockSize); + const chunkTotalSize = SPARSE_CHUNK_HEADER_SIZE + dataSize; + // Slice the original chunk (header + data) from the source blob + parts.push(originalBlob.slice(chunk.offset, chunk.offset + chunkTotalSize)); + } + + return new Blob(parts); +} diff --git a/app/src/lib/fastboot/types.ts b/app/src/lib/fastboot/types.ts new file mode 100644 index 0000000..13fb613 --- /dev/null +++ b/app/src/lib/fastboot/types.ts @@ -0,0 +1,65 @@ +/** + * Fastboot protocol type definitions. + */ + +/** Fastboot response status prefixes (4 ASCII bytes) */ +export enum FastbootResponse { + Okay = "OKAY", + Fail = "FAIL", + Data = "DATA", + Info = "INFO", +} + +/** Parsed response from a fastboot command */ +export interface FastbootResult { + status: FastbootResponse; + message: string; + /** Present when status === Data — the expected data size */ + dataSize?: number; +} + +// ---- Sparse Image Structures ---- + +/** Sparse image magic number: 0xED26FF3A */ +export const SPARSE_MAGIC = 0xed26ff3a; + +/** Sparse image file header (28 bytes) */ +export interface SparseHeader { + magic: number; + majorVersion: number; + minorVersion: number; + fileHeaderSize: number; + chunkHeaderSize: number; + blockSize: number; + totalBlocks: number; + totalChunks: number; + imageChecksum: number; +} + +/** Size of the sparse file header in bytes */ +export const SPARSE_HEADER_SIZE = 28; + +/** Size of a sparse chunk header in bytes */ +export const SPARSE_CHUNK_HEADER_SIZE = 12; + +/** Sparse chunk types */ +export enum SparseChunkType { + Raw = 0xcac1, + Fill = 0xcac2, + DontCare = 0xcac3, + Crc32 = 0xcac4, +} + +/** Parsed sparse chunk header */ +export interface SparseChunkHeader { + type: SparseChunkType; + chunkBlocks: number; + /** Total size in bytes of this chunk (header + data) */ + totalSize: number; +} + +/** Default fastboot command timeout (30 seconds) */ +export const FASTBOOT_COMMAND_TIMEOUT_MS = 30_000; + +/** Extended timeout for flash operations (5 minutes) */ +export const FASTBOOT_FLASH_TIMEOUT_MS = 300_000; diff --git a/app/src/lib/index.ts b/app/src/lib/index.ts new file mode 100644 index 0000000..29dd82d --- /dev/null +++ b/app/src/lib/index.ts @@ -0,0 +1,26 @@ +// Transport +export { WebUsbTransport } from "./transport/webusb.js"; + +// Fastboot +export { FastbootDevice } from "./fastboot/fastboot-device.js"; +export { isSparseImage, splitSparseImage } from "./fastboot/sparse-image.js"; + +// ADB +export { AdbDevice } from "./adb/adb-device.js"; +export { BrowserAdbCredentialStore } from "./adb/adb-auth.js"; + +// Types & Errors +export { + DeviceError, + TimeoutError, + ProtocolError, + UsbError, + DeviceMode, + LogLevel, + setLogLevel, +} from "./types.js"; +export type { + ProgressCallback, + SideloadProgressCallback, + DeviceBanner, +} from "./types.js"; diff --git a/app/src/lib/transport/types.ts b/app/src/lib/transport/types.ts new file mode 100644 index 0000000..8b294a4 --- /dev/null +++ b/app/src/lib/transport/types.ts @@ -0,0 +1,29 @@ +/** + * USB filter constants and transport type definitions. + */ + +/** ADB interface: vendor class 0xFF, subclass 0x42, protocol 0x01 */ +export const ADB_USB_FILTER: USBDeviceFilter = { + classCode: 0xff, + subclassCode: 0x42, + protocolCode: 0x01, +}; + +/** Fastboot interface: vendor class 0xFF, subclass 0x42, protocol 0x03 */ +export const FASTBOOT_USB_FILTER: USBDeviceFilter = { + classCode: 0xff, + subclassCode: 0x42, + protocolCode: 0x03, +}; + +/** Default timeout for USB operations (30 seconds) */ +export const DEFAULT_TIMEOUT_MS = 30_000; + +/** Maximum USB bulk transfer size (16 MB) */ +export const MAX_TRANSFER_SIZE = 16 * 1024 * 1024; + +export interface EndpointInfo { + inEndpoint: number; + outEndpoint: number; + interfaceNumber: number; +} diff --git a/app/src/lib/transport/webusb.ts b/app/src/lib/transport/webusb.ts new file mode 100644 index 0000000..04b53d5 --- /dev/null +++ b/app/src/lib/transport/webusb.ts @@ -0,0 +1,481 @@ +/** + * WebUSB transport layer. + * + * Handles device discovery, USB interface claiming, and raw bulk transfers. + * Both ADB and Fastboot protocols build on top of this transport. + */ + +import { TimeoutError, UsbError, log } from "../types.js"; +import { + DEFAULT_TIMEOUT_MS, + type EndpointInfo, +} from "./types.js"; + +/** + * Size of the internal buffer used for USB transferIn calls. + * Must be large enough to hold any single USB transfer the device might send. + * ADB and Fastboot both send packets well under 16 KB. + */ +const USB_RECEIVE_BUFFER_SIZE = 16384; + +export class WebUsbTransport { + private _device: USBDevice; + private _inEndpoint = 0; + private _outEndpoint = 0; + private _interfaceNumber = 0; + private _opened = false; + private _filter: USBDeviceFilter; + + /** Internal buffer for excess bytes received from USB transfers. */ + private _rxBuf: Uint8Array = new Uint8Array(0); + + private constructor(device: USBDevice, filter: USBDeviceFilter) { + this._device = device; + this._filter = filter; + } + + // ---- Static Factory Methods ---- + + /** + * Prompt the user to select a USB device matching the given filter. + * Requires a user gesture (click/tap) in the browser. + */ + static async requestDevice( + filter: USBDeviceFilter, + ): Promise { + try { + const device = await navigator.usb.requestDevice({ + filters: [filter], + }); + return new WebUsbTransport(device, filter); + } catch (e) { + throw new UsbError( + `Failed to request USB device: ${(e as Error).message || e}`, + e, + ); + } + } + + /** + * Find an already-paired USB device matching the filter. + * Does not require a user gesture. + */ + static async findDevice( + filter: USBDeviceFilter, + ): Promise { + const devices = await navigator.usb.getDevices(); + for (const device of devices) { + if (WebUsbTransport.matchesFilter(device, filter)) { + return new WebUsbTransport(device, filter); + } + } + return null; + } + + /** + * Get all paired USB devices matching the filter. + */ + static async getDevices(filter: USBDeviceFilter): Promise { + const devices = await navigator.usb.getDevices(); + return devices.filter((d) => WebUsbTransport.matchesFilter(d, filter)); + } + + // ---- Connection Management ---- + + /** + * Open the device, select configuration, claim interface, and find endpoints. + */ + async open(): Promise { + if (this._opened) return; + + try { + await this._device.open(); + + // Select configuration (usually configuration 1) + if (this._device.configuration === null) { + await this._device.selectConfiguration(1); + } + + // Find the matching interface and endpoints + const endpoints = this.findEndpoints(); + this._inEndpoint = endpoints.inEndpoint; + this._outEndpoint = endpoints.outEndpoint; + this._interfaceNumber = endpoints.interfaceNumber; + + // Claim the interface + await this._device.claimInterface(this._interfaceNumber); + + // Clear any stale halt condition on both endpoints. + // Previous sessions that were interrupted (tab closed, USB unplugged) + // can leave endpoints in a HALTED state, causing every subsequent + // transferIn/transferOut to fail with "A transfer error has occurred". + try { + await this._device.clearHalt("in", this._inEndpoint); + } catch { + // clearHalt may fail if endpoint isn't halted — that's fine + } + try { + await this._device.clearHalt("out", this._outEndpoint); + } catch { + // clearHalt may fail if endpoint isn't halted — that's fine + } + + this._rxBuf = new Uint8Array(0); + this._opened = true; + log( + `Transport opened: ${this.productName} ` + + `(in=${this._inEndpoint}, out=${this._outEndpoint}, ` + + `iface=${this._interfaceNumber})`, + ); + } catch (e) { + throw new UsbError( + `Failed to open USB device: ${(e as Error).message || e}`, + e, + ); + } + } + + /** + * Release the interface and close the device. + */ + async close(): Promise { + if (!this._opened) return; + + try { + await this._device.releaseInterface(this._interfaceNumber); + await this._device.close(); + } catch (e) { + log(`Close warning: ${(e as Error).message || e}`); + } finally { + this._opened = false; + this._rxBuf = new Uint8Array(0); + } + } + + /** + * Reset the USB device. May help recover from stale state. + */ + async reset(): Promise { + try { + await this._device.reset(); + log("USB device reset"); + } catch (e) { + throw new UsbError( + `USB device reset failed: ${(e as Error).message || e}`, + e, + ); + } + } + + /** + * Close and re-open the USB connection for a fresh session. + * Useful for recovering from degraded USB state (e.g., after flash timeouts). + */ + async reconnect(settleMs = 2000): Promise { + log("Reconnecting USB session..."); + await this.close(); + + // Wait for USB bus to stabilize + await new Promise((resolve) => setTimeout(resolve, settleMs)); + + // Re-open the connection + await this.open(); + log("USB session reconnected"); + } + + /** + * Discard any buffered receive data. Call after a mode switch or error + * recovery to avoid reading stale bytes. + */ + flushReceiveBuffer(): void { + this._rxBuf = new Uint8Array(0); + } + + // ---- Data Transfer ---- + + /** + * Send raw bytes to the device via bulk OUT transfer. + */ + async send(data: Uint8Array): Promise { + if (!this._opened) { + throw new UsbError("Transport not open"); + } + + const result = await this._device.transferOut(this._outEndpoint, data as BufferSource); + if (result.status !== "ok") { + throw new UsbError(`USB transferOut failed: status=${result.status}`); + } + } + + /** + * Receive exactly `length` bytes from the device. + * + * Uses an internal buffer so that USB transfers can be read with a large + * buffer (preventing overflow errors when the device sends more bytes than + * requested) and excess bytes are kept for subsequent reads. + * + * Use this for protocols that frame messages with known lengths (ADB). + */ + async receive(length: number): Promise { + if (!this._opened) { + throw new UsbError("Transport not open"); + } + + // Accumulate data until we have enough + while (this._rxBuf.byteLength < length) { + const fresh = await this.doTransferIn(); + if (fresh.byteLength === 0) { + throw new UsbError("USB transferIn returned empty data"); + } + const combined = new Uint8Array(this._rxBuf.byteLength + fresh.byteLength); + combined.set(this._rxBuf, 0); + combined.set(fresh, this._rxBuf.byteLength); + this._rxBuf = combined; + } + + // Return exactly the requested bytes, keep the rest buffered + const result = this._rxBuf.slice(0, length); + this._rxBuf = this._rxBuf.slice(length); + return result; + } + + /** + * Read a single USB transfer (up to `maxLength` bytes). + * + * Does NOT wait until `maxLength` bytes arrive — returns whatever the + * device sent in one transfer. Use this for protocols with + * variable-length, single-transfer responses (Fastboot). + */ + async readTransfer(maxLength: number = USB_RECEIVE_BUFFER_SIZE): Promise { + if (!this._opened) { + throw new UsbError("Transport not open"); + } + + // Drain any leftover buffered data first + if (this._rxBuf.byteLength > 0) { + const take = Math.min(maxLength, this._rxBuf.byteLength); + const result = this._rxBuf.slice(0, take); + this._rxBuf = this._rxBuf.slice(take); + return result; + } + + return this.doTransferIn(maxLength); + } + + /** + * Receive exactly `length` bytes with a timeout. + * Throws TimeoutError if the data does not arrive in time. + */ + async receiveWithTimeout( + length: number, + timeoutMs: number = DEFAULT_TIMEOUT_MS, + ): Promise { + return Promise.race([ + this.receive(length), + new Promise((_, reject) => { + setTimeout( + () => + reject( + new TimeoutError( + `USB receive timed out after ${timeoutMs}ms`, + timeoutMs, + ), + ), + timeoutMs, + ); + }), + ]); + } + + /** + * Read a single USB transfer with a timeout. + */ + async readTransferWithTimeout( + maxLength: number = USB_RECEIVE_BUFFER_SIZE, + timeoutMs: number = DEFAULT_TIMEOUT_MS, + ): Promise { + return Promise.race([ + this.readTransfer(maxLength), + new Promise((_, reject) => { + setTimeout( + () => + reject( + new TimeoutError( + `USB receive timed out after ${timeoutMs}ms`, + timeoutMs, + ), + ), + timeoutMs, + ); + }), + ]); + } + + /** + * Send bytes with a timeout. + */ + async sendWithTimeout( + data: Uint8Array, + timeoutMs: number = DEFAULT_TIMEOUT_MS, + ): Promise { + return Promise.race([ + this.send(data), + new Promise((_, reject) => { + setTimeout( + () => + reject( + new TimeoutError( + `USB send timed out after ${timeoutMs}ms`, + timeoutMs, + ), + ), + timeoutMs, + ); + }), + ]); + } + + // ---- Getters ---- + + get device(): USBDevice { + return this._device; + } + + get isOpen(): boolean { + return this._opened; + } + + get productName(): string { + return this._device.productName ?? ""; + } + + get serialNumber(): string { + return this._device.serialNumber ?? ""; + } + + // ---- Private Helpers ---- + + /** + * Perform a single USB bulk IN transfer with a large buffer. + */ + private async doTransferIn( + bufferSize: number = USB_RECEIVE_BUFFER_SIZE, + ): Promise { + const result = await this._device.transferIn(this._inEndpoint, bufferSize); + if (result.status !== "ok") { + throw new UsbError(`USB transferIn failed: status=${result.status}`); + } + if (!result.data || result.data.byteLength === 0) { + return new Uint8Array(0); + } + return new Uint8Array( + result.data.buffer, + result.data.byteOffset, + result.data.byteLength, + ); + } + + /** + * Find IN and OUT bulk endpoints matching the configured USB filter. + */ + private findEndpoints(): EndpointInfo { + const config = this._device.configuration; + if (!config) { + throw new UsbError("No USB configuration selected"); + } + + for (const iface of config.interfaces) { + for (const alt of iface.alternates) { + // Match the filter criteria + const classMatch = + this._filter.classCode === undefined || + alt.interfaceClass === this._filter.classCode; + const subclassMatch = + this._filter.subclassCode === undefined || + alt.interfaceSubclass === this._filter.subclassCode; + const protocolMatch = + this._filter.protocolCode === undefined || + alt.interfaceProtocol === this._filter.protocolCode; + + if (classMatch && subclassMatch && protocolMatch) { + let inEndpoint = -1; + let outEndpoint = -1; + + for (const ep of alt.endpoints) { + if (ep.type !== "bulk") continue; + if (ep.direction === "in") { + inEndpoint = ep.endpointNumber; + } else if (ep.direction === "out") { + outEndpoint = ep.endpointNumber; + } + } + + if (inEndpoint >= 0 && outEndpoint >= 0) { + return { + inEndpoint, + outEndpoint, + interfaceNumber: iface.interfaceNumber, + }; + } + } + } + } + + throw new UsbError( + `No matching USB interface found for filter ` + + `(class=0x${this._filter.classCode?.toString(16)}, ` + + `subclass=0x${this._filter.subclassCode?.toString(16)}, ` + + `protocol=0x${this._filter.protocolCode?.toString(16)})`, + ); + } + + /** + * Check if a USB device has at least one interface matching the filter. + */ + private static matchesFilter( + device: USBDevice, + filter: USBDeviceFilter, + ): boolean { + // Check vendor/product ID filters + if (filter.vendorId !== undefined && device.vendorId !== filter.vendorId) { + return false; + } + if ( + filter.productId !== undefined && + device.productId !== filter.productId + ) { + return false; + } + + // Check interface class filters + if ( + filter.classCode !== undefined || + filter.subclassCode !== undefined || + filter.protocolCode !== undefined + ) { + const config = device.configuration; + if (!config) return false; + + for (const iface of config.interfaces) { + for (const alt of iface.alternates) { + const classMatch = + filter.classCode === undefined || + alt.interfaceClass === filter.classCode; + const subclassMatch = + filter.subclassCode === undefined || + alt.interfaceSubclass === filter.subclassCode; + const protocolMatch = + filter.protocolCode === undefined || + alt.interfaceProtocol === filter.protocolCode; + + if (classMatch && subclassMatch && protocolMatch) { + return true; + } + } + } + return false; + } + + return true; + } +} diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts new file mode 100644 index 0000000..7c180ef --- /dev/null +++ b/app/src/lib/types.ts @@ -0,0 +1,104 @@ +/** + * Shared types, error classes, and enums for the WebUSB device library. + */ + +// ---- Error Types ---- + +export class DeviceError extends Error { + public readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = "DeviceError"; + this.cause = cause; + } +} + +export class TimeoutError extends DeviceError { + public readonly timeoutMs: number; + + constructor(message: string, timeoutMs: number) { + super(message); + this.name = "TimeoutError"; + this.timeoutMs = timeoutMs; + } +} + +export class ProtocolError extends DeviceError { + /** For fastboot FAIL responses or bootloader-specific errors */ + public readonly bootloaderMessage?: string; + + constructor( + message: string, + options?: { bootloaderMessage?: string; cause?: unknown }, + ) { + super(message, options?.cause); + this.name = "ProtocolError"; + this.bootloaderMessage = options?.bootloaderMessage; + } +} + +export class UsbError extends DeviceError { + constructor(message: string, cause?: unknown) { + super(message, cause); + this.name = "UsbError"; + } +} + +// ---- Enums ---- + +export enum DeviceMode { + ADB = "adb", + Fastboot = "fastboot", + Recovery = "recovery", + Bootloader = "bootloader", +} + +export enum LogLevel { + Silent = 0, + Error = 1, + Debug = 2, +} + +// ---- Callback Types ---- + +/** Progress callback: (bytesSent, bytesTotal) */ +export type ProgressCallback = (sent: number, total: number) => void; + +/** Sideload progress: (blockIndex, totalBlocks) */ +export type SideloadProgressCallback = ( + block: number, + totalBlocks: number, +) => void; + +// ---- Device Info ---- + +export interface DeviceBanner { + device: string; // codename (e.g., "raven") + model: string; // model name (e.g., "Pixel 6 Pro") + product: string; // product name (e.g., "raven") +} + +// ---- Logger ---- + +let currentLogLevel: LogLevel = LogLevel.Silent; + +export function setLogLevel(level: LogLevel): void { + currentLogLevel = level; +} + +export function getLogLevel(): LogLevel { + return currentLogLevel; +} + +export function log(...args: unknown[]): void { + if (currentLogLevel >= LogLevel.Debug) { + console.log("[lib]", ...args); + } +} + +export function logError(...args: unknown[]): void { + if (currentLogLevel >= LogLevel.Error) { + console.error("[lib]", ...args); + } +} diff --git a/app/tsconfig.json b/app/tsconfig.json new file mode 100644 index 0000000..c601771 --- /dev/null +++ b/app/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "isolatedModules": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "types": ["w3c-web-usb"] + }, + "include": ["src/lib/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/app/vite.config.js b/app/vite.config.js index 3cd15ca..16ef1ec 100644 --- a/app/vite.config.js +++ b/app/vite.config.js @@ -1,20 +1,5 @@ import { defineConfig } from "vite"; -import { viteStaticCopy } from "vite-plugin-static-copy"; export default defineConfig({ base: "", - plugins: [ - viteStaticCopy({ - targets: [ - { - src: "node_modules/@zip.js/zip.js/dist/z-worker-pako.js", - dest: "vendor", - }, - { - src: "node_modules/pako/dist/pako_inflate.min.js", - dest: "vendor", - }, - ], - }), - ], }); -- GitLab From 9e955f912748659bc13356d8e5349ec439ac7cd9 Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Fri, 6 Feb 2026 19:00:12 +0530 Subject: [PATCH 26/41] Fix erase/unlock/lock returning falsy empty string runCommand() returns the response message text after the OKAY prefix, which is an empty string for bare OKAY responses. The controller checks return values for truthiness, so empty string was treated as failure. Await the command and explicitly return true instead. Signed-off-by: Jackeagle --- app/src/controller/device.manager.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/controller/device.manager.js b/app/src/controller/device.manager.js index 9dc3c12..8ad47f5 100644 --- a/app/src/controller/device.manager.js +++ b/app/src/controller/device.manager.js @@ -116,8 +116,9 @@ export class DeviceManager { return false; } - erase(partition) { - return this.bootloader.runCommand(`erase:${partition}`); + async erase(partition) { + await this.bootloader.runCommand(`erase:${partition}`); + return true; } format() { @@ -126,12 +127,14 @@ export class DeviceManager { // the fastboot format md_udc is not supported evne by the official fastboot program } - unlock(command) { - return this.bootloader.runCommand(command); + async unlock(command) { + await this.bootloader.runCommand(command); + return true; } - lock(command) { - return this.bootloader.runCommand(command); + async lock(command) { + await this.bootloader.runCommand(command); + return true; } async flash(file, partition, onProgress) { -- GitLab From a89189ef3516c5b377873749dc6655bffff936ba Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Sat, 7 Feb 2026 07:53:43 +0530 Subject: [PATCH 27/41] Fix sparse image splitting corrupting partitions on flash Sub-images after the first now include a DONT_CARE prefix chunk to skip blocks already written by previous sub-images, preventing every sub-image from overwriting from block 0. Each sub-image header sets totalBlocks = blocksOffset + dataBlocks so the chunk block sum matches (required by MediaTek LK bootloader validation). Also reduce flash cooldown to 1s and update device config. Signed-off-by: Jackeagle --- app/public/resources/two.json | 9 +++-- app/src/controller.manager.js | 2 +- app/src/lib/fastboot/sparse-image.ts | 59 ++++++++++++++++++++++++---- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/app/public/resources/two.json b/app/public/resources/two.json index 6cf28db..5ca43b1 100644 --- a/app/public/resources/two.json +++ b/app/public/resources/two.json @@ -4,7 +4,10 @@ { "mode": "bootloader", "id": "connect-bootloader", - "command": ["connect bootloader", "flashing unlock unlocked"], + "command": [ + "connect bootloader", + "flashing unlock unlocked" + ], "needUserGesture": true }, { @@ -67,9 +70,9 @@ ], "folder": [ { - "path": "https://images.ecloud.global/official/two/IMG-e-latest-t-official-two.zip", + "path": "https://images.ecloud.global/official/two/IMG-e-3.0.4-t-20250709507786-official-two.zip", "name": "Murena Two installer", "unzip": true } ] -} +} \ No newline at end of file diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index fae74c7..c633612 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -183,7 +183,7 @@ export class Controller { case Command.CMD_TYPE.erase: return this.deviceManager.erase(cmd.partition); case Command.CMD_TYPE.flash: { - const FLASH_COOLDOWN_MS = 5000; // Pause after flash to let device stabilize + const FLASH_COOLDOWN_MS = 1000; // Pause after flash to let device stabilize const result = await this.deviceManager.flash( cmd.file, cmd.partition, diff --git a/app/src/lib/fastboot/sparse-image.ts b/app/src/lib/fastboot/sparse-image.ts index 313efb5..99f70d5 100644 --- a/app/src/lib/fastboot/sparse-image.ts +++ b/app/src/lib/fastboot/sparse-image.ts @@ -131,7 +131,15 @@ function buildSparseHeader( * * This is needed when the device's max-download-size is smaller than the * sparse image. Each sub-image is a valid sparse image that can be downloaded - * and flashed independently (the device will write each to the correct offsets). + * and flashed independently. + * + * Each sub-image after the first includes a DONT_CARE chunk at the start to + * skip the blocks already written by previous sub-images, so the device + * writes each sub-image's data at the correct partition offset. + * + * Each sub-image's header totalBlocks = blocksOffset + dataBlocks, so the + * chunk block sum matches the header (required by MediaTek LK and other + * bootloaders that validate this). */ export async function splitSparseImage( blob: Blob, @@ -171,11 +179,13 @@ export async function splitSparseImage( return [blob]; } - // Group chunks into sub-images that fit within maxSize + // Group chunks into sub-images that fit within maxSize. + // Each sub-image after the first reserves space for a DONT_CARE prefix chunk. const subImages: Blob[] = []; let currentChunks: ChunkEntry[] = []; let currentSize = SPARSE_HEADER_SIZE; let currentBlocks = 0; + let blocksWrittenSoFar = 0; for (const chunk of chunks) { const dataSize = chunkDataSize(chunk.header, header.blockSize); @@ -187,10 +197,12 @@ export async function splitSparseImage( currentSize + chunkTotalSize > maxSize ) { subImages.push( - buildSubImage(blob, header.blockSize, currentBlocks, currentChunks), + buildSubImage(blob, header.blockSize, header.totalBlocks, currentChunks, blocksWrittenSoFar), ); + blocksWrittenSoFar += currentBlocks; currentChunks = []; - currentSize = SPARSE_HEADER_SIZE; + // Reserve space for the DONT_CARE prefix chunk in subsequent sub-images + currentSize = SPARSE_HEADER_SIZE + SPARSE_CHUNK_HEADER_SIZE; currentBlocks = 0; } @@ -202,7 +214,7 @@ export async function splitSparseImage( // Finalize the last sub-image if (currentChunks.length > 0) { subImages.push( - buildSubImage(blob, header.blockSize, currentBlocks, currentChunks), + buildSubImage(blob, header.blockSize, header.totalBlocks, currentChunks, blocksWrittenSoFar), ); } @@ -211,19 +223,38 @@ export async function splitSparseImage( /** * Build a sub-image Blob from a subset of chunks. + * + * @param originalTotalBlocks The ORIGINAL image's total block count. + * @param blocksOffset Blocks already written by previous sub-images. + * A DONT_CARE chunk is prepended to skip these blocks. */ function buildSubImage( originalBlob: Blob, blockSize: number, - totalBlocks: number, + originalTotalBlocks: number, chunks: Array<{ header: SparseChunkHeader; offset: number; + blocks: number; }>, + blocksOffset: number, ): Blob { - const newHeader = buildSparseHeader(blockSize, totalBlocks, chunks.length); + const hasDontCarePrefix = blocksOffset > 0; + const numChunks = chunks.length + (hasDontCarePrefix ? 1 : 0); + + // totalBlocks for this sub-image = offset blocks + data blocks. + // The bootloader validates that chunk blocks sum to totalBlocks. + const dataBlocks = chunks.reduce((sum, c) => sum + c.blocks, 0); + const subImageTotalBlocks = blocksOffset + dataBlocks; + + const newHeader = buildSparseHeader(blockSize, subImageTotalBlocks, numChunks); const parts: BlobPart[] = [newHeader as BlobPart]; + // Prepend a DONT_CARE chunk to skip blocks written by previous sub-images + if (hasDontCarePrefix) { + parts.push(buildDontCareChunk(blocksOffset) as BlobPart); + } + for (const chunk of chunks) { const dataSize = chunkDataSize(chunk.header, blockSize); const chunkTotalSize = SPARSE_CHUNK_HEADER_SIZE + dataSize; @@ -233,3 +264,17 @@ function buildSubImage( return new Blob(parts); } + +/** + * Build a 12-byte DONT_CARE chunk header. + * Used to skip blocks already written by previous sub-images. + */ +function buildDontCareChunk(chunkBlocks: number): Uint8Array { + const buf = new ArrayBuffer(SPARSE_CHUNK_HEADER_SIZE); + const view = new DataView(buf); + view.setUint16(0, SparseChunkType.DontCare, true); + view.setUint16(2, 0, true); // reserved + view.setUint32(4, chunkBlocks, true); + view.setUint32(8, SPARSE_CHUNK_HEADER_SIZE, true); // total size = header only + return new Uint8Array(buf); +} -- GitLab From 333faa76c41f8c381362a1201b18fe1652cef6da Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Sat, 7 Feb 2026 10:15:47 +0530 Subject: [PATCH 28/41] Fix ADB on Pixel 7: select alternate interface and route stale packets Select the USB alternate setting that has bulk endpoints when it is not alternate 0 (Pixel 7 exposes ADB endpoints on alternate 1). Add packet routing by checking arg1 (target localId) in stream open and shell read loops so stale CLSE acknowledgments from previously closed streams are skipped instead of breaking the next stream. Signed-off-by: Jackeagle --- app/src/lib/adb/adb-device.ts | 9 +++++++ app/src/lib/adb/adb-stream.ts | 46 +++++++++++++++++++++------------ app/src/lib/transport/types.ts | 1 + app/src/lib/transport/webusb.ts | 11 ++++++++ 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/app/src/lib/adb/adb-device.ts b/app/src/lib/adb/adb-device.ts index a3ec3bc..8b14f11 100644 --- a/app/src/lib/adb/adb-device.ts +++ b/app/src/lib/adb/adb-device.ts @@ -156,6 +156,15 @@ export class AdbDevice { while (true) { const packet = await this.receivePacket(); + // Skip packets for other streams (stale CLSE acks, etc.) + if (packet.arg1 !== 0 && packet.arg1 !== stream.localId) { + log( + `Shell: skipping stale packet cmd=0x${packet.command.toString(16)} ` + + `for localId=${packet.arg1} (ours=${stream.localId})`, + ); + continue; + } + if (packet.command === AdbCommand.Write) { output += decodeUtf8(packet.payload); // Send OKAY to acknowledge diff --git a/app/src/lib/adb/adb-stream.ts b/app/src/lib/adb/adb-stream.ts index 2536300..e47a4b6 100644 --- a/app/src/lib/adb/adb-stream.ts +++ b/app/src/lib/adb/adb-stream.ts @@ -59,26 +59,40 @@ export class AdbStream { log(`Stream OPEN: localId=${localId}, service="${service}"`); - // Read response — expect OKAY - const response = await receivePacket(); - - if (response.command === AdbCommand.Okay) { - const stream = new AdbStream(transport, localId, response.arg0); - log( - `Stream opened: localId=${localId}, remoteId=${stream.remoteId}`, - ); - return stream; - } + // Read response — expect OKAY. + // Skip stale packets from previously closed streams (e.g., a CLSE + // acknowledgment for a stream we already closed). + // eslint-disable-next-line no-constant-condition + while (true) { + const response = await receivePacket(); + + // Packets for other streams have arg1 != our localId — skip them + if (response.arg1 !== 0 && response.arg1 !== localId) { + log( + `Stream OPEN: skipping stale packet cmd=0x${response.command.toString(16)} ` + + `for localId=${response.arg1} (ours=${localId})`, + ); + continue; + } + + if (response.command === AdbCommand.Okay) { + const stream = new AdbStream(transport, localId, response.arg0); + log( + `Stream opened: localId=${localId}, remoteId=${stream.remoteId}`, + ); + return stream; + } + + if (response.command === AdbCommand.Close) { + throw new ProtocolError( + `ADB service "${service}" rejected (CLSE received)`, + ); + } - if (response.command === AdbCommand.Close) { throw new ProtocolError( - `ADB service "${service}" rejected (CLSE received)`, + `Unexpected response to OPEN: command=0x${response.command.toString(16)}`, ); } - - throw new ProtocolError( - `Unexpected response to OPEN: command=0x${response.command.toString(16)}`, - ); } /** diff --git a/app/src/lib/transport/types.ts b/app/src/lib/transport/types.ts index 8b294a4..94cc378 100644 --- a/app/src/lib/transport/types.ts +++ b/app/src/lib/transport/types.ts @@ -26,4 +26,5 @@ export interface EndpointInfo { inEndpoint: number; outEndpoint: number; interfaceNumber: number; + alternateSetting: number; } diff --git a/app/src/lib/transport/webusb.ts b/app/src/lib/transport/webusb.ts index 04b53d5..e976e0b 100644 --- a/app/src/lib/transport/webusb.ts +++ b/app/src/lib/transport/webusb.ts @@ -105,6 +105,16 @@ export class WebUsbTransport { // Claim the interface await this._device.claimInterface(this._interfaceNumber); + // Select the alternate setting that has the bulk endpoints. + // Some devices (e.g. Pixel 7) have alternate 0 with no endpoints + // and alternate 1 with the actual bulk IN/OUT endpoints. + if (endpoints.alternateSetting !== 0) { + await this._device.selectAlternateInterface( + this._interfaceNumber, + endpoints.alternateSetting, + ); + } + // Clear any stale halt condition on both endpoints. // Previous sessions that were interrupted (tab closed, USB unplugged) // can leave endpoints in a HALTED state, causing every subsequent @@ -415,6 +425,7 @@ export class WebUsbTransport { inEndpoint, outEndpoint, interfaceNumber: iface.interfaceNumber, + alternateSetting: alt.alternateSetting, }; } } -- GitLab From 81a32b77785fe0befb4ffe8be0e4ce4016cf234b Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Sat, 7 Feb 2026 10:27:38 +0530 Subject: [PATCH 29/41] Detect stale bootloader connection after device reboot After unlock the device reboots, leaving the old FastbootDevice thinking it is still connected. Verify existing connections with a lightweight getvar before reusing them; on failure disconnect and re-acquire the paired device via findDevice(). Signed-off-by: Jackeagle --- app/src/controller/device/bootloader.class.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index 8ee0c67..6b8beab 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -57,6 +57,31 @@ export class Bootloader extends Device { // On first attempt or after a failed reconnect, create a new device if (!this.fastboot) { this.fastboot = await FastbootDevice.requestDevice(); + } else if (this.fastboot.isConnected) { + // Existing connection — verify it's still alive (device may have + // rebooted after unlock). A stale session would short-circuit + // connect() but fail on the first real transfer. + try { + await this.fastboot.getVariable("version"); + WDebug.log( + `Bootloader.connect() existing connection verified in ${Date.now() - connectStart}ms`, + ); + return; + } catch { + WDebug.log( + "Bootloader.connect() existing connection stale, reconnecting...", + ); + try { + await this.fastboot.disconnect(); + } catch { + /* ignore */ + } + this.fastboot = await FastbootDevice.findDevice(); + if (!this.fastboot) { + // No paired device found — need user gesture + this.fastboot = await FastbootDevice.requestDevice(); + } + } } await this.fastboot.connect(); -- GitLab From c32b2a0b9051cb40b1ea434f0e00fe8b0062edd6 Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Sat, 7 Feb 2026 10:51:26 +0530 Subject: [PATCH 30/41] Auto-resolve A/B slot suffix for flash and erase commands Query getvar:has-slot: and getvar:current-slot to automatically append the active slot suffix (_a/_b) when the partition is slotted. Matches native fastboot behaviour so device configs can use bare partition names like dtbo instead of dtbo_a. Signed-off-by: Jackeagle --- app/src/lib/fastboot/fastboot-device.ts | 57 +++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/app/src/lib/fastboot/fastboot-device.ts b/app/src/lib/fastboot/fastboot-device.ts index 92d5041..d1ebdab 100644 --- a/app/src/lib/fastboot/fastboot-device.ts +++ b/app/src/lib/fastboot/fastboot-device.ts @@ -27,6 +27,7 @@ export class FastbootDevice { private _transport: WebUsbTransport; private _connected = false; private _maxDownloadSize: number | null = null; + private _currentSlot: string | null = null; private constructor(transport: WebUsbTransport) { this._transport = transport; @@ -83,6 +84,7 @@ export class FastbootDevice { await this._transport.close(); this._connected = false; this._maxDownloadSize = null; + this._currentSlot = null; } // ---- Commands ---- @@ -110,6 +112,7 @@ export class FastbootDevice { * * Automatically detects sparse images and splits them if they exceed * the device's max-download-size. Reports progress via callback. + * Resolves A/B slot suffix automatically when needed. */ async flashBlob( partition: string, @@ -118,24 +121,28 @@ export class FastbootDevice { ): Promise { this.ensureConnected(); + const resolved = await this.resolvePartition(partition); + // Read the first few bytes to check for sparse format const headerBytes = new Uint8Array( await blob.slice(0, 4).arrayBuffer(), ); if (isSparseImage(headerBytes)) { - await this.flashSparseBlob(partition, blob, onProgress); + await this.flashSparseBlob(resolved, blob, onProgress); } else { - await this.flashRawBlob(partition, blob, onProgress); + await this.flashRawBlob(resolved, blob, onProgress); } } /** * Erase a partition. + * Resolves A/B slot suffix automatically when needed. */ async erase(partition: string): Promise { this.ensureConnected(); - await erasePartition(this._transport, partition); + const resolved = await this.resolvePartition(partition); + await erasePartition(this._transport, resolved); } /** @@ -177,6 +184,7 @@ export class FastbootDevice { async reconnect(): Promise { this._connected = false; this._maxDownloadSize = null; + this._currentSlot = null; await this._transport.reconnect(); this._connected = true; } @@ -221,6 +229,49 @@ export class FastbootDevice { return this._maxDownloadSize; } + /** + * Resolve a partition name by appending the current A/B slot suffix + * if the device reports the partition is slotted (getvar:has-slot). + * Partitions that already have a slot suffix (_a/_b) are returned as-is. + */ + private async resolvePartition(partition: string): Promise { + if (partition.endsWith("_a") || partition.endsWith("_b")) { + return partition; + } + + try { + const hasSlot = await getVar(this._transport, `has-slot:${partition}`); + if (hasSlot === "yes") { + const slot = await this.getCurrentSlot(); + const resolved = `${partition}_${slot}`; + log(`Partition ${partition} → ${resolved} (slot=${slot})`); + return resolved; + } + } catch { + // getvar:has-slot not supported — use partition name as-is + } + + return partition; + } + + /** + * Get and cache the device's current A/B slot. + */ + private async getCurrentSlot(): Promise { + if (this._currentSlot !== null) return this._currentSlot; + + try { + this._currentSlot = await getVar(this._transport, "current-slot"); + // Some bootloaders return "a" or "b", others return "_a" or "_b" + this._currentSlot = this._currentSlot.replace(/^_/, ""); + } catch { + this._currentSlot = "a"; + } + + log(`Current slot: ${this._currentSlot}`); + return this._currentSlot; + } + /** * Flash a raw (non-sparse) blob: download + flash. */ -- GitLab From 79646cf0cfd467518e1ed5ee6b0928b51c44e9c5 Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Sat, 7 Feb 2026 10:52:20 +0530 Subject: [PATCH 31/41] two: Use latest /e/OS Release Signed-off-by: Jackeagle --- app/public/resources/two.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/public/resources/two.json b/app/public/resources/two.json index 5ca43b1..8db2521 100644 --- a/app/public/resources/two.json +++ b/app/public/resources/two.json @@ -70,7 +70,7 @@ ], "folder": [ { - "path": "https://images.ecloud.global/official/two/IMG-e-3.0.4-t-20250709507786-official-two.zip", + "path": "https://images.ecloud.global/official/two/IMG-e-latest-t-official-two.zip", "name": "Murena Two installer", "unzip": true } -- GitLab From 20d3eae365fb7e968b9523f081856c1d6697bb95 Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Sat, 7 Feb 2026 14:58:05 +0530 Subject: [PATCH 32/41] Fix flash reliability: data transfer timing, reconnect, and max-download-size parsing - Add 250ms settle delay after data transfer before reading device response, preventing bootloaders from missing the OKAY acknowledgment - Yield to browser event loop between USB transfer chunks so Chrome's USB stack can process completion events - Verify bytesWritten on every transferOut to catch partial writes early - Add post-flash getvar synchronization barrier so the device finishes internal processing before the next download begins - Reset USB device before close during reconnect to abort dangling transferIn calls left by timed-out reads - Fix max-download-size parsing: detect 0x prefix for hex, otherwise parse as decimal (matches AOSP fastboot behavior). Qualcomm ABL returns decimal strings which were incorrectly parsed as hex, inflating the limit from 768 MB to 34 GB and preventing sparse image splitting. - Increase flash cooldown from 1000ms to 2500ms Signed-off-by: Jackeagle --- app/src/controller.manager.js | 18 +++++++------- app/src/lib/fastboot/fastboot-device.ts | 30 ++++++++++++++++++++++- app/src/lib/fastboot/fastboot-protocol.ts | 15 ++++++++++++ app/src/lib/transport/webusb.ts | 17 ++++++++++++- 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index c633612..3abff20 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -37,7 +37,7 @@ export class Controller { const alreadyInMode = this.inInMode(next.mode); WDebug.log( `next() step="${next.name}" requires mode="${next.mode}", ` + - `alreadyInMode=${alreadyInMode}, needUserGesture=${next.needUserGesture}`, + `alreadyInMode=${alreadyInMode}, needUserGesture=${next.needUserGesture}`, ); //if next step require another mode [adb|fastboot|bootloader] if (!alreadyInMode) { @@ -111,9 +111,9 @@ export class Controller { } else { throw new Error( "this is not the current step " + - current.name + - " is not equals to " + - stepName, + current.name + + " is not equals to " + + stepName, ); } } @@ -183,7 +183,7 @@ export class Controller { case Command.CMD_TYPE.erase: return this.deviceManager.erase(cmd.partition); case Command.CMD_TYPE.flash: { - const FLASH_COOLDOWN_MS = 1000; // Pause after flash to let device stabilize + const FLASH_COOLDOWN_MS = 2500; // Pause after flash to let device stabilize const result = await this.deviceManager.flash( cmd.file, cmd.partition, @@ -214,8 +214,8 @@ export class Controller { WDebug.log( "ControllerManager unlock: ", this.deviceManager.adb.getProductName() + - " isUnlocked = " + - isUnlocked, + " isUnlocked = " + + isUnlocked, ); if (!isUnlocked) { try { @@ -243,8 +243,8 @@ export class Controller { WDebug.log( "Bypass step", this.steps[this.currentIndex].name + - " " + - (this.steps[this.currentIndex].name == gotoStep), + " " + + (this.steps[this.currentIndex].name == gotoStep), ); } while (!(this.steps[this.currentIndex].name == gotoStep)); this.currentIndex--; diff --git a/app/src/lib/fastboot/fastboot-device.ts b/app/src/lib/fastboot/fastboot-device.ts index d1ebdab..a19805f 100644 --- a/app/src/lib/fastboot/fastboot-device.ts +++ b/app/src/lib/fastboot/fastboot-device.ts @@ -210,13 +210,23 @@ export class FastbootDevice { /** * Get and cache the device's max-download-size. * Falls back to 512 MB if the variable is not available. + * + * Bootloaders vary in format: + * - Qualcomm ABL: decimal string ("805306368" = 768 MB) + * - MediaTek/Google: hex with 0x prefix ("0x30000000" = 768 MB) + * Matches AOSP fastboot's strtoll(str, NULL, 0) behavior: 0x prefix + * means hex, otherwise decimal. */ private async getMaxDownloadSize(): Promise { if (this._maxDownloadSize !== null) return this._maxDownloadSize; try { const value = await getVar(this._transport, "max-download-size"); - this._maxDownloadSize = parseInt(value, 16) || parseInt(value, 10); + if (value.startsWith("0x") || value.startsWith("0X")) { + this._maxDownloadSize = parseInt(value, 16); + } else { + this._maxDownloadSize = parseInt(value, 10); + } if (isNaN(this._maxDownloadSize) || this._maxDownloadSize <= 0) { this._maxDownloadSize = 512 * 1024 * 1024; } @@ -283,6 +293,7 @@ export class FastbootDevice { const data = new Uint8Array(await blob.arrayBuffer()); await downloadData(this._transport, data, onProgress, FASTBOOT_FLASH_TIMEOUT_MS); await flashPartition(this._transport, partition, FASTBOOT_FLASH_TIMEOUT_MS); + await this.waitDeviceReady(); } /** @@ -326,5 +337,22 @@ export class FastbootDevice { `(${subImageSize} bytes)`, ); } + + await this.waitDeviceReady(); + } + + /** + * Verify the device is responsive after a flash operation. + * Some bootloaders (Qualcomm ABL) continue internal processing after + * sending OKAY for a flash command. Starting the next download before + * this finishes can cause the device to hang without responding. + * A quick getvar round-trip acts as a synchronization barrier. + */ + private async waitDeviceReady(): Promise { + try { + await getVar(this._transport, "product", 5000); + } catch { + // FAIL or timeout — either way, the device had time to settle + } } } diff --git a/app/src/lib/fastboot/fastboot-protocol.ts b/app/src/lib/fastboot/fastboot-protocol.ts index 654c75a..f8d2811 100644 --- a/app/src/lib/fastboot/fastboot-protocol.ts +++ b/app/src/lib/fastboot/fastboot-protocol.ts @@ -94,6 +94,14 @@ export async function sendData( await transport.sendWithTimeout(chunk, timeoutMs); offset = end; onProgress?.(offset, total); + + // Yield to the browser event loop between chunks so Chrome's USB + // stack can process completion events and hardware ACKs. Without + // this, back-to-back transferOut calls can starve the USB driver's + // completion handler, causing the device to miss data. + if (offset < total) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } } } @@ -125,6 +133,13 @@ export async function downloadData( // Send the raw data await sendData(transport, data, onProgress, 512 * 1024, timeoutMs); + // Let the device fully process the received data before we issue a + // USB IN transfer for the OKAY response. Chrome's async transferOut + // resolves when the host controller accepts the data, but the device + // may still be DMA-ing the last packets. Issuing transferIn too early + // can cause some bootloaders (Qualcomm ABL) to miss the response. + await new Promise((resolve) => setTimeout(resolve, 250)); + // Read final OKAY await readResponse(transport, timeoutMs); } diff --git a/app/src/lib/transport/webusb.ts b/app/src/lib/transport/webusb.ts index e976e0b..93ad0b2 100644 --- a/app/src/lib/transport/webusb.ts +++ b/app/src/lib/transport/webusb.ts @@ -183,9 +183,19 @@ export class WebUsbTransport { */ async reconnect(settleMs = 2000): Promise { log("Reconnecting USB session..."); + + // Reset first to abort any pending transferIn/transferOut calls. + // After a timeout, Promise.race leaves the underlying USB transfer + // still active, which blocks releaseInterface() during close(). + try { + await this._device.reset(); + } catch { + // Reset may fail if device is already disconnected — that's OK + } + await this.close(); - // Wait for USB bus to stabilize + // Wait for USB bus to stabilize after reset await new Promise((resolve) => setTimeout(resolve, settleMs)); // Re-open the connection @@ -215,6 +225,11 @@ export class WebUsbTransport { if (result.status !== "ok") { throw new UsbError(`USB transferOut failed: status=${result.status}`); } + if (result.bytesWritten !== undefined && result.bytesWritten !== data.byteLength) { + throw new UsbError( + `USB transferOut incomplete: wrote ${result.bytesWritten}/${data.byteLength} bytes`, + ); + } } /** -- GitLab From ad3c938a5ce57073efb93b8c9af432b63be81126 Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Sun, 8 Feb 2026 09:55:17 +0530 Subject: [PATCH 33/41] Make flash cooldown configurable per device via JSON config Add flash_cooldown_ms to device config JSON to override the default 2500ms delay between flash operations. Devices that need more time (e.g. Qualcomm ABL) can set a higher value without slowing down devices that work fine with the default. Signed-off-by: Jackeagle --- CONTRIBUTING.md | 7 +++++++ app/src/controller.manager.js | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de44713..45fceb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,13 @@ Open chrome base browser and go to http://localhost:5173/. > For oem, recovery, rom and key, we parse these command and execute them. The others commands are not analyzed and executed arbitrarily in the device. + - Optional top-level settings + + | key | example | description | + |----------------------|---------|----------------------------------------------------------------------------------------------| + | `flash_cooldown_ms` | `3000` | Delay in ms between flash operations. Defaults to 2500 if not set. Increase for slow devices | + | `security_patch_level` | `"2018-01-05"` | When the device's patch level is newer, the `-safe.json` variant is loaded instead | + - Define the folder, an array describing the files involved in the flash process - template: ```json diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index 3abff20..8ea4e8d 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -183,7 +183,7 @@ export class Controller { case Command.CMD_TYPE.erase: return this.deviceManager.erase(cmd.partition); case Command.CMD_TYPE.flash: { - const FLASH_COOLDOWN_MS = 2500; // Pause after flash to let device stabilize + const FLASH_COOLDOWN_MS = this.resources?.flash_cooldown_ms ?? 2500; const result = await this.deviceManager.flash( cmd.file, cmd.partition, -- GitLab From 98c03f1511197a1ca351d71818d7d6ecbaa22ff2 Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Sun, 8 Feb 2026 10:07:40 +0530 Subject: [PATCH 34/41] two: Format JSON with prettier Signed-off-by: Jackeagle --- app/public/resources/two.json | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/public/resources/two.json b/app/public/resources/two.json index 8db2521..6cf28db 100644 --- a/app/public/resources/two.json +++ b/app/public/resources/two.json @@ -4,10 +4,7 @@ { "mode": "bootloader", "id": "connect-bootloader", - "command": [ - "connect bootloader", - "flashing unlock unlocked" - ], + "command": ["connect bootloader", "flashing unlock unlocked"], "needUserGesture": true }, { @@ -75,4 +72,4 @@ "unzip": true } ] -} \ No newline at end of file +} -- GitLab From 33f56e5517ae35529f6e4a43ff2cc3ae915661b0 Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Mon, 9 Feb 2026 14:45:25 +0530 Subject: [PATCH 35/41] Fix large blob loss: keep extracted files in memory and fix IndexedDB commit setInDBStore resolved on request.onsuccess which fires before the IndexedDB transaction commits. For large blobs (e.g. super.img), the transaction can abort during commit due to storage quota limits, silently rolling back the write while the code assumes it succeeded. - Keep extracted blobs in memory (this.blobs) so they are always available for the current session regardless of IndexedDB state - getFile checks in-memory store first, falls back to IndexedDB for blobs cached from a previous session - setInDBStore now waits on transaction.oncomplete instead of request.onsuccess and handles transaction.onabort - IndexedDB write failures are caught and logged as warnings without blocking the flash process Signed-off-by: Jackeagle --- app/src/controller/downloader.manager.js | 42 +++++++++++++++++------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/app/src/controller/downloader.manager.js b/app/src/controller/downloader.manager.js index 385272c..6024f77 100644 --- a/app/src/controller/downloader.manager.js +++ b/app/src/controller/downloader.manager.js @@ -15,6 +15,7 @@ export class Downloader { constructor() { this.db = null; this.stored = {}; + this.blobs = {}; } async init() { @@ -91,8 +92,15 @@ export class Downloader { file.mapping, ); if (filesRequired.includes(filename)) { - await this.setInDBStore(unzippedEntry.blob, 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) => { @@ -104,8 +112,15 @@ export class Downloader { } await zipReader.close(); } else { - await this.setInDBStore(blob, file.name); + 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}`, + ); + } } } } @@ -187,10 +202,14 @@ export class Downloader { * this function retrieve the promise linked to the fileName */ async getFile(name) { - const file = this.stored[name]; - if (!file) { + 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); } @@ -239,15 +258,14 @@ export class Downloader { return new Promise((resolve, reject) => { const transaction = this.db.transaction(DB_NAME, "readwrite"); const store = transaction.objectStore(DB_NAME); - const request = store.put(blob, key); + store.put(blob, key); - request.onsuccess = () => { - resolve(); - }; - - request.onerror = (event) => { - reject(event.target.error); - }; + // 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?)")); }); } -- GitLab From cc4a5c7fdbb68f5ecd66f92a00d10bab5f701c34 Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Tue, 10 Feb 2026 17:22:42 +0530 Subject: [PATCH 36/41] src: Format controller.manager.js with Prettier Signed-off-by: Jackeagle --- app/src/controller.manager.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index 8ea4e8d..cda7197 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -37,7 +37,7 @@ export class Controller { const alreadyInMode = this.inInMode(next.mode); WDebug.log( `next() step="${next.name}" requires mode="${next.mode}", ` + - `alreadyInMode=${alreadyInMode}, needUserGesture=${next.needUserGesture}`, + `alreadyInMode=${alreadyInMode}, needUserGesture=${next.needUserGesture}`, ); //if next step require another mode [adb|fastboot|bootloader] if (!alreadyInMode) { @@ -111,9 +111,9 @@ export class Controller { } else { throw new Error( "this is not the current step " + - current.name + - " is not equals to " + - stepName, + current.name + + " is not equals to " + + stepName, ); } } @@ -214,8 +214,8 @@ export class Controller { WDebug.log( "ControllerManager unlock: ", this.deviceManager.adb.getProductName() + - " isUnlocked = " + - isUnlocked, + " isUnlocked = " + + isUnlocked, ); if (!isUnlocked) { try { @@ -243,8 +243,8 @@ export class Controller { WDebug.log( "Bypass step", this.steps[this.currentIndex].name + - " " + - (this.steps[this.currentIndex].name == gotoStep), + " " + + (this.steps[this.currentIndex].name == gotoStep), ); } while (!(this.steps[this.currentIndex].name == gotoStep)); this.currentIndex--; -- GitLab From 95ba582b14dff01d799e33e78e079bdaa7b5c177 Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Tue, 10 Feb 2026 19:40:52 +0530 Subject: [PATCH 37/41] Fix unlock/lock timeout: use 5-minute timeout for user-interactive commands The flashing unlock/lock commands show a confirmation screen on the device requiring physical button presses. The default 30s command timeout is not enough time for the user to confirm. Pass a 5-minute timeout through the call chain for these interactive operations. Signed-off-by: Jackeagle --- app/src/controller/device.manager.js | 8 ++++++-- app/src/controller/device/bootloader.class.js | 4 ++-- app/src/lib/fastboot/fastboot-device.ts | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/controller/device.manager.js b/app/src/controller/device.manager.js index 8ad47f5..6b757ac 100644 --- a/app/src/controller/device.manager.js +++ b/app/src/controller/device.manager.js @@ -128,12 +128,16 @@ export class DeviceManager { } async unlock(command) { - await this.bootloader.runCommand(command); + // Unlock requires physical confirmation on the device (volume keys + + // power button), so use a generous 5-minute timeout instead of the + // default 30 seconds. + await this.bootloader.runCommand(command, 300_000); return true; } async lock(command) { - await this.bootloader.runCommand(command); + // Lock may also require physical confirmation on the device. + await this.bootloader.runCommand(command, 300_000); return true; } diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index 6b8beab..442c1e4 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -24,8 +24,8 @@ export class Bootloader extends Device { return this.fastboot.reboot(mode); } - runCommand(command) { - return this.fastboot.runCommand(command); + runCommand(command, timeoutMs) { + return this.fastboot.runCommand(command, timeoutMs); } isBootloader() { diff --git a/app/src/lib/fastboot/fastboot-device.ts b/app/src/lib/fastboot/fastboot-device.ts index a19805f..2995ccd 100644 --- a/app/src/lib/fastboot/fastboot-device.ts +++ b/app/src/lib/fastboot/fastboot-device.ts @@ -101,9 +101,9 @@ export class FastbootDevice { * Run an arbitrary fastboot command and return the response message. * Used for commands like "flashing unlock", "oem unlock", "flashing lock", etc. */ - async runCommand(command: string): Promise { + async runCommand(command: string, timeoutMs?: number): Promise { this.ensureConnected(); - const result = await sendCommand(this._transport, command); + const result = await sendCommand(this._transport, command, timeoutMs); return result.message; } -- GitLab From 8809d2ec30b19179a52abc803fa1a417445bc8b5 Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Tue, 10 Feb 2026 21:24:11 +0530 Subject: [PATCH 38/41] Fix Volla Tablet unlock: use reactive clearHalt instead of proactive Proactive clearHalt on open() sends CLEAR_FEATURE(ENDPOINT_HALT) even when endpoints are not halted. This corrupts the MediaTek bootloader's USB state on Volla Tablet, causing flashing unlock to never receive a response. Replace with reactive approach: detect stall status on actual transfers and clearHalt + retry only when needed. This still fixes Murena Two stale endpoints from interrupted sessions. Signed-off-by: Jackeagle --- app/src/lib/transport/webusb.ts | 35 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/app/src/lib/transport/webusb.ts b/app/src/lib/transport/webusb.ts index 93ad0b2..d5b11cd 100644 --- a/app/src/lib/transport/webusb.ts +++ b/app/src/lib/transport/webusb.ts @@ -115,21 +115,6 @@ export class WebUsbTransport { ); } - // Clear any stale halt condition on both endpoints. - // Previous sessions that were interrupted (tab closed, USB unplugged) - // can leave endpoints in a HALTED state, causing every subsequent - // transferIn/transferOut to fail with "A transfer error has occurred". - try { - await this._device.clearHalt("in", this._inEndpoint); - } catch { - // clearHalt may fail if endpoint isn't halted — that's fine - } - try { - await this._device.clearHalt("out", this._outEndpoint); - } catch { - // clearHalt may fail if endpoint isn't halted — that's fine - } - this._rxBuf = new Uint8Array(0); this._opened = true; log( @@ -221,7 +206,15 @@ export class WebUsbTransport { throw new UsbError("Transport not open"); } - const result = await this._device.transferOut(this._outEndpoint, data as BufferSource); + let result = await this._device.transferOut(this._outEndpoint, data as BufferSource); + + // Stalled endpoint from a previous interrupted session — clear and retry + if (result.status === "stall") { + log("OUT endpoint stalled, clearing halt and retrying..."); + await this._device.clearHalt("out", this._outEndpoint); + result = await this._device.transferOut(this._outEndpoint, data as BufferSource); + } + if (result.status !== "ok") { throw new UsbError(`USB transferOut failed: status=${result.status}`); } @@ -386,7 +379,15 @@ export class WebUsbTransport { private async doTransferIn( bufferSize: number = USB_RECEIVE_BUFFER_SIZE, ): Promise { - const result = await this._device.transferIn(this._inEndpoint, bufferSize); + let result = await this._device.transferIn(this._inEndpoint, bufferSize); + + // Stalled endpoint from a previous interrupted session — clear and retry + if (result.status === "stall") { + log("IN endpoint stalled, clearing halt and retrying..."); + await this._device.clearHalt("in", this._inEndpoint); + result = await this._device.transferIn(this._inEndpoint, bufferSize); + } + if (result.status !== "ok") { throw new UsbError(`USB transferIn failed: status=${result.status}`); } -- GitLab From e1d50ce1a74a5e788a3606b5ec36ba2b51281808 Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Wed, 11 Feb 2026 18:42:22 +0530 Subject: [PATCH 39/41] Revert "Fix Volla Tablet unlock: use reactive clearHalt instead of proactive" This reverts commit 7bbe6ab78146cab62533de159f11cbfa942af499. --- app/src/lib/transport/webusb.ts | 35 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/app/src/lib/transport/webusb.ts b/app/src/lib/transport/webusb.ts index d5b11cd..93ad0b2 100644 --- a/app/src/lib/transport/webusb.ts +++ b/app/src/lib/transport/webusb.ts @@ -115,6 +115,21 @@ export class WebUsbTransport { ); } + // Clear any stale halt condition on both endpoints. + // Previous sessions that were interrupted (tab closed, USB unplugged) + // can leave endpoints in a HALTED state, causing every subsequent + // transferIn/transferOut to fail with "A transfer error has occurred". + try { + await this._device.clearHalt("in", this._inEndpoint); + } catch { + // clearHalt may fail if endpoint isn't halted — that's fine + } + try { + await this._device.clearHalt("out", this._outEndpoint); + } catch { + // clearHalt may fail if endpoint isn't halted — that's fine + } + this._rxBuf = new Uint8Array(0); this._opened = true; log( @@ -206,15 +221,7 @@ export class WebUsbTransport { throw new UsbError("Transport not open"); } - let result = await this._device.transferOut(this._outEndpoint, data as BufferSource); - - // Stalled endpoint from a previous interrupted session — clear and retry - if (result.status === "stall") { - log("OUT endpoint stalled, clearing halt and retrying..."); - await this._device.clearHalt("out", this._outEndpoint); - result = await this._device.transferOut(this._outEndpoint, data as BufferSource); - } - + const result = await this._device.transferOut(this._outEndpoint, data as BufferSource); if (result.status !== "ok") { throw new UsbError(`USB transferOut failed: status=${result.status}`); } @@ -379,15 +386,7 @@ export class WebUsbTransport { private async doTransferIn( bufferSize: number = USB_RECEIVE_BUFFER_SIZE, ): Promise { - let result = await this._device.transferIn(this._inEndpoint, bufferSize); - - // Stalled endpoint from a previous interrupted session — clear and retry - if (result.status === "stall") { - log("IN endpoint stalled, clearing halt and retrying..."); - await this._device.clearHalt("in", this._inEndpoint); - result = await this._device.transferIn(this._inEndpoint, bufferSize); - } - + const result = await this._device.transferIn(this._inEndpoint, bufferSize); if (result.status !== "ok") { throw new UsbError(`USB transferIn failed: status=${result.status}`); } -- GitLab From 64ddeeea83b1cb4ed38d993c42c7e0ebcf1fd6b5 Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Wed, 11 Feb 2026 18:44:36 +0530 Subject: [PATCH 40/41] mimir: Skip clearHalt to fix Volla Tablet bootloader unlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proactive clearHalt on USB open() breaks the Volla Tablet (MediaTek) bootloader — flashing unlock never receives a response. Add a skip_clear_halt config flag in device JSON that disables clearHalt during transport open for devices where it causes issues. Set it for mimir (Volla Tablet). Signed-off-by: Jackeagle --- app/public/resources/mimir.json | 1 + app/src/controller.manager.js | 4 +++- app/src/controller/device.manager.js | 5 +++- app/src/controller/device/bootloader.class.js | 5 +++- app/src/lib/fastboot/fastboot-device.ts | 4 ++-- app/src/lib/transport/webusb.ts | 24 +++++++++++-------- 6 files changed, 28 insertions(+), 15 deletions(-) diff --git a/app/public/resources/mimir.json b/app/public/resources/mimir.json index 5509828..3810317 100644 --- a/app/public/resources/mimir.json +++ b/app/public/resources/mimir.json @@ -1,5 +1,6 @@ { "android": 14, + "skip_clear_halt": true, "steps": [ { "command": ["reboot bootloader", "delay 15"] diff --git a/app/src/controller.manager.js b/app/src/controller.manager.js index cda7197..5bb1429 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -457,6 +457,8 @@ export class Controller { ); this.view.updateTotalStep(this.steps.length); } - this.deviceManager.setResources(this.resources.folder, this.steps); + this.deviceManager.setResources(this.resources.folder, this.steps, { + skipClearHalt: this.resources.skip_clear_halt, + }); } } diff --git a/app/src/controller/device.manager.js b/app/src/controller/device.manager.js index 6b757ac..3088a1f 100644 --- a/app/src/controller/device.manager.js +++ b/app/src/controller/device.manager.js @@ -43,7 +43,7 @@ export class DeviceManager { this.wasConnected = true; } - setResources(folder, steps) { + setResources(folder, steps, options) { this.folder = folder; this.files = steps .map((s) => { @@ -52,6 +52,9 @@ export class DeviceManager { }); }) .flat(); + if (options?.skipClearHalt) { + this.bootloader.skipClearHalt = true; + } } async getUnlocked(variable) { diff --git a/app/src/controller/device/bootloader.class.js b/app/src/controller/device/bootloader.class.js index 442c1e4..d0982cd 100644 --- a/app/src/controller/device/bootloader.class.js +++ b/app/src/controller/device/bootloader.class.js @@ -14,6 +14,7 @@ export class Bootloader extends Device { constructor() { super(null); this.fastboot = null; + this.skipClearHalt = false; } async init() { @@ -83,7 +84,9 @@ export class Bootloader extends Device { } } } - await this.fastboot.connect(); + await this.fastboot.connect({ + skipClearHalt: this.skipClearHalt, + }); const elapsed = Date.now() - connectStart; WDebug.log( diff --git a/app/src/lib/fastboot/fastboot-device.ts b/app/src/lib/fastboot/fastboot-device.ts index 2995ccd..6f767e1 100644 --- a/app/src/lib/fastboot/fastboot-device.ts +++ b/app/src/lib/fastboot/fastboot-device.ts @@ -59,10 +59,10 @@ export class FastbootDevice { /** * Open the USB connection and verify the device speaks fastboot. */ - async connect(): Promise { + async connect(options?: { skipClearHalt?: boolean }): Promise { if (this._connected) return; - await this._transport.open(); + await this._transport.open(options); // Verify fastboot protocol with a handshake try { diff --git a/app/src/lib/transport/webusb.ts b/app/src/lib/transport/webusb.ts index 93ad0b2..ab83a08 100644 --- a/app/src/lib/transport/webusb.ts +++ b/app/src/lib/transport/webusb.ts @@ -85,7 +85,7 @@ export class WebUsbTransport { /** * Open the device, select configuration, claim interface, and find endpoints. */ - async open(): Promise { + async open(options?: { skipClearHalt?: boolean }): Promise { if (this._opened) return; try { @@ -119,15 +119,19 @@ export class WebUsbTransport { // Previous sessions that were interrupted (tab closed, USB unplugged) // can leave endpoints in a HALTED state, causing every subsequent // transferIn/transferOut to fail with "A transfer error has occurred". - try { - await this._device.clearHalt("in", this._inEndpoint); - } catch { - // clearHalt may fail if endpoint isn't halted — that's fine - } - try { - await this._device.clearHalt("out", this._outEndpoint); - } catch { - // clearHalt may fail if endpoint isn't halted — that's fine + // Some bootloaders (e.g. Volla Tablet / MediaTek) break when clearHalt + // is sent to non-halted endpoints — use skip_clear_halt in device config. + if (!options?.skipClearHalt) { + try { + await this._device.clearHalt("in", this._inEndpoint); + } catch { + // clearHalt may fail if endpoint isn't halted — that's fine + } + try { + await this._device.clearHalt("out", this._outEndpoint); + } catch { + // clearHalt may fail if endpoint isn't halted — that's fine + } } this._rxBuf = new Uint8Array(0); -- GitLab From ccf843a041ead788017a65bd76b5cd7434eae7ca Mon Sep 17 00:00:00 2001 From: Jackeagle Date: Wed, 11 Feb 2026 18:49:33 +0530 Subject: [PATCH 41/41] Document skip_clear_halt option in CONTRIBUTING.md Signed-off-by: Jackeagle --- CONTRIBUTING.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45fceb9..ea6ab6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,8 +60,9 @@ Open chrome base browser and go to http://localhost:5173/. | key | example | description | |----------------------|---------|----------------------------------------------------------------------------------------------| - | `flash_cooldown_ms` | `3000` | Delay in ms between flash operations. Defaults to 2500 if not set. Increase for slow devices | - | `security_patch_level` | `"2018-01-05"` | When the device's patch level is newer, the `-safe.json` variant is loaded instead | + | `flash_cooldown_ms` | `3000` | Delay in ms between flash operations. Defaults to 2500 if not set. Increase for slow devices | + | `security_patch_level` | `"2018-01-05"` | When the device's patch level is newer, the `-safe.json` variant is loaded instead | + | `skip_clear_halt` | `true` | Skip USB clearHalt on connect. Required for MediaTek bootloaders (e.g. Volla Tablet) where proactive clearHalt breaks flashing unlock | - Define the folder, an array describing the files involved in the flash process - template: -- GitLab