diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de4471308b301f3f1b7595ae07e04c781d73b71e..ea6ab6ccb2cf51b052fd48c92e02ad486f3967d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,14 @@ 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 | + | `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: ```json diff --git a/app/package-lock.json b/app/package-lock.json index b075615b4cddda17059f5dfbe96494ea8d8358f5..eac8acf2467ba9d991efc1a5cf65f23320888ad1 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -9,32 +9,18 @@ "version": "0.13.4", "license": "GPLv3", "dependencies": { - "@e/fastboot": "1.1.4", - "@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/api/v4/projects/1751/packages/npm/@e/fastboot/-/@e/fastboot-1.1.4.tgz", - "integrity": "sha1-aJPGtwFhrnnf0ktIJH47kVvlV+k=", - "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": { @@ -668,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", @@ -990,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", @@ -1135,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", @@ -1163,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", @@ -1187,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", @@ -1227,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", @@ -1547,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", @@ -1591,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", @@ -1614,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", @@ -1665,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", @@ -1721,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", @@ -1781,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", @@ -1817,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", @@ -1868,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", @@ -1940,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", @@ -2010,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", @@ -2070,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", @@ -2116,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", @@ -2194,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", @@ -2238,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", @@ -2288,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", @@ -2371,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", @@ -2397,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": { @@ -2489,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 35808c1016336a10baa8b483e83b77802113ab43..cc37b65da4d04beda28fb1fa68ea346395934bfa 100644 --- a/app/package.json +++ b/app/package.json @@ -12,21 +12,16 @@ }, "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": "1.1.4", - "hash-wasm": "^4.11.0", - "@yume-chan/adb": "1.1.0", - "@yume-chan/adb-daemon-webusb": "1.1.0", - "@yume-chan/adb-credential-web": "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/public/resources/mimir.json b/app/public/resources/mimir.json index 55098284abaebaf05adf12013de990bf0f02e56a..3810317de2c08843841e8dc300d9dc71d69d5676 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 bfe32a731f9603d3f4be2d111ef9e11af8ab6ec9..5bb142957c3a1a9e83d86b31b415bc7cea993c32 100644 --- a/app/src/controller.manager.js +++ b/app/src/controller.manager.js @@ -33,19 +33,41 @@ export class Controller { WDebug.log("Controller Manager Next", next); if (next) { - //K1ZFP check this 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.deviceManager.isConnected() && !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`); } - if (!this.deviceManager.isConnected()) { + 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); @@ -138,12 +160,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."; @@ -162,14 +182,22 @@ 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 = this.resources?.flash_cooldown_ms ?? 2500; + const result = await this.deviceManager.flash( cmd.file, cmd.partition, (done, total) => { this.view.onInstalling(cmd.file, done, total); }, ); + // Small delay between flash operations to prevent overwhelming the device + WDebug.log( + `Flash cooldown: waiting ${FLASH_COOLDOWN_MS}ms before next operation`, + ); + await new Promise((resolve) => setTimeout(resolve, FLASH_COOLDOWN_MS)); + return result; + } case Command.CMD_TYPE.unlock: { //check if unlocked to avoid unnecessary command let isUnlocked = false; @@ -191,13 +219,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 +259,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}`); } } } @@ -246,16 +279,14 @@ 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 { - this.deviceManager.for(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; @@ -269,8 +300,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); @@ -304,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) @@ -317,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(); @@ -331,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); } } @@ -366,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); } } @@ -426,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 74aff737139366989a321faacb6fa9f4f8c7ecf9..3088a1fc870474847409502167eba0c5bb9a0fd0 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", @@ -34,15 +35,15 @@ export class DeviceManager { await this.downloader.init(); } - wasAlreadyConnected() { - if (this.wasConnected == false) { - this.wasConnected = true; - return false; - } - return true; + isFirstConnection() { + return !this.wasConnected; } - setResources(folder, steps) { + markAsConnected() { + this.wasConnected = true; + } + + setResources(folder, steps, options) { this.folder = folder; this.files = steps .map((s) => { @@ -51,6 +52,9 @@ export class DeviceManager { }); }) .flat(); + if (options?.skipClearHalt) { + this.bootloader.skipClearHalt = true; + } } async getUnlocked(variable) { @@ -98,30 +102,26 @@ 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; } - erase(partition) { - return this.bootloader.runCommand(`erase:${partition}`); + async erase(partition) { + await this.bootloader.runCommand(`erase:${partition}`); + return true; } format() { @@ -130,12 +130,18 @@ 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) { + // 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; } - lock(command) { - return this.bootloader.runCommand(command); + async lock(command) { + // Lock may also require physical confirmation on the device. + await this.bootloader.runCommand(command, 300_000); + return true; } async flash(file, partition, onProgress) { @@ -185,6 +191,64 @@ 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/adb.class.js b/app/src/controller/device/adb.class.js index 04be40556d3f9e7f350b3b5a992ebb3b9ea59eb0..bb6875c88f7c8820358a8c6e6f8713fde8f635f6 100644 --- a/app/src/controller/device/adb.class.js +++ b/app/src/controller/device/adb.class.js @@ -1,27 +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; - } - - async isConnected() { - if (!this.device) { - return false; - } - try { - return this.device.getDevice(); - } catch { - return false; - } + this._adbDevice = null; } isADB() { @@ -32,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)"); - } + // Try to find a paired device first, then request if needed + this._adbDevice = await AdbDevice.requestDevice(); + await this._adbDevice.connect(); - 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); - - 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); @@ -76,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 5c34fd353e107b5df4a779ba927a70097aeb9890..d0982cd854c1a6b786b4ba6fcf10c647dbff0218 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,30 +12,21 @@ import { WDebug } from "../../debug.js"; * */ export class Bootloader extends Device { constructor() { - super(new FastbootDevice()); + super(null); + this.fastboot = null; + this.skipClearHalt = false; } 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); - } - - isConnected() { - return this.device.isConnected; + runCommand(command, timeoutMs) { + return this.fastboot.runCommand(command, timeoutMs); } isBootloader() { @@ -44,45 +34,204 @@ 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 + const connectStart = Date.now(); + + WDebug.log( + `Bootloader.connect() starting, maxAttempts=${MAX_CONNECT_ATTEMPTS}, ` + + `retryDelay=${CONNECT_RETRY_DELAY}ms`, + ); + + for (let attempt = 1; attempt <= MAX_CONNECT_ATTEMPTS; attempt++) { + try { + // Log paired devices before each attempt for debugging + const pairedDevices = await navigator.usb.getDevices(); + WDebug.log( + `Bootloader.connect() attempt ${attempt}/${MAX_CONNECT_ATTEMPTS}: ` + + `${pairedDevices.length} paired USB device(s)`, + pairedDevices.map( + (d) => `${d.vendorId}:${d.productId} "${d.productName}"`, + ), + ); + + // 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({ + skipClearHalt: this.skipClearHalt, + }); + + const elapsed = Date.now() - connectStart; + WDebug.log( + `Bootloader.connect() succeeded on attempt ${attempt} after ${elapsed}ms`, + ); + return; + } catch (e) { + const errorMsg = e.message || String(e); + const elapsed = Date.now() - connectStart; + WDebug.log( + `Bootloader.connect() attempt ${attempt} failed after ${elapsed}ms: ${errorMsg}`, + ); + + // If this is the last attempt, throw the error + if (attempt === MAX_CONNECT_ATTEMPTS) { + throw new Error( + `Cannot connect to bootloader after ${MAX_CONNECT_ATTEMPTS} attempts. ` + + `The device may not be in bootloader mode yet. ` + + `Please ensure the device is in bootloader/fastboot mode and try again. ` + + `Error: ${errorMsg}`, + ); + } + + // Wait before retry, with increasing delay + const delay = CONNECT_RETRY_DELAY * attempt; + 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 (this.fastboot) { + WDebug.log("Bootloader.connect() attempting USB device reset..."); + try { + 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 + } + } + } } } getProductName() { - return this.device.device.productName; + return this.fastboot?.usbDevice?.productName; } getSerialNumber() { - return this.device.device.serialNumber; + return this.fastboot?.usbDevice?.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); - }, - ); + /** + * 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() { + if (!this.fastboot) { + WDebug.log("reconnectDevice: no fastboot device reference, skipping"); + return; + } + + WDebug.log("reconnectDevice: reconnecting USB session..."); + try { + await this.fastboot.reconnect(); + WDebug.log( + `reconnectDevice: connection re-established, isConnected=${this.fastboot.isConnected}`, + ); + } catch (e) { + WDebug.log(`reconnectDevice: reconnect failed: ${e.message || e}`); + throw e; + } } - async flashBlob(partition, blob, onProgress) { + async flashBlob(partition, blob, onProgress, attempt = 1) { + const MAX_ATTEMPTS = 3; + 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.fastboot?.isConnected) { + throw new Error(`Device disconnected before flashing ${partition}`); + } + try { - await this.device.flashBlob(partition, blob, (progress) => { - onProgress(progress * blob.size, blob.size, partition); + WDebug.log( + `flashBlob: ${partition} (${(blob.size / 1024 / 1024).toFixed(1)} MB), ` + + `attempt ${attempt}/${MAX_ATTEMPTS}`, + ); + await this.fastboot.flashBlob(partition, blob, (sent, total) => { + onProgress(sent, total, partition); }); onProgress(blob.size, blob.size, partition); + const elapsed = Date.now() - flashStart; + WDebug.log(`flashBlob: ${partition} succeeded in ${elapsed}ms`); return true; } catch (e) { if (e instanceof TimeoutError) { - WDebug.log("Timeout on flashblob >" + partition); - return await this.flashBlob(partition, blob, onProgress); + const elapsed = Date.now() - flashStart; + WDebug.log( + `flashBlob: timeout on ${partition} after ${elapsed}ms ` + + `(attempt ${attempt}/${MAX_ATTEMPTS})`, + ); + if (attempt < MAX_ATTEMPTS) { + WDebug.log(`flashBlob: waiting ${RETRY_DELAY_MS}ms before retry...`); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + + // Try to reset USB device to clear stale state + WDebug.log("flashBlob: attempting USB device reset..."); + 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 + 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 + if (!this.fastboot?.isConnected) { + 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.`, + ); } else { console.log("flashBlob error", e); throw new Error(`Bootloader error: ${e.message || e}`); @@ -91,16 +240,16 @@ 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(e); // K1ZFP TODO + console.error("isUnlocked check failed:", e); throw e; } } @@ -108,12 +257,12 @@ 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(e); //K1ZFP TODO + console.error("isLocked check failed:", e); throw e; } } @@ -122,18 +271,18 @@ export class Bootloader extends Device { async unlock(command) { if (command) { - await this.device.runCommand(command); + await this.fastboot.runCommand(command); } else { - throw Error("no unlock command configured"); //K1ZFP TODO + throw new Error("No unlock command configured for this device"); } } async lock(command) { if (command) { - await this.device.runCommand(command); + await this.fastboot.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"); } } } diff --git a/app/src/controller/device/device.class.js b/app/src/controller/device/device.class.js index 10bcdb9882dd7e4de92809440db167f9f4fdb40b..61a50dbffc118d08f07f8ab82ed91495c8064406 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 6746c61573fb497bc4c401b2b62ec91a21ad7cbf..fae5d33ab421f8d0e41945aaf423b7a80c5ff5d3 100644 --- a/app/src/controller/device/recovery.class.js +++ b/app/src/controller/device/recovery.class.js @@ -1,27 +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.webusb = null; - this.count = 0; - this.adbWebBackend = null; - } - - async isConnected() { - if (!this.device) { - return false; - } - try { - return this.device.getDevice(); - } catch { - return false; - } + this._adbDevice = null; } isRecovery() { @@ -30,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.interfaceSubclass === WebUsbDeviceFilter.subclassCode && - alternate.interfaceClass === WebUsbDeviceFilter.classCode && - alternate.interfaceSubclass === WebUsbDeviceFilter.subclassCode - ) { - 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.webusb.name; + return this._adbDevice?.usbDevice?.productName; } getSerialNumber() { - return this.webusb.product; - } - - 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/controller/downloader.manager.js b/app/src/controller/downloader.manager.js index 4a2937cf281aab51fa2c56d634b09db9ee48af7e..6024f77ee7270b91c83601c85e2e87a4a6ce9ae7 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() { @@ -22,7 +23,6 @@ export class Downloader { this.db = await this.openDBStore(); await this.clearDBStore(); - this.quota = await navigator.storage.estimate(); } /* @@ -80,20 +80,27 @@ 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)) { - 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) => { @@ -105,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}`, + ); + } } } } @@ -188,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); } @@ -240,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?)")); }); } diff --git a/app/src/lib/adb/adb-auth.ts b/app/src/lib/adb/adb-auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..94de09d1a7dde9ac53d3153f8d57561ce7f2af15 --- /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 0000000000000000000000000000000000000000..8b14f1195a65def846c1e544196c935d1ccc65b6 --- /dev/null +++ b/app/src/lib/adb/adb-device.ts @@ -0,0 +1,385 @@ +/** + * 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(); + + // 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 + 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 0000000000000000000000000000000000000000..5e868153fc741084e96185f4666308531c964128 --- /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 0000000000000000000000000000000000000000..4fdbf0c2604eeb84c9721ccf53331461e3acf88c --- /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 0000000000000000000000000000000000000000..e47a4b664a2d64acba347f6a0cc84c3c82aeb966 --- /dev/null +++ b/app/src/lib/adb/adb-stream.ts @@ -0,0 +1,165 @@ +/** + * 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. + // 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)`, + ); + } + + 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 0000000000000000000000000000000000000000..bdeac6fa7e008a22598bda04d00ca4ef3758f4fc --- /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 0000000000000000000000000000000000000000..3722fc2b1e76230db79d04ee098ecac25d6c23cc --- /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 0000000000000000000000000000000000000000..6f767e17f96de3671957c2ddfa28828a55d74910 --- /dev/null +++ b/app/src/lib/fastboot/fastboot-device.ts @@ -0,0 +1,358 @@ +/** + * 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 _currentSlot: string | 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(options?: { skipClearHalt?: boolean }): Promise { + if (this._connected) return; + + await this._transport.open(options); + + // 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; + this._currentSlot = 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, timeoutMs?: number): Promise { + this.ensureConnected(); + const result = await sendCommand(this._transport, command, timeoutMs); + 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. + * Resolves A/B slot suffix automatically when needed. + */ + async flashBlob( + partition: string, + blob: Blob, + onProgress?: ProgressCallback, + ): 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(resolved, blob, onProgress); + } else { + await this.flashRawBlob(resolved, blob, onProgress); + } + } + + /** + * Erase a partition. + * Resolves A/B slot suffix automatically when needed. + */ + async erase(partition: string): Promise { + this.ensureConnected(); + const resolved = await this.resolvePartition(partition); + await erasePartition(this._transport, resolved); + } + + /** + * 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; + this._currentSlot = 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. + * + * 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"); + 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; + } + } catch { + // Default to 512 MB + this._maxDownloadSize = 512 * 1024 * 1024; + } + + log(`Max download size: ${this._maxDownloadSize} bytes`); + 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. + */ + 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); + await this.waitDeviceReady(); + } + + /** + * 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)`, + ); + } + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..f8d2811e5ff811939ae3bb80c4e35d353d23fc34 --- /dev/null +++ b/app/src/lib/fastboot/fastboot-protocol.ts @@ -0,0 +1,219 @@ +/** + * 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); + + // 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)); + } + } +} + +/** + * 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); + + // 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); +} + +/** + * 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 0000000000000000000000000000000000000000..0af9c2962fc7f674c1a60f986e29dd2338e2ab53 --- /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 0000000000000000000000000000000000000000..99f70d5c01a8dcdb0fc740f4323a476ec4e5315e --- /dev/null +++ b/app/src/lib/fastboot/sparse-image.ts @@ -0,0 +1,280 @@ +/** + * 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. + * + * 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, + 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. + // 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); + 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, header.totalBlocks, currentChunks, blocksWrittenSoFar), + ); + blocksWrittenSoFar += currentBlocks; + currentChunks = []; + // Reserve space for the DONT_CARE prefix chunk in subsequent sub-images + currentSize = SPARSE_HEADER_SIZE + SPARSE_CHUNK_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, header.totalBlocks, currentChunks, blocksWrittenSoFar), + ); + } + + return subImages; +} + +/** + * 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, + originalTotalBlocks: number, + chunks: Array<{ + header: SparseChunkHeader; + offset: number; + blocks: number; + }>, + blocksOffset: number, +): Blob { + 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; + // Slice the original chunk (header + data) from the source blob + parts.push(originalBlob.slice(chunk.offset, chunk.offset + chunkTotalSize)); + } + + 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); +} diff --git a/app/src/lib/fastboot/types.ts b/app/src/lib/fastboot/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..13fb6132934f58a857258aa4650c7121fa6a5785 --- /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 0000000000000000000000000000000000000000..29dd82df274e0052b06b8d943f0ef2da22d9357a --- /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 0000000000000000000000000000000000000000..94cc37818721b6204d7ad17aa64c3afcd4482c1f --- /dev/null +++ b/app/src/lib/transport/types.ts @@ -0,0 +1,30 @@ +/** + * 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; + alternateSetting: number; +} diff --git a/app/src/lib/transport/webusb.ts b/app/src/lib/transport/webusb.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab83a08712d345128bfc3764a13d4b9e08567bf4 --- /dev/null +++ b/app/src/lib/transport/webusb.ts @@ -0,0 +1,511 @@ +/** + * 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(options?: { skipClearHalt?: boolean }): 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); + + // 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 + // transferIn/transferOut to fail with "A transfer error has occurred". + // 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); + 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..."); + + // 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 after reset + 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}`); + } + if (result.bytesWritten !== undefined && result.bytesWritten !== data.byteLength) { + throw new UsbError( + `USB transferOut incomplete: wrote ${result.bytesWritten}/${data.byteLength} bytes`, + ); + } + } + + /** + * 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, + alternateSetting: alt.alternateSetting, + }; + } + } + } + } + + 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 0000000000000000000000000000000000000000..7c180ef7f9cf9351083d581f55d97d2f6271e83c --- /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 0000000000000000000000000000000000000000..c601771e8710694feca9f779d0a0faefa580b357 --- /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 3cd15caa950b78f7c4cb6417c8b29be6d17a7eed..16ef1eca2b4fff59ccabfa1c7372cbb52c18d7fc 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", - }, - ], - }), - ], });