diff --git a/.eslintrc.json b/.eslintrc.json index dc9027bf..57819752 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,33 +1,18 @@ { "root": true, + "parser": "@typescript-eslint/parser", "env": { "browser": true, "es2020": true, "node": true }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:react/jsx-runtime", - "plugin:react-hooks/recommended" - ], - "ignorePatterns": [ - "node_modules", - "dist", - ".eslintrc.cjs" - ], + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], + "ignorePatterns": ["node_modules", "dist", ".eslintrc.cjs"], + "plugins": ["@typescript-eslint"], "parserOptions": { "ecmaVersion": "latest", "sourceType": "module" }, - "settings": { - "react": { - "version": "detect" - } - }, - "plugins": [ - "react-refresh" - ], "rules": { "no-constant-condition": [ "error", @@ -35,14 +20,6 @@ "checkLoops": false } ], - "no-inner-declarations": "off", - "react/jsx-no-target-blank": "off", - "react/prop-types": "off", - "react-refresh/only-export-components": [ - "warn", - { - "allowConstantExport": true - } - ] + "no-inner-declarations": "off" } } diff --git a/.gitignore b/.gitignore index c5eab7ea..0ff36cc2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,11 @@ .idea *.iml .vscode +!.vscode/extensions.json + # dependencies -/node_modules +node_modules /.pnp .pnp.js /.pnpm-store @@ -20,16 +22,28 @@ /out/ # production -/build +build +dist +dist-ssr # misc .DS_Store *.pem +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + # debug npm-debug.log* yarn-debug.log* yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +logs +*.log # local env files .env*.local diff --git a/src/assets/bolt.svg b/assets/bolt.svg similarity index 100% rename from src/assets/bolt.svg rename to assets/bolt.svg diff --git a/src/assets/cable.svg b/assets/cable.svg similarity index 100% rename from src/assets/cable.svg rename to assets/cable.svg diff --git a/src/assets/cloud.svg b/assets/cloud.svg similarity index 100% rename from src/assets/cloud.svg rename to assets/cloud.svg diff --git a/src/assets/cloud_download.svg b/assets/cloud_download.svg similarity index 100% rename from src/assets/cloud_download.svg rename to assets/cloud_download.svg diff --git a/src/assets/cloud_error.svg b/assets/cloud_error.svg similarity index 100% rename from src/assets/cloud_error.svg rename to assets/cloud_error.svg diff --git a/src/assets/comma.svg b/assets/comma.svg similarity index 100% rename from src/assets/comma.svg rename to assets/comma.svg diff --git a/src/assets/device_exclamation_c3.svg b/assets/device_exclamation_c3.svg similarity index 100% rename from src/assets/device_exclamation_c3.svg rename to assets/device_exclamation_c3.svg diff --git a/src/assets/device_question_c3.svg b/assets/device_question_c3.svg similarity index 100% rename from src/assets/device_question_c3.svg rename to assets/device_question_c3.svg diff --git a/src/assets/done.svg b/assets/done.svg similarity index 100% rename from src/assets/done.svg rename to assets/done.svg diff --git a/src/assets/exclamation.svg b/assets/exclamation.svg similarity index 100% rename from src/assets/exclamation.svg rename to assets/exclamation.svg diff --git a/src/assets/fastboot-ports.svg b/assets/fastboot-ports.svg similarity index 100% rename from src/assets/fastboot-ports.svg rename to assets/fastboot-ports.svg diff --git a/src/assets/frame_alert.svg b/assets/frame_alert.svg similarity index 100% rename from src/assets/frame_alert.svg rename to assets/frame_alert.svg diff --git a/src/assets/system_update_c3.svg b/assets/system_update_c3.svg similarity index 100% rename from src/assets/system_update_c3.svg rename to assets/system_update_c3.svg diff --git a/src/assets/zadig_create_new_device.png b/assets/zadig_create_new_device.png similarity index 100% rename from src/assets/zadig_create_new_device.png rename to assets/zadig_create_new_device.png diff --git a/src/assets/zadig_form.png b/assets/zadig_form.png similarity index 100% rename from src/assets/zadig_form.png rename to assets/zadig_form.png diff --git a/bun.lockb b/bun.lockb index c6d81dfa..f7d966a2 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/index.html b/index.html index 3060b4a2..036cf674 100644 --- a/index.html +++ b/index.html @@ -1,22 +1,249 @@ - - - - - - flash.comma.ai - - -
- - - + + + + + flash.comma.ai + + + +
+
+
+ comma +

flash.comma.ai

+

+ This tool allows you to flash AGNOS onto your comma + device. +

+

+ AGNOS is the Ubuntu-based operating system for your + + comma 3/3X + + . +

+
+
+
+

Requirements

+
    +
  • + A web browser which supports WebUSB (such as Google + Chrome, Microsoft Edge, Opera), running on Windows, + macOS, Linux, or Android. +
  • +
  • + A USB-C cable to power your device outside the car. +
  • +
  • + Another USB-C cable to connect the device to your + computer. +
  • +
+

USB Driver

+

+ You need additional driver software for Windows before + you connect your device. +

+
    +
  1. + Download and install + Zadig. +
  2. +
  3. + Under Device in the menu bar, select + Create New Device. + Zadig Create New Device +
  4. +
  5. + Fill in three fields. The first field is just a + description and you can fill in anything. The next + two fields are very important. Fill them in with + 18D1 and D00D + respectively. Press "Install Driver" and + give it a few minutes to install. + Zadig Form +
  6. +
+

+ No additional software is required for macOS or Linux. +

+
+
+ +
+

Fastboot

+

+ Follow these steps to put your device into fastboot + mode: +

+
    +
  1. + Power off the device and wait for the LEDs to switch + off. +
  2. +
  3. + Connect power to the OBD-C port + (port 1). +
  4. +
  5. + Then, + + quickly + + connect the device to your computer using the USB-C + port + (port 2). +
  6. +
  7. + After a few seconds, the device should indicate + it's in fastboot mode and show its serial + number. +
  8. +
+ image showing comma three and two ports. the upper port is labeled 1. the lower port is labeled 2. +

+ If your device shows the comma spinner with a loading + bar, then it's not in fastboot mode. Unplug all + cables, wait for the device to switch off, and try + again. +

+
+
+ +
+

Flashing

+

+ After your device is in fastboot mode, you can click the + button to start flashing. A prompt may appear to select + a device; choose the device labeled "Android". +

+

+ The process can take 15+ minutes depending on your + internet connection and system performance. Do not + unplug the device until all steps are complete. +

+
+
+ +
+

Troubleshooting

+

+ Cannot enter fastboot or device says "Press any key + to continue" +

+

+ Try using a different USB cable or USB port. Sometimes + USB 2.0 ports work better than USB 3.0 (blue) ports. If + you're using a USB hub, try connecting the device + directly to your computer, or alternatively use a USB + hub between your computer and the device. +

+

My device's screen is blank

+

+ The device can still be in fastboot mode and reflashed + normally if the screen isn't displaying anything. A + blank screen is usually caused by installing older + software that doesn't support newer displays. If a + reflash doesn't fix the blank screen, then the + device's display may be damaged. +

+

+ After flashing, device says unable to mount data + partition +

+

+ This is expected after the filesystem is erased. Press + confirm to finish resetting your device. +

+

General Tips

+
    +
  • Try another computer or OS
  • +
  • Try different USB ports on your computer
  • +
  • + Try different USB-C cables, including the OBD-C + cable that came with the device +
  • +
+

Other questions

+

+ If you need help, join our + + Discord server + + and go to the #hw-three-3x channel. +

+
+ + +
+
+
+
+ cable +
+
+
+
+
+
+ + +
+
+
+ + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..64e69705 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1854 @@ +{ + "name": "@commaai/flash", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@commaai/flash", + "version": "0.1.0", + "dependencies": { + "@fontsource-variable/inter": "^5.0.19", + "@fontsource-variable/jetbrains-mono": "^5.0.21", + "android-fastboot": "github:commaai/fastboot.js#c3ec6fe3c96a48dab46e23d0c8c861af15b2144a", + "comlink": "^4.4.1", + "jssha": "^3.3.1", + "xz-decompress": "^0.2.2" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.13", + "@types/node": "^20.14.11", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.6", + "typescript": "^5.2.2", + "vite": "^5.3.4" + }, + "engines": { + "node": ">=20.11.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.0.19", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.0.19.tgz", + "integrity": "sha512-V5KPpF5o0sI1uNWAdFArC87NDOb/ZJDPXLomEiKmDCYMlDUCTn2flkuAZkyME2rtGOKO7vzCuDJAND0m/5PhDA==" + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.0.21", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.0.21.tgz", + "integrity": "sha512-LL/57KBbM3r0UMuN6tSeYExiBObt0QuGq49m1FyoDFIv1GAcuKU0EQ/GAKJ/yt3R8onOCD3f5X9Dln//G6uzRQ==" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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, + "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, + "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, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.1.tgz", + "integrity": "sha512-vk+ma8iC1ebje/ahpxpnrfVQJibTMyHdWpOGZ3JpQ7Mgn/3QNHmPq7YwjZbIE7km73dH5M1e6MRRsnEBW7v5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.13.tgz", + "integrity": "sha512-ADGcJ8dX21dVVHIwTRgzrcunY6YY9uSlAHHGVKvkA+vLc5qLwEszvKts40lx7z0qc4clpjclwLeK5rVCV2P/uw==", + "dev": true, + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.14.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", + "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@zip.js/zip.js": { + "version": "2.7.47", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.47.tgz", + "integrity": "sha512-jmtJMA3/Jl4rMzo/DZ79s6g0CJ1AZcNAO6emTy/vHfIKAB/iiFY7PLs6KmbRTJ+F8GnK2eCLnjQfCCneRxXgzg==", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=16.5.0" + } + }, + "node_modules/android-fastboot": { + "version": "1.1.5-commaai", + "resolved": "git+ssh://git@github.com/commaai/fastboot.js.git#c3ec6fe3c96a48dab46e23d0c8c861af15b2144a", + "integrity": "sha512-fbmXKrcRlUpXz+hq05Xi2ySy2VFvXLy5KSWqv+mYCL/KopFxWd069qAAin7tOBh59VU7qfNrjMqED5Sq3uhwEw==", + "dependencies": { + "@zip.js/zip.js": "^2.7.6", + "pako": "^2.1.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "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, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/autoprefixer": { + "version": "10.4.19", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.23.0", + "caniuse-lite": "^1.0.30001599", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "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, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.2.tgz", + "integrity": "sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001640", + "electron-to-chromium": "^1.4.820", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.1.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001642", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001642.tgz", + "integrity": "sha512-3XQ0DoRgLijXJErLSl+bLnJ+Et4KqV1PY6JJBGAFlsNsz31zeAIncyeZfLCabHK/jtSh+671RM9YMldxjUPZtA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "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, + "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, + "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", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/comlink": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.1.tgz", + "integrity": "sha512-+1dlx0aY5Jo1vHy/tSsIGpSkN4tS9rZSW8FIhG0JH/crs9wwweswIo/POr451r7bZww3hFbPAKnTpimzL/mm4Q==" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.4.829", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.829.tgz", + "integrity": "sha512-5qp1N2POAfW0u1qGAxXEtz6P7bO1m6gpZr5hdf5ve6lxpLM7MpiM4jIPz7xcrNlClQMafbyUDDWjlIQZ1Mw0Rw==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "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.4" + }, + "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, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "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, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", + "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.0.tgz", + "integrity": "sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "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, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/jssha": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz", + "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==", + "engines": { + "node": "*" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "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, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", + "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.17.tgz", + "integrity": "sha512-Ww6ZlOiEQfPfXM45v17oabk77Z7mg5bOt7AjDyzy7RjK9OrLrLC8dyZQoAPEOtFX9SaNf1Tdvr5gRJWdTJj7GA==", + "dev": true + }, + "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, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", + "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/postcss-nested": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.11" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz", + "integrity": "sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "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" + } + ] + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.1.tgz", + "integrity": "sha512-Elx2UT8lzxxOXMpy5HWQGZqkrQOtrVDDa/bm9l10+U4rQnVzbL/LgZ4NOM1MPIDyHk69W4InuYDF5dzRh4Kw1A==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.18.1", + "@rollup/rollup-android-arm64": "4.18.1", + "@rollup/rollup-darwin-arm64": "4.18.1", + "@rollup/rollup-darwin-x64": "4.18.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.18.1", + "@rollup/rollup-linux-arm-musleabihf": "4.18.1", + "@rollup/rollup-linux-arm64-gnu": "4.18.1", + "@rollup/rollup-linux-arm64-musl": "4.18.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.18.1", + "@rollup/rollup-linux-riscv64-gnu": "4.18.1", + "@rollup/rollup-linux-s390x-gnu": "4.18.1", + "@rollup/rollup-linux-x64-gnu": "4.18.1", + "@rollup/rollup-linux-x64-musl": "4.18.1", + "@rollup/rollup-win32-arm64-msvc": "4.18.1", + "@rollup/rollup-win32-ia32-msvc": "4.18.1", + "@rollup/rollup-win32-x64-msvc": "4.18.1", + "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" + } + ], + "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", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz", + "integrity": "sha512-1uRHzPB+Vzu57ocybfZ4jh5Q3SdlH7XW23J5sQoM9LhE9eIOlzxer/3XPSsycvih3rboRsvt0QCmzSrqyOYUIA==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.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, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/update-browserslist-db": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.2", + "picocolors": "^1.0.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.4.tgz", + "integrity": "sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.39", + "rollup": "^4.13.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xz-decompress": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/xz-decompress/-/xz-decompress-0.2.2.tgz", + "integrity": "sha512-DSOnX+ZLVTrsW+CtjZPwjrMWvuRkzCcEpwLsY2faZyVgLH/ZHpTg3h3+KyN16mGuduMgO+/pc9rSEG735oGN0g==", + "engines": { + "node": ">=16" + } + }, + "node_modules/yaml": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", + "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + } + } +} diff --git a/package.json b/package.json index 9c54987e..c2bfc3b6 100644 --- a/package.json +++ b/package.json @@ -5,41 +5,33 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", - "start": "vite preview", - "lint": "eslint . --ext js,jsx --report-unused-disable-directives", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,js --report-unused-disable-directives", "test": "vitest" }, "engines": { "node": ">=20.11.0" }, "dependencies": { - "@fontsource-variable/inter": "^5.0.18", + "@fontsource-variable/inter": "^5.0.19", "@fontsource-variable/jetbrains-mono": "^5.0.21", "android-fastboot": "github:commaai/fastboot.js#c3ec6fe3c96a48dab46e23d0c8c861af15b2144a", "comlink": "^4.4.1", "jssha": "^3.3.1", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "xz-decompress": "^0.2.1" + "xz-decompress": "^0.2.2" }, "devDependencies": { "@tailwindcss/typography": "^0.5.13", - "@testing-library/jest-dom": "^6.4.5", - "@testing-library/react": "^15.0.7", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.0", - "autoprefixer": "10.4.14", + "@types/node": "^20.14.11", + "@typescript-eslint/eslint-plugin": "^7.16.1", + "@typescript-eslint/parser": "^7.16.1", + "autoprefixer": "^10.4.19", "eslint": "^8.57.0", - "eslint-plugin-react": "^7.34.2", - "eslint-plugin-react-hooks": "^4.6.2", - "eslint-plugin-react-refresh": "^0.4.7", - "jsdom": "^22.1.0", - "postcss": "^8.4.38", - "tailwindcss": "^3.4.3", - "vite": "^5.2.12", - "vite-svg-loader": "^5.1.0", - "vitest": "^1.6.0" + "postcss": "^8.4.39", + "tailwindcss": "^3.4.6", + "typescript": "^5.2.2", + "vitest": "^1.6.0", + "vite": "^5.3.4" } } diff --git a/src/app/App.test.jsx b/src/app/App.test.jsx deleted file mode 100644 index 206de720..00000000 --- a/src/app/App.test.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Suspense } from 'react' -import { expect, test } from 'vitest' -import { render, screen } from '@testing-library/react' - -import App from '.' - -test('renders without crashing', () => { - render() - expect(screen.getByText('flash.comma.ai')).toBeInTheDocument() -}) diff --git a/src/app/Flash.jsx b/src/app/Flash.jsx deleted file mode 100644 index d7c544dc..00000000 --- a/src/app/Flash.jsx +++ /dev/null @@ -1,254 +0,0 @@ -import { useCallback } from 'react' - -import { Step, Error, useFastboot } from '@/utils/fastboot' - -import bolt from '@/assets/bolt.svg' -import cable from '@/assets/cable.svg' -import cloud from '@/assets/cloud.svg' -import cloudDownload from '@/assets/cloud_download.svg' -import cloudError from '@/assets/cloud_error.svg' -import deviceExclamation from '@/assets/device_exclamation_c3.svg' -import deviceQuestion from '@/assets/device_question_c3.svg' -import done from '@/assets/done.svg' -import exclamation from '@/assets/exclamation.svg' -import frameAlert from '@/assets/frame_alert.svg' -import systemUpdate from '@/assets/system_update_c3.svg' - - -const steps = { - [Step.INITIALIZING]: { - status: 'Initializing...', - bgColor: 'bg-gray-400 dark:bg-gray-700', - icon: cloud, - }, - [Step.READY]: { - status: 'Ready', - description: 'Tap the button above to begin', - bgColor: 'bg-[#51ff00]', - icon: bolt, - iconStyle: '', - }, - [Step.CONNECTING]: { - status: 'Waiting for connection', - description: 'Follow the instructions to connect your device to your computer', - bgColor: 'bg-yellow-500', - icon: cable, - }, - [Step.DOWNLOADING]: { - status: 'Downloading...', - bgColor: 'bg-blue-500', - icon: cloudDownload, - }, - [Step.UNPACKING]: { - status: 'Unpacking...', - bgColor: 'bg-blue-500', - icon: cloudDownload, - }, - [Step.FLASHING]: { - status: 'Flashing device...', - description: 'Do not unplug your device until the process is complete.', - bgColor: 'bg-lime-400', - icon: systemUpdate, - }, - [Step.ERASING]: { - status: 'Erasing device...', - bgColor: 'bg-lime-400', - icon: systemUpdate, - }, - [Step.DONE]: { - status: 'Done', - description: 'Your device has been updated successfully. You can now unplug the USB cable from your computer. To ' + - 'complete the system reset, follow the instructions on your device.', - bgColor: 'bg-green-500', - icon: done, - }, -} - -const errors = { - [Error.UNKNOWN]: { - status: 'Unknown error', - description: 'An unknown error has occurred. Restart your browser and try again.', - bgColor: 'bg-red-500', - icon: exclamation, - }, - [Error.UNRECOGNIZED_DEVICE]: { - status: 'Unrecognized device', - description: 'The device connected to your computer is not supported.', - bgColor: 'bg-yellow-500', - icon: deviceQuestion, - }, - [Error.LOST_CONNECTION]: { - status: 'Lost connection', - description: 'The connection to your device was lost. Check that your cables are connected properly and try again.', - icon: cable, - }, - [Error.DOWNLOAD_FAILED]: { - status: 'Download failed', - description: 'The system image could not be downloaded. Check your internet connection and try again.', - icon: cloudError, - }, - [Error.CHECKSUM_MISMATCH]: { - status: 'Download mismatch', - description: 'The system image downloaded does not match the expected checksum. Try again.', - icon: frameAlert, - }, - [Error.FLASH_FAILED]: { - status: 'Flash failed', - description: 'The system image could not be flashed to your device. Try using a different cable, USB port, or ' + - 'computer. If the problem persists, join the #hw-three-3x channel on Discord for help.', - icon: deviceExclamation, - }, - [Error.ERASE_FAILED]: { - status: 'Erase failed', - description: 'The device could not be erased. Try using a different cable, USB port, or computer. If the problem ' + - 'persists, join the #hw-three-3x channel on Discord for help.', - icon: deviceExclamation, - }, - [Error.REQUIREMENTS_NOT_MET]: { - status: 'Requirements not met', - description: 'Your system does not meet the requirements to flash your device. Make sure to use a browser which ' + - 'supports WebUSB and is up to date.', - }, -} - - -function LinearProgress({ value, barColor }) { - if (value === -1 || value > 100) value = 100 - return ( -
-
-
- ) -} - - -function USBIndicator() { - return
- - - - Device connected -
-} - - -function SerialIndicator({ serial }) { - return
- - Serial: - {serial || 'unknown'} - -
-} - - -function DeviceState({ serial }) { - return ( -
- - | - -
- ) -} - - -function beforeUnloadListener(event) { - // NOTE: not all browsers will show this message - event.preventDefault() - return (event.returnValue = "Flash in progress. Are you sure you want to leave?") -} - - -export default function Flash() { - const { - step, - message, - progress, - error, - - onContinue, - onRetry, - - connected, - serial, - } = useFastboot() - - const handleContinue = useCallback(() => { - onContinue?.() - }, [onContinue]) - - const handleRetry = useCallback(() => { - onRetry?.() - }, [onRetry]) - - const uiState = steps[step] - if (error) { - Object.assign(uiState, errors[Error.UNKNOWN], errors[error]) - } - const { status, description, bgColor, icon, iconStyle = 'invert' } = uiState - - let title - if (message && !error) { - title = message + '...' - if (progress >= 0) { - title += ` (${(progress * 100).toFixed(0)}%)` - } - } else { - title = status - } - - // warn the user if they try to leave the page while flashing - if (Step.DOWNLOADING <= step && step <= Step.ERASING) { - window.addEventListener("beforeunload", beforeUnloadListener, { capture: true }) - } else { - window.removeEventListener("beforeunload", beforeUnloadListener, { capture: true }) - } - - return ( -
-
- cable -
-
- -
- {title} - {description} - {error && ( - - ) || false} - {connected && } -
- ) -} diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index fad53e89..00000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/icon.png b/src/app/icon.png deleted file mode 100644 index d5a6bf26..00000000 Binary files a/src/app/icon.png and /dev/null differ diff --git a/src/app/icon.svg b/src/app/icon.svg deleted file mode 100644 index c2688769..00000000 --- a/src/app/icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/app/index.jsx b/src/app/index.jsx deleted file mode 100644 index 54828b10..00000000 --- a/src/app/index.jsx +++ /dev/null @@ -1,162 +0,0 @@ -import { Suspense, lazy } from 'react' - -import comma from '../assets/comma.svg' -import fastbootPorts from '../assets/fastboot-ports.svg' -import zadigCreateNewDevice from '../assets/zadig_create_new_device.png' -import zadigForm from '../assets/zadig_form.png' - -const Flash = lazy(() => import('./Flash')) - -export default function App() { - const version = import.meta.env.VITE_PUBLIC_GIT_SHA || 'dev' - console.info(`flash.comma.ai version: ${version}`); - return ( -
-
-
- comma -

flash.comma.ai

- -

This tool allows you to flash AGNOS onto your comma device.

-

- AGNOS is the Ubuntu-based operating system for your{" "} - comma 3/3X. -

-
-
- -
-

Requirements

-
    -
  • - A web browser which supports WebUSB (such as Google Chrome, Microsoft Edge, Opera), running on Windows, macOS, Linux, or Android. -
  • -
  • - A USB-C cable to power your device outside the car. -
  • -
  • - Another USB-C cable to connect the device to your computer. -
  • -
-

USB Driver

-

- You need additional driver software for Windows before you connect - your device. -

-
    -
  1. - Download and install Zadig. -
  2. -
  3. - Under Device in the menu bar, select Create New Device. - Zadig Create New Device -
  4. -
  5. - Fill in three fields. The first field is just a description and - you can fill in anything. The next two fields are very important. - Fill them in with 18D1 and D00D respectively. - Press "Install Driver" and give it a few minutes to install. - Zadig Form -
  6. -
-

- No additional software is required for macOS or Linux. -

-
-
- -
-

Fastboot

-

Follow these steps to put your device into fastboot mode:

-
    -
  1. Power off the device and wait for the LEDs to switch off.
  2. -
  3. Connect power to the OBD-C port (port 1).
  4. -
  5. Then, quickly connect - the device to your computer using the USB-C port (port 2).
  6. -
  7. After a few seconds, the device should indicate it's in fastboot mode and show its serial number.
  8. -
- image showing comma three and two ports. the upper port is labeled 1. the lower port is labeled 2. -

- If your device shows the comma spinner with a loading bar, then it's not in fastboot mode. - Unplug all cables, wait for the device to switch off, and try again. -

-
-
- -
-

Flashing

-

- After your device is in fastboot mode, you can click the button to start flashing. A prompt may appear to - select a device; choose the device labeled "Android". -

-

- The process can take 15+ minutes depending on your internet connection and system performance. Do not - unplug the device until all steps are complete. -

-
-
- -
-

Troubleshooting

-

Cannot enter fastboot or device says "Press any key to continue"

-

- Try using a different USB cable or USB port. Sometimes USB 2.0 ports work better than USB 3.0 (blue) ports. - If you're using a USB hub, try connecting the device directly to your computer, or alternatively use a - USB hub between your computer and the device. -

-

My device's screen is blank

-

- The device can still be in fastboot mode and reflashed normally if the screen isn't displaying - anything. A blank screen is usually caused by installing older software that doesn't support newer - displays. If a reflash doesn't fix the blank screen, then the device's display may be damaged. -

-

After flashing, device says unable to mount data partition

-

- This is expected after the filesystem is erased. Press confirm to finish resetting your device. -

-

General Tips

-
    -
  • Try another computer or OS
  • -
  • Try different USB ports on your computer
  • -
  • Try different USB-C cables, including the OBD-C cable that came with the device
  • -
-

Other questions

-

- If you need help, join our Discord server and go to - the #hw-three-3x channel. -

-
- -
-
- flash.comma.ai version: {version} -
-
- -
- Loading...

}> - -
-
- -
- flash.comma.ai version: {version.substring(0, 7)} -
-
- ) -} diff --git a/src/config.js b/src/config.ts similarity index 100% rename from src/config.js rename to src/config.ts diff --git a/src/main.jsx b/src/main.jsx deleted file mode 100644 index 40fea3ef..00000000 --- a/src/main.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' - -import '@fontsource-variable/inter' -import '@fontsource-variable/jetbrains-mono' - -import './index.css' -import App from './app' - -ReactDOM.createRoot(document.getElementById('root')).render( - - - , -) diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 00000000..e094530b --- /dev/null +++ b/src/main.ts @@ -0,0 +1,220 @@ +import { + FastbootError, + FastbootManager, + FastbootManagerStateType, + FastbootStep, +} from "./utils/fastboot"; +import "@fontsource-variable/inter"; +import "@fontsource-variable/jetbrains-mono"; +import "./index.css"; + +const fb = new FastbootManager(); +fb.init(); + +function setupProgressIndicatorView(initialState: FastbootManagerStateType) { + renderProgressIndicator(initialState); + fb.on("progress", renderProgressIndicator); +} + +function setupStatusView(initialState: FastbootManagerStateType) { + renderStatusView(initialState); + fb.on("message", renderStatusView); + fb.on("step", renderStatusView); + fb.on("error", renderStatusView); +} + +function setupIconView(initialState: FastbootManagerStateType) { + renderIconView(initialState); + fb.on("step", renderIconView); + fb.on("error", renderIconView); +} + +function setupRetryButtonView(initialState: FastbootManagerStateType) { + renderRetryButtonView(initialState); + fb.on("error", renderRetryButtonView); +} + +function setupDeviceStatusView(initialState: FastbootManagerStateType) { + renderDeviceStateView(initialState); + fb.on("connected", renderDeviceStateView); + fb.on("serial", renderDeviceStateView); +} + +function renderProgressIndicator(state: FastbootManagerStateType) { + const el = document.getElementById("linear-progress")!; + const ctnEl = document.getElementById("linear-progress-ctn")!; + + const { progress, step } = state; + el.style.transform = `translateX(${progress - 100}%)`; + el.className = `absolute top-0 bottom-0 left-0 w-full transition-all ${fbSteps[step].bgColor}`; + ctnEl.style.opacity = progress === -1 ? "0" : "1"; +} + +function renderStatusView(state: FastbootManagerStateType) { + const el = document.getElementById("title")!; + const subtitleEl = document.getElementById("subtitle")!; + + const { message, error, progress, step } = state; + let title; + if (message && !error) { + title = message + "..."; + if (progress >= 0) { + title += ` (${(progress * 100).toFixed(0)}%)`; + } + } else { + title = fbSteps[step].status; + } + el.innerHTML = title; + subtitleEl.innerHTML = fbSteps[step].description ?? ""; +} + +function renderIconView(state: FastbootManagerStateType) { + const el = document.getElementById("icon-ctn")!; + const img = el.getElementsByTagName("img")[0]; + const { step, error, onContinue } = state; + el.className = `p-8 rounded-full ${fbSteps[step].bgColor}`; + if (onContinue) { + el.style.cursor = "pointer"; + el.addEventListener("click", onContinue); + } + img.src = fbSteps[step].icon; + img.className = `${!error && step !== FastbootStep.DONE ? "animate-pulse" : ""}`; +} + +function renderRetryButtonView(state: FastbootManagerStateType) { + const { error } = state; + if (error !== FastbootError.NONE) { + const el = document.getElementById("subtitle")!; + el.insertAdjacentHTML( + "afterend", + ` + + `, + ); + const retryButton = document.getElementById("retry-btn")!; + retryButton.addEventListener("click", retryFlashing); + } else { + const retryButton = document.getElementById("retry-btn"); + if (retryButton) { + retryButton.removeEventListener("click", retryFlashing); + retryButton.remove(); + } + } +} + +function renderDeviceStateView(state: FastbootManagerStateType) { + const { serial, connected } = state; + if (!connected) { + const deviceStateEl = document.getElementById("device-state"); + if (!deviceStateEl) return; + deviceStateEl.remove(); + return; + } + + let el: Element; + const retryButton = document.getElementById("retry-btn"); + if (retryButton) el = retryButton; + else el = document.getElementById("subtitle")!; + el.insertAdjacentHTML( + "afterend", + ` +
+
+ + + + Device connected +
+ | +
+ + Serial: + ${serial || "unknown"} + +
+
+ `, + ); +} + +function retryFlashing() { + console.debug("[fastboot] on retry"); + window.location.reload(); +} + +const fbSteps: Record< + FastbootStep, + { status: string; bgColor: string; icon: string; description?: string } +> = { + [FastbootStep.INITIALIZING]: { + status: "Initializing...", + bgColor: "bg-gray-400 dark:bg-gray-700", + icon: "assets/cloud.svg", + }, + [FastbootStep.READY]: { + status: "Ready", + description: "Tap the button above to begin", + bgColor: "bg-[#51ff00]", + icon: "assets/bolt.svg", + }, + [FastbootStep.CONNECTING]: { + status: "Waiting for connection", + description: + "Follow the instructions to connect your device to your computer", + bgColor: "bg-yellow-500", + icon: "assets/cable.svg", + }, + [FastbootStep.DOWNLOADING]: { + status: "Downloading...", + bgColor: "bg-blue-500", + icon: "assets/cloud_download.svg", + }, + [FastbootStep.UNPACKING]: { + status: "Unpacking...", + bgColor: "bg-blue-500", + icon: "assets/cloud_download.svg", + }, + [FastbootStep.FLASHING]: { + status: "Flashing device...", + description: "Do not unplug your device until the process is complete.", + bgColor: "bg-lime-400", + icon: "assets/system_update_c3.svg", + }, + [FastbootStep.ERASING]: { + status: "Erasing device...", + bgColor: "bg-lime-400", + icon: "assets/system_update_c3.svg", + }, + [FastbootStep.DONE]: { + status: "Done", + description: + "Your device has been updated successfully. You can now unplug the USB cable from your computer. To " + + "complete the system reset, follow the instructions on your device.", + bgColor: "bg-green-500", + icon: "assets/done.svg", + }, +}; + +setupProgressIndicatorView(fb.state); +setupIconView(fb.state); +setupStatusView(fb.state); +setupDeviceStatusView(fb.state); +setupRetryButtonView(fb.state); diff --git a/src/utils/android-fastboot.d.ts b/src/utils/android-fastboot.d.ts new file mode 100644 index 00000000..d85bd58b --- /dev/null +++ b/src/utils/android-fastboot.d.ts @@ -0,0 +1,69 @@ +declare module "android-fastboot" { + export class FastbootError extends Error {} + export class FastbootDevice { + /** + * Request the user to select a USB device and connect to it using the + * fastboot protocol. + * + * @throws {UsbError} + */ + connect(): Promise; + get isConnected(): boolean; + /** + * Wait for the current USB device to disconnect, if it's still connected. + * Returns immediately if no device is connected. + */ + waitForDisconnect(): Promise; + /** + * Wait for the USB device to connect. Returns at the next connection, + * regardless of whether the connected USB device matches the previous one. + */ + waitForConnect(onReconnect?: () => void): Promise; + + /** + * Read the value of a bootloader variable. Returns undefined if the variable + * does not exist. + * @throws {FastbootError} + */ + getVariable(varName: string): Promise; + /** + * Send a textual command to the bootloader and read the response. + * This is in raw fastboot format, not AOSP fastboot syntax. + * + * @param {string} command - The command to send. + * @returns {Promise} Object containing response text and data size, if any. + * @throws {FastbootError} + */ + runCommand( + cmd: string, + ): Promise<{ textSize: string; dataSize?: string | number }>; + + /** + * Flash the given Blob to the given partition on the device. Any image + * format supported by the bootloader is allowed, e.g. sparse or raw images. + * Large raw images will be converted to sparse images automatically, and + * large sparse images will be split and flashed in multiple passes + * depending on the bootloader's payload size limit. + * + * @param {string} partition - The name of the partition to flash. + * @param {Blob} blob - The Blob to retrieve data from. + * @param {"a" | "b" | "current" | "other"} targetSlot - Which slot to flash to, if partition + * is A/B. Defaults to current slot. + * @param {FlashProgressCallback} onProgress - Callback for flashing progress updates. + * @throws {FastbootError} + */ + flashBlob( + partition: string, + blob: Blob, + onProgress: (p: number) => void, + targetSlot: "a" | "b" | "current" | "other", + ): Promise; + } + /** + * Change the debug level for the fastboot client: + * - 0 = silent + * - 1 = debug, recommended for general use + * - 2 = verbose, for debugging only + */ + export function setDebugLevel(level: number): void; +} diff --git a/src/utils/blob.js b/src/utils/blob.js deleted file mode 100644 index 27e0422f..00000000 --- a/src/utils/blob.js +++ /dev/null @@ -1,23 +0,0 @@ -export async function download(url) { - const response = await fetch(url, { mode: 'cors' }) - const reader = response.body.getReader() - const contentLength = +response.headers.get('Content-Length') - console.debug('[blob] Downloading', url, contentLength) - - const chunks = [] - while (true) { - const { done, value } = await reader.read() - if (done) break - chunks.push(value) - } - - const blob = new Blob(chunks) - console.debug('[blob] Downloaded', url, blob.size) - if (blob.size !== contentLength) console.warn('[blob] Download size mismatch', { - url, - expected: contentLength, - actual: blob.size, - }) - - return blob -} diff --git a/src/utils/blob.ts b/src/utils/blob.ts new file mode 100644 index 00000000..5d57823d --- /dev/null +++ b/src/utils/blob.ts @@ -0,0 +1,24 @@ +export async function download(url: string) { + const response = await fetch(url, { mode: "cors" }); + const reader = response.body!.getReader(); + const contentLength = +response.headers.get("Content-Length")!; + console.debug("[blob] Downloading", url, contentLength); + + const chunks = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + + const blob = new Blob(chunks); + console.debug("[blob] Downloaded", url, blob.size); + if (blob.size !== contentLength) + console.warn("[blob] Download size mismatch", { + url, + expected: contentLength, + actual: blob.size, + }); + + return blob; +} diff --git a/src/utils/fastboot.js b/src/utils/fastboot.js deleted file mode 100644 index a9af8655..00000000 --- a/src/utils/fastboot.js +++ /dev/null @@ -1,368 +0,0 @@ -import { useEffect, useRef, useState } from 'react' - -import { FastbootDevice, setDebugLevel } from 'android-fastboot' -import * as Comlink from 'comlink' - -import config from '@/config' -import { download } from '@/utils/blob' -import { useImageWorker } from '@/utils/image' -import { createManifest } from '@/utils/manifest' -import { withProgress } from '@/utils/progress' - -/** - * @typedef {import('./manifest.js').Image} Image - */ - -// Verbose logging for fastboot -setDebugLevel(2) - -export const Step = { - INITIALIZING: 0, - READY: 1, - CONNECTING: 2, - DOWNLOADING: 3, - UNPACKING: 4, - FLASHING: 6, - ERASING: 7, - DONE: 8, -} - -export const Error = { - UNKNOWN: -1, - NONE: 0, - UNRECOGNIZED_DEVICE: 1, - LOST_CONNECTION: 2, - DOWNLOAD_FAILED: 3, - UNPACK_FAILED: 4, - CHECKSUM_MISMATCH: 5, - FLASH_FAILED: 6, - ERASE_FAILED: 7, - REQUIREMENTS_NOT_MET: 8, -} - -function isRecognizedDevice(deviceInfo) { - // check some variables are as expected for a comma three - const { - kernel, - "max-download-size": maxDownloadSize, - "slot-count": slotCount, - } = deviceInfo - if (kernel !== "uefi" || maxDownloadSize !== "104857600" || slotCount !== "2") { - console.error('[fastboot] Unrecognised device (kernel, maxDownloadSize or slotCount)', deviceInfo) - return false - } - - const partitions = [] - for (const key of Object.keys(deviceInfo)) { - if (!key.startsWith("partition-type:")) continue - let partition = key.substring("partition-type:".length) - if (partition.endsWith("_a") || partition.endsWith("_b")) { - partition = partition.substring(0, partition.length - 2) - } - if (partitions.includes(partition)) continue - partitions.push(partition) - } - - // check we have the expected partitions to make sure it's a comma three - const expectedPartitions = [ - "ALIGN_TO_128K_1", "ALIGN_TO_128K_2", "ImageFv", "abl", "aop", "apdp", "bluetooth", "boot", "cache", - "cdt", "cmnlib", "cmnlib64", "ddr", "devcfg", "devinfo", "dip", "dsp", "fdemeta", "frp", "fsc", "fsg", - "hyp", "keymaster", "keystore", "limits", "logdump", "logfs", "mdtp", "mdtpsecapp", "misc", "modem", - "modemst1", "modemst2", "msadp", "persist", "qupfw", "rawdump", "sec", "splash", "spunvm", "ssd", - "sti", "storsec", "system", "systemrw", "toolsfv", "tz", "userdata", "vm-linux", "vm-system", "xbl", - "xbl_config" - ] - if (!partitions.every(partition => expectedPartitions.includes(partition))) { - console.error('[fastboot] Unrecognised device (partitions)', partitions) - return false - } - - // sanity check, also useful for logging - if (!deviceInfo['serialno']) { - console.error('[fastboot] Unrecognised device (missing serialno)', deviceInfo) - return false - } - - return true -} - -export function useFastboot() { - const [step, _setStep] = useState(Step.INITIALIZING) - const [message, _setMessage] = useState('') - const [progress, setProgress] = useState(0) - const [error, _setError] = useState(Error.NONE) - - const [connected, setConnected] = useState(false) - const [serial, setSerial] = useState(null) - - const [onContinue, setOnContinue] = useState(null) - const [onRetry, setOnRetry] = useState(null) - - const imageWorker = useImageWorker() - const fastboot = useRef(new FastbootDevice()) - - /** @type {React.RefObject} */ - const manifest = useRef(null) - - function setStep(step) { - _setStep(step) - } - - function setMessage(message = '') { - if (message) console.info('[fastboot]', message) - _setMessage(message) - } - - function setError(error) { - _setError(error) - } - - useEffect(() => { - setProgress(-1) - setMessage() - - if (error) return - if (!imageWorker.current) { - console.debug('[fastboot] Waiting for image worker') - return - } - - switch (step) { - case Step.INITIALIZING: { - // Check that the browser supports WebUSB - if (typeof navigator.usb === 'undefined') { - console.error('[fastboot] WebUSB not supported') - setError(Error.REQUIREMENTS_NOT_MET) - break - } - - // Check that the browser supports Web Workers - if (typeof Worker === 'undefined') { - console.error('[fastboot] Web Workers not supported') - setError(Error.REQUIREMENTS_NOT_MET) - break - } - - // Check that the browser supports Storage API - if (typeof Storage === 'undefined') { - console.error('[fastboot] Storage API not supported') - setError(Error.REQUIREMENTS_NOT_MET) - break - } - - // TODO: change manifest once alt image is in release - imageWorker.current?.init() - .then(() => download(config.manifests['master'])) - .then(blob => blob.text()) - .then(text => { - manifest.current = createManifest(text) - - // sanity check - if (manifest.current.length === 0) { - throw 'Manifest is empty' - } - - console.debug('[fastboot] Loaded manifest', manifest.current) - setStep(Step.READY) - }) - .catch((err) => { - console.error('[fastboot] Initialization error', err) - setError(Error.UNKNOWN) - }) - break - } - - case Step.READY: { - // wait for user interaction (we can't use WebUSB without user event) - setOnContinue(() => () => { - setOnContinue(null) - setStep(Step.CONNECTING) - }) - break - } - - case Step.CONNECTING: { - fastboot.current.waitForConnect() - .then(() => { - console.info('[fastboot] Connected', { fastboot: fastboot.current }) - return fastboot.current.getVariable('all') - .then((all) => { - const deviceInfo = all.split('\n').reduce((obj, line) => { - const parts = line.split(':') - const key = parts.slice(0, -1).join(':').trim() - obj[key] = parts.slice(-1)[0].trim() - return obj - }, {}) - - const recognized = isRecognizedDevice(deviceInfo) - console.debug('[fastboot] Device info', { recognized, deviceInfo }) - - if (!recognized) { - setError(Error.UNRECOGNIZED_DEVICE) - return - } - - setSerial(deviceInfo['serialno'] || 'unknown') - setConnected(true) - setStep(Step.DOWNLOADING) - }) - .catch((err) => { - console.error('[fastboot] Error getting device information', err) - setError(Error.UNKNOWN) - }) - }) - .catch((err) => { - console.error('[fastboot] Connection lost', err) - setError(Error.LOST_CONNECTION) - setConnected(false) - }) - - fastboot.current.connect() - .catch((err) => { - console.error('[fastboot] Connection error', err) - setStep(Step.READY) - }) - break - } - - case Step.DOWNLOADING: { - setProgress(0) - - async function downloadImages() { - for await (const [image, onProgress] of withProgress(manifest.current, setProgress)) { - setMessage(`Downloading ${image.name}`) - await imageWorker.current.downloadImage(image, Comlink.proxy(onProgress)) - } - } - - downloadImages() - .then(() => { - console.debug('[fastboot] Downloaded all images') - setStep(Step.UNPACKING) - }) - .catch((err) => { - console.error('[fastboot] Download error', err) - setError(Error.DOWNLOAD_FAILED) - }) - break - } - - case Step.UNPACKING: { - setProgress(0) - - async function unpackImages() { - for await (const [image, onProgress] of withProgress(manifest.current, setProgress)) { - setMessage(`Unpacking ${image.name}`) - await imageWorker.current.unpackImage(image, Comlink.proxy(onProgress)) - } - } - - unpackImages() - .then(() => { - console.debug('[fastboot] Unpacked all images') - setStep(Step.FLASHING) - }) - .catch((err) => { - console.error('[fastboot] Unpack error', err) - if (err.startsWith('Checksum mismatch')) { - setError(Error.CHECKSUM_MISMATCH) - } else { - setError(Error.UNPACK_FAILED) - } - }) - break - } - - case Step.FLASHING: { - setProgress(0) - - async function flashDevice() { - const currentSlot = await fastboot.current.getVariable('current-slot') - if (!['a', 'b'].includes(currentSlot)) { - throw `Unknown current slot ${currentSlot}` - } - - for await (const [image, onProgress] of withProgress(manifest.current, setProgress)) { - const fileHandle = await imageWorker.current.getImage(image) - const blob = await fileHandle.getFile() - - if (image.sparse) { - setMessage(`Erasing ${image.name}`) - await fastboot.current.runCommand(`erase:${image.name}`) - } - setMessage(`Flashing ${image.name}`) - await fastboot.current.flashBlob(image.name, blob, onProgress, 'other') - } - console.debug('[fastboot] Flashed all partitions') - - const otherSlot = currentSlot === 'a' ? 'b' : 'a' - setMessage(`Changing slot to ${otherSlot}`) - await fastboot.current.runCommand(`set_active:${otherSlot}`) - } - - flashDevice() - .then(() => { - console.debug('[fastboot] Flash complete') - setStep(Step.ERASING) - }) - .catch((err) => { - console.error('[fastboot] Flashing error', err) - setError(Error.FLASH_FAILED) - }) - break - } - - case Step.ERASING: { - setProgress(0) - - async function eraseDevice() { - setMessage('Erasing userdata') - await fastboot.current.runCommand('erase:userdata') - setProgress(0.9) - - setMessage('Rebooting') - await fastboot.current.runCommand('continue') - setProgress(1) - setConnected(false) - } - - eraseDevice() - .then(() => { - console.debug('[fastboot] Erase complete') - setStep(Step.DONE) - }) - .catch((err) => { - console.error('[fastboot] Erase error', err) - setError(Error.ERASE_FAILED) - }) - break - } - } - }, [error, imageWorker, step]) - - useEffect(() => { - if (error !== Error.NONE) { - console.debug('[fastboot] error', error) - setProgress(-1) - setOnContinue(null) - - setOnRetry(() => () => { - console.debug('[fastboot] on retry') - window.location.reload() - }) - } - }, [error]) - - return { - step, - message, - progress, - error, - - connected, - serial, - - onContinue, - onRetry, - } -} diff --git a/src/utils/fastboot.ts b/src/utils/fastboot.ts new file mode 100644 index 00000000..b3b9a4f4 --- /dev/null +++ b/src/utils/fastboot.ts @@ -0,0 +1,394 @@ +import * as Comlink from "comlink"; +import { FastbootDevice, setDebugLevel } from "android-fastboot"; +import type { ImageWorkerType } from "../workers/image.worker"; +import { download } from "./blob"; +import config from "../config"; +import { createManifest, Image as ManifestImage } from "./manifest"; +import { withProgress } from "./progress"; + +setDebugLevel(2); + +export class FastbootManager extends EventTarget { + state: FastbootManagerStateType; + imageWorker: Comlink.Remote; + device: FastbootDevice; + manifest: ManifestImage[] | null; + + constructor() { + super(); + this.state = { + step: FastbootStep.INITIALIZING, + message: "", + progress: -1, + error: FastbootError.NONE, + connected: false, + serial: null, + }; + this.imageWorker = Comlink.wrap( + new Worker(new URL("../workers/image.worker.ts", import.meta.url), { + type: "module", + }), + ); + this.device = new FastbootDevice(); + this.manifest = null; + } + + on(type: string, listener: (data: FastbootManagerStateType) => void) { + this.addEventListener(type, (( + event: CustomEvent, + ) => listener(event.detail)) as EventListener); + } + + private setStep(step: FastbootStep) { + this.state.step = step; + this.dispatchEvent(new CustomEvent("step", { detail: this.state })); + } + + private setError(error: FastbootError) { + this.state.error = error; + this.dispatchEvent(new CustomEvent("error", { detail: this.state })); + } + + private setSerial(serial: string) { + this.state.serial = serial; + this.dispatchEvent(new CustomEvent("serial", { detail: this.state })); + } + + private setConnected(isConnected: boolean) { + this.state.connected = isConnected; + this.dispatchEvent(new CustomEvent("connected", { detail: this.state })); + } + + private setProgress(progress: number) { + this.state.progress = progress; + this.dispatchEvent(new CustomEvent("progress", { detail: this.state })); + } + + private setMessage(message: string) { + this.state.message = message; + this.dispatchEvent(new CustomEvent("message", { detail: this.state })); + } + + init() { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Check that the browser supports WebUSB + if (typeof navigator.usb === "undefined") { + console.error("[fastboot] WebUSB not supported"); + this.setError(FastbootError.REQUIREMENTS_NOT_MET); + return; + } + + // Check that the browser supports Web Workers + if (typeof Worker === "undefined") { + console.error("[fastboot] Web Workers not supported"); + this.setError(FastbootError.REQUIREMENTS_NOT_MET); + return; + } + + // Check that the browser supports Storage API + if (typeof Storage === "undefined") { + console.error("[fastboot] Storage API not supported"); + this.setError(FastbootError.REQUIREMENTS_NOT_MET); + return; + } + + this.imageWorker + .init() + .then(() => download(config.manifests["master"])) + .then((blob) => blob.text()) + .then((text) => { + this.manifest = createManifest(text); + // sanity check + if (this.manifest.length === 0) { + throw "Manifest is empty"; + } + console.debug("[fastboot] Loaded manifest", this.manifest); + this.state.onContinue = () => this.connectDevice(); + this.setStep(FastbootStep.READY); + }) + .catch((err) => { + console.error("[fastboot] Initialization error", err); + this.setError(FastbootError.UNKNOWN); + }); + } + + async connectDevice() { + this.device.connect().catch((err) => { + console.error("[fastboot] Connection error", err); + this.setStep(FastbootStep.READY); + }); + this.setStep(FastbootStep.CONNECTING); + try { + await this.device.waitForConnect(); + console.info("[fastboot] Connected", { + fastboot: this.device, + }); + try { + const all = await this.device.getVariable("all"); + const deviceInfo = all.split("\n").reduce( + (obj, line) => { + const parts = line.split(":"); + const key = parts.slice(0, -1).join(":").trim(); + obj[key] = parts.slice(-1)[0].trim(); + return obj; + }, + {} as Record, + ); + + const recognized = isRecognizedDevice(deviceInfo); + console.debug("[fastboot] Device info", { + recognized, + deviceInfo, + }); + + if (!recognized) { + this.setError(FastbootError.UNRECOGNIZED_DEVICE); + return; + } + + this.setSerial(deviceInfo["serialno"] || "unknown"); + this.setConnected(true); + this.downloadImages(); + } catch (err) { + console.error("[fastboot] Error getting device information", err); + this.setError(FastbootError.UNKNOWN); + } + } catch (err) { + console.error("[fastboot] Connection lost", err); + this.setError(FastbootError.LOST_CONNECTION); + this.setConnected(false); + } + } + + async downloadImages() { + this.setStep(FastbootStep.DOWNLOADING); + this.setProgress(0); + try { + for await (const [image, onProgress] of withProgress( + this.manifest!, + this.setProgress, + )) { + this.setMessage(`Downloading ${image.name}`); + await this.imageWorker.downloadImage(image, Comlink.proxy(onProgress)); + } + this.unpackImages(); + } catch (err) { + console.error("[fastboot] Download error", err); + this.setError(FastbootError.DOWNLOAD_FAILED); + } + } + + async unpackImages() { + this.setStep(FastbootStep.UNPACKING); + this.setProgress(0); + try { + for await (const [image, onProgress] of withProgress( + this.manifest!, + this.setProgress, + )) { + this.setMessage(`Unpacking ${image.name}`); + await this.imageWorker.unpackImage(image, Comlink.proxy(onProgress)); + } + this.flashDevice(); + } catch (err) { + console.error("[fastboot] Unpack error", err); + if ((err as string).startsWith("Checksum mismatch")) { + this.setError(FastbootError.CHECKSUM_MISMATCH); + } else { + this.setError(FastbootError.UNPACK_FAILED); + } + } + } + + async flashDevice() { + this.setStep(FastbootStep.FLASHING); + this.setProgress(0); + try { + const currentSlot = await this.device.getVariable("current-slot"); + if (!["a", "b"].includes(currentSlot)) { + throw `Unknown current slot ${currentSlot}`; + } + + for await (const [image, onProgress] of withProgress( + this.manifest!, + this.setProgress, + )) { + const fileHandle = await this.imageWorker.getImage(image); + const blob = await fileHandle.getFile(); + + if (image.sparse) { + this.setMessage(`Erasing ${image.name}`); + await this.device.runCommand(`erase:${image.name}`); + } + this.setMessage(`Flashing ${image.name}`); + await this.device.flashBlob(image.name, blob, onProgress, "other"); + } + console.debug("[fastboot] Flashed all partitions"); + + const otherSlot = currentSlot === "a" ? "b" : "a"; + this.setMessage(`Changing slot to ${otherSlot}`); + await this.device.runCommand(`set_active:${otherSlot}`); + console.debug("[fastboot] Flash complete"); + this.eraseDevice(); + } catch (err) { + console.error("[fastboot] Flashing error", err); + this.setError(FastbootError.FLASH_FAILED); + } + } + + async eraseDevice() { + this.setStep(FastbootStep.ERASING); + this.setProgress(0); + try { + this.setMessage("Erasing userdata"); + await this.device.runCommand("erase:userdata"); + this.setProgress(0.9); + + this.setMessage("Rebooting"); + await this.device.runCommand("continue"); + this.setProgress(1); + this.setConnected(false); + } catch (err) { + console.error("[fastboot] Erase error", err); + this.setError(FastbootError.ERASE_FAILED); + } + } +} + +export type FastbootManagerStateType = { + step: FastbootStep; + message: string; + progress: number; + error: FastbootError; + connected: boolean; + serial: string | null; + onContinue?: () => void; +}; + +export enum FastbootStep { + INITIALIZING = 0, + READY, + CONNECTING, + DOWNLOADING, + UNPACKING, + FLASHING, + ERASING, + DONE, +} + +export enum FastbootError { + UNKNOWN = -1, + NONE, + UNRECOGNIZED_DEVICE, + LOST_CONNECTION, + DOWNLOAD_FAILED, + UNPACK_FAILED, + CHECKSUM_MISMATCH, + FLASH_FAILED, + ERASE_FAILED, + REQUIREMENTS_NOT_MET, +} + +function isRecognizedDevice(deviceInfo: Record) { + // check some variables are as expected for a comma three + const { + kernel, + "max-download-size": maxDownloadSize, + "slot-count": slotCount, + } = deviceInfo; + if ( + kernel !== "uefi" || + maxDownloadSize !== "104857600" || + slotCount !== "2" + ) { + console.error( + "[fastboot] Unrecognised device (kernel, maxDownloadSize or slotCount)", + deviceInfo, + ); + return false; + } + + const partitions: string[] = []; + for (const key of Object.keys(deviceInfo)) { + if (!key.startsWith("partition-type:")) continue; + let partition = key.substring("partition-type:".length); + if (partition.endsWith("_a") || partition.endsWith("_b")) { + partition = partition.substring(0, partition.length - 2); + } + if (partitions.includes(partition)) continue; + partitions.push(partition); + } + + // check we have the expected partitions to make sure it's a comma three + const expectedPartitions = [ + "ALIGN_TO_128K_1", + "ALIGN_TO_128K_2", + "ImageFv", + "abl", + "aop", + "apdp", + "bluetooth", + "boot", + "cache", + "cdt", + "cmnlib", + "cmnlib64", + "ddr", + "devcfg", + "devinfo", + "dip", + "dsp", + "fdemeta", + "frp", + "fsc", + "fsg", + "hyp", + "keymaster", + "keystore", + "limits", + "logdump", + "logfs", + "mdtp", + "mdtpsecapp", + "misc", + "modem", + "modemst1", + "modemst2", + "msadp", + "persist", + "qupfw", + "rawdump", + "sec", + "splash", + "spunvm", + "ssd", + "sti", + "storsec", + "system", + "systemrw", + "toolsfv", + "tz", + "userdata", + "vm-linux", + "vm-system", + "xbl", + "xbl_config", + ]; + if ( + !partitions.every((partition) => expectedPartitions.includes(partition)) + ) { + console.error("[fastboot] Unrecognised device (partitions)", partitions); + return false; + } + + // sanity check, also useful for logging + if (!deviceInfo["serialno"]) { + console.error( + "[fastboot] Unrecognised device (missing serialno)", + deviceInfo, + ); + return false; + } + + return true; +} diff --git a/src/utils/image.js b/src/utils/image.js deleted file mode 100644 index ed27f09f..00000000 --- a/src/utils/image.js +++ /dev/null @@ -1,17 +0,0 @@ -import { useEffect, useRef } from 'react' - -import * as Comlink from 'comlink' - -export function useImageWorker() { - const apiRef = useRef() - - useEffect(() => { - const worker = new Worker(new URL('../workers/image.worker', import.meta.url), { - type: 'module', - }) - apiRef.current = Comlink.wrap(worker) - return () => worker.terminate() - }, []) - - return apiRef -} diff --git a/src/utils/manifest.js b/src/utils/manifest.js deleted file mode 100644 index 9afdd487..00000000 --- a/src/utils/manifest.js +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Represents a partition image defined in the AGNOS manifest. - * - * Image archives can be retrieved from {@link archiveUrl}. - */ -export class Image { - /** - * Partition name - * @type {string} - */ - name - - /** - * SHA-256 checksum of the image, encoded as a hex string - * @type {string} - */ - checksum - /** - * Size of the unpacked image in bytes - * @type {number} - */ - size - /** - * Whether the image is sparse - * @type {boolean} - */ - sparse - - /** - * Name of the image file - * @type {string} - */ - fileName - - /** - * Name of the image archive file - * @type {string} - */ - archiveFileName - /** - * URL of the image archive - * @type {string} - */ - archiveUrl - - constructor(json) { - this.name = json.name - this.sparse = json.sparse - - if (this.name === 'system') { - this.checksum = json.alt.hash - this.fileName = `${this.name}-skip-chunks-${json.hash_raw}.img` - this.archiveUrl = json.alt.url - this.size = json.alt.size - } else { - this.checksum = json.hash - this.fileName = `${this.name}-${json.hash_raw}.img` - this.archiveUrl = json.url - this.size = json.size - } - - this.archiveFileName = this.archiveUrl.split('/').pop() - } -} - -/** - * @param {string} text - * @returns {Image[]} - */ -export function createManifest(text) { - const expectedPartitions = ['aop', 'devcfg', 'xbl', 'xbl_config', 'abl', 'boot', 'system'] - const partitions = JSON.parse(text).map((image) => new Image(image)) - - // Sort into consistent order - partitions.sort((a, b) => expectedPartitions.indexOf(a.name) - expectedPartitions.indexOf(b.name)) - - // Check that all partitions are present - // TODO: should we prevent flashing if there are extra partitions? - const missingPartitions = expectedPartitions.filter((name) => !partitions.some((image) => image.name === name)) - if (missingPartitions.length > 0) { - throw new Error(`Manifest is missing partitions: ${missingPartitions.join(', ')}`) - } - - return partitions -} - -/** - * @param {string} url - * @returns {Promise} - */ -export function getManifest(url) { - return fetch(url) - .then((response) => response.text()) - .then(createManifest) -} diff --git a/src/utils/manifest.test.js b/src/utils/manifest.test.js deleted file mode 100644 index cabcac0e..00000000 --- a/src/utils/manifest.test.js +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, test, vi } from 'vitest' - -import * as Comlink from 'comlink' - -import config from '../config' -import { getManifest } from './manifest' - -async function getImageWorker() { - let imageWorker - - vi.mock('comlink') - vi.mocked(Comlink.expose).mockImplementation(worker => { - imageWorker = worker - imageWorker.init() - }) - - await import('./../workers/image.worker') - - return imageWorker -} - -for (const [branch, manifestUrl] of Object.entries(config.manifests)) { - describe(`${branch} manifest`, async () => { - const imageWorkerFileHandler = { - getFile: vi.fn(), - createWritable: vi.fn().mockImplementation(() => ({ - write: vi.fn(), - close: vi.fn(), - })), - } - - globalThis.navigator = { - storage: { - getDirectory: () => ({ - getFileHandle: () => imageWorkerFileHandler, - }) - } - } - - const imageWorker = await getImageWorker() - - const images = await getManifest(manifestUrl) - - // Check all images are present - expect(images.length).toBe(7) - - for (const image of images) { - describe(`${image.name} image`, async () => { - test('xz archive', () => { - expect(image.archiveFileName, 'archive to be in xz format').toContain('.xz') - expect(image.archiveUrl, 'archive url to be in xz format').toContain('.xz') - }) - - if (image.name === 'system') { - test('alt image', () => { - expect(image.sparse, 'system image to be sparse').toBe(true) - expect(image.fileName, 'system image to be skip chunks').toContain('-skip-chunks-') - expect(image.archiveUrl, 'system image to point to skip chunks').toContain('-skip-chunks-') - }) - } - - test('image and checksum', async () => { - imageWorkerFileHandler.getFile.mockImplementation(async () => { - const response = await fetch(image.archiveUrl) - expect(response.ok, 'to be uploaded').toBe(true) - - return response.blob() - }) - - await imageWorker.unpackImage(image) - }, 8 * 60 * 1000) - }) - } - }) -} diff --git a/src/utils/manifest.test.ts b/src/utils/manifest.test.ts new file mode 100644 index 00000000..e0e5d1d8 --- /dev/null +++ b/src/utils/manifest.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test, vi } from "vitest"; + +import * as Comlink from "comlink"; + +import config from "../config"; +import { getManifest } from "./manifest"; +import { ImageWorkerType } from "./../workers/image.worker"; + +async function getImageWorker() { + let imageWorker: ImageWorkerType | undefined; + + vi.mock("comlink"); + vi.mocked(Comlink.expose).mockImplementation((worker) => { + imageWorker = worker; + imageWorker!.init(); + }); + + await import("./../workers/image.worker"); + + return imageWorker; +} + +for (const [branch, manifestUrl] of Object.entries(config.manifests)) { + describe(`${branch} manifest`, async () => { + const imageWorkerFileHandler = { + getFile: vi.fn(), + createWritable: vi.fn().mockImplementation(() => ({ + write: vi.fn(), + close: vi.fn(), + })), + }; + + globalThis.navigator = { + storage: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + getDirectory: async () => ({ + getFileHandle: () => imageWorkerFileHandler, + }), + }, + }; + + const imageWorker = await getImageWorker(); + + const images = await getManifest(manifestUrl); + + // Check all images are present + expect(images.length).toBe(7); + + for (const image of images) { + describe(`${image.name} image`, () => { + test("xz archive", () => { + expect(image.archiveFileName, "archive to be in xz format").toContain( + ".xz", + ); + expect(image.archiveUrl, "archive url to be in xz format").toContain( + ".xz", + ); + }); + + if (image.name === "system") { + test("alt image", () => { + expect(image.sparse, "system image to be sparse").toBe(true); + expect(image.fileName, "system image to be skip chunks").toContain( + "-skip-chunks-", + ); + expect( + image.archiveUrl, + "system image to point to skip chunks", + ).toContain("-skip-chunks-"); + }); + } + + test( + "image and checksum", + async () => { + imageWorkerFileHandler.getFile.mockImplementation(async () => { + const response = await fetch(image.archiveUrl); + expect(response.ok, "to be uploaded").toBe(true); + + return response.blob(); + }); + + await imageWorker!.unpackImage(image); + }, + 8 * 60 * 1000, + ); + }); + } + }); +} diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts new file mode 100644 index 00000000..0bda302e --- /dev/null +++ b/src/utils/manifest.ts @@ -0,0 +1,114 @@ +/** + * Represents a partition image defined in the AGNOS manifest. + * + * Image archives can be retrieved from {@link archiveUrl}. + */ +export class Image { + /** + * Partition name + * @type {string} + */ + name: string; + + /** + * SHA-256 checksum of the image, encoded as a hex string + * @type {string} + */ + checksum: string; + /** + * Size of the unpacked image in bytes + * @type {number} + */ + size: number; + /** + * Whether the image is sparse + * @type {boolean} + */ + sparse: boolean; + + /** + * Name of the image file + * @type {string} + */ + fileName: string; + + /** + * Name of the image archive file + * @type {string} + */ + archiveFileName: string; + /** + * URL of the image archive + * @type {string} + */ + archiveUrl: string; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(json: Record) { + this.name = json.name; + this.sparse = json.sparse; + + if (this.name === "system") { + this.checksum = json.alt.hash; + this.fileName = `${this.name}-skip-chunks-${json.hash_raw}.img`; + this.archiveUrl = json.alt.url; + this.size = json.alt.size; + } else { + this.checksum = json.hash; + this.fileName = `${this.name}-${json.hash_raw}.img`; + this.archiveUrl = json.url; + this.size = json.size; + } + + this.archiveFileName = this.archiveUrl.split("/").pop()!; + } +} + +/** + * @param {string} text + * @returns {Image[]} + */ +export function createManifest(text: string) { + const expectedPartitions = [ + "aop", + "devcfg", + "xbl", + "xbl_config", + "abl", + "boot", + "system", + ]; + const partitions: Image[] = JSON.parse(text).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (image: any) => new Image(image), + ); + + // Sort into consistent order + partitions.sort( + (a, b) => + expectedPartitions.indexOf(a.name) - expectedPartitions.indexOf(b.name), + ); + + // Check that all partitions are present + // TODO: should we prevent flashing if there are extra partitions? + const missingPartitions = expectedPartitions.filter( + (name) => !partitions.some((image) => image.name === name), + ); + if (missingPartitions.length > 0) { + throw new Error( + `Manifest is missing partitions: ${missingPartitions.join(", ")}`, + ); + } + + return partitions; +} + +/** + * @param {string} url + * @returns {Promise} + */ +export function getManifest(url: string | URL) { + return fetch(url) + .then((response) => response.text()) + .then(createManifest); +} diff --git a/src/utils/progress.js b/src/utils/progress.js deleted file mode 100644 index ef209eed..00000000 --- a/src/utils/progress.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Create a set of callbacks that can be used to track progress of a multistep process. - * - * @param {(number[]|number)} steps - * @param {progressCallback} onProgress - * @returns {(progressCallback)[]} - */ -export function createSteps(steps, onProgress) { - const stepWeights = typeof steps === 'number' ? Array(steps).fill(1) : steps - - const progressParts = Array(stepWeights.length).fill(0) - const totalSize = stepWeights.reduce((total, weight) => total + weight, 0) - - function updateProgress() { - const weightedAverage = stepWeights.reduce((acc, weight, idx) => { - return acc + progressParts[idx] * weight - }, 0) - onProgress(weightedAverage / totalSize) - } - - return stepWeights.map((weight, idx) => (progress) => { - if (progressParts[idx] !== progress) { - progressParts[idx] = progress - updateProgress() - } - }) -} - -/** - * Iterate over a list of steps while reporting progress. - * @template T - * @param {(number[]|T[])} steps - * @param {progressCallback} onProgress - * @returns {([T, progressCallback])[]} - */ -export function withProgress(steps, onProgress) { - const callbacks = createSteps( - steps.map(step => typeof step === 'number' ? step : step.size || step.length || 1), - onProgress, - ) - return steps.map((step, idx) => [step, callbacks[idx]]) -} diff --git a/src/utils/progress.ts b/src/utils/progress.ts new file mode 100644 index 00000000..361da3c1 --- /dev/null +++ b/src/utils/progress.ts @@ -0,0 +1,51 @@ +/** + * Create a set of callbacks that can be used to track progress of a multistep process. + * + * @param {(number[]|number)} steps + * @param {progressCallback} onProgress + * @returns {(progressCallback)[]} + */ +export function createSteps( + steps: number | number[], + onProgress: (val: number) => void, +) { + const stepWeights = typeof steps === "number" ? Array(steps).fill(1) : steps; + + const progressParts = Array(stepWeights.length).fill(0); + const totalSize = stepWeights.reduce((total, weight) => total + weight, 0); + + function updateProgress() { + const weightedAverage = stepWeights.reduce((acc, weight, idx) => { + return acc + progressParts[idx] * weight; + }, 0); + onProgress(weightedAverage / totalSize); + } + + return stepWeights.map((_weight, idx) => (progress: number) => { + if (progressParts[idx] !== progress) { + progressParts[idx] = progress; + updateProgress(); + } + }); +} + +/** + * Iterate over a list of steps while reporting progress. + * @template T + * @param {(number[]|T[])} steps + * @param {progressCallback} onProgress + * @returns {([T, progressCallback])[]} + */ +export function withProgress( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + steps: number[] | any[], + onProgress: (val: number) => void, +) { + const callbacks = createSteps( + steps.map((step) => + typeof step === "number" ? step : step.size || step.length || 1, + ), + onProgress, + ); + return steps.map((step, idx) => [step, callbacks[idx]]); +} diff --git a/src/workers/image.worker.js b/src/workers/image.worker.js deleted file mode 100644 index 5df064cf..00000000 --- a/src/workers/image.worker.js +++ /dev/null @@ -1,185 +0,0 @@ -import * as Comlink from 'comlink' - -import jsSHA from 'jssha' -import { XzReadableStream } from 'xz-decompress' - -/** - * @typedef {import('@/utils/manifest').Image} Image - */ - -/** - * Chunk callback - * - * @callback chunkCallback - * @param {Uint8Array} chunk - * @returns {Promise} - */ - -/** - * Progress callback - * - * @callback progressCallback - * @param {number} progress - * @returns {void} - */ - -/** - * Read chunks from a readable stream reader while reporting progress - * - * @param {ReadableStreamDefaultReader} reader - * @param {number} total - * @param {chunkCallback} onChunk - * @param {progressCallback} [onProgress] - * @returns {Promise} - */ -async function readChunks(reader, total, { onChunk, onProgress = undefined }) { - let processed = 0 - while (true) { - const { done, value } = await reader.read() - if (done) break - await onChunk(value) - processed += value.length - onProgress?.(processed / total) - } -} - -let root - -const imageWorker = { - async init() { - if (root) { - console.warn('[ImageWorker] Already initialized') - return - } - - // TODO: check storage quota and report error if insufficient - root = await navigator.storage.getDirectory() - console.info('[ImageWorker] Initialized') - }, - - /** - * Download an image to persistent storage. - * - * @param {Image} image - * @param {progressCallback} [onProgress] - * @returns {Promise} - */ - async downloadImage(image, onProgress = undefined) { - const { archiveFileName, archiveUrl } = image - - let writable - try { - const fileHandle = await root.getFileHandle(archiveFileName, { create: true }) - writable = await fileHandle.createWritable() - } catch (e) { - throw `Error opening file handle: ${e}` - } - - console.debug('[ImageWorker] Downloading', archiveUrl) - const response = await fetch(archiveUrl, { mode: 'cors' }) - if (!response.ok) { - throw `Fetch failed: ${response.status} ${response.statusText}` - } - - try { - const contentLength = +response.headers.get('Content-Length') - const reader = response.body.getReader() - await readChunks(reader, contentLength, { - onChunk: async (chunk) => await writable.write(chunk), - onProgress, - }) - onProgress?.(1) - } catch (e) { - throw `Could not read response body: ${e}` - } - - try { - await writable.close() - } catch (e) { - throw `Error closing file handle: ${e}` - } - }, - - /** - * Unpack and verify a downloaded image archive. - * - * Throws an error if the checksum does not match. - * - * @param {Image} image - * @param {progressCallback} [onProgress] - * @returns {Promise} - */ - async unpackImage(image, onProgress = undefined) { - const { archiveFileName, checksum: expectedChecksum, fileName, size: imageSize } = image - - let archiveFile - try { - const archiveFileHandle = await root.getFileHandle(archiveFileName, { create: false }) - archiveFile = await archiveFileHandle.getFile() - } catch (e) { - throw `Error opening archive file handle: ${e}` - } - - let writable - try { - const fileHandle = await root.getFileHandle(fileName, { create: true }) - writable = await fileHandle.createWritable() - } catch (e) { - throw `Error opening output file handle: ${e}` - } - - const shaObj = new jsSHA('SHA-256', 'UINT8ARRAY') - let complete - try { - const reader = (new XzReadableStream(archiveFile.stream())).getReader() - - await readChunks(reader, imageSize, { - onChunk: async (chunk) => { - await writable.write(chunk) - shaObj.update(chunk) - }, - onProgress, - }) - - complete = true - onProgress?.(1) - } catch (e) { - throw `Error unpacking archive: ${e}` - } - - if (!complete) { - throw 'Decompression error: unexpected end of stream' - } - - try { - await writable.close() - } catch (e) { - throw `Error closing file handle: ${e}` - } - - const checksum = shaObj.getHash('HEX') - if (checksum !== expectedChecksum) { - throw `Checksum mismatch: got ${checksum}, expected ${expectedChecksum}` - } - }, - - /** - * Get a file handle for an image. - * @param {Image} image - * @returns {Promise} - */ - async getImage(image) { - const { fileName } = image - - let fileHandle - try { - fileHandle = await root.getFileHandle(fileName, { create: false }) - } catch (e) { - throw `Error getting file handle: ${e}` - } - - return fileHandle - }, -} - -Comlink.expose(imageWorker) diff --git a/src/workers/image.worker.ts b/src/workers/image.worker.ts new file mode 100644 index 00000000..d4a8a82b --- /dev/null +++ b/src/workers/image.worker.ts @@ -0,0 +1,177 @@ +import * as Comlink from "comlink"; + +import jsSHA from "jssha"; +import { XzReadableStream } from "xz-decompress"; +import { Image as ManifestImage } from "../utils/manifest"; + +type ChunkCallbackFnType = (chunk: Uint8Array) => Promise; + +type ProgressCallbackFnType = (progress: number) => Promise; + +type CallbackType = { + onChunk: ChunkCallbackFnType; + onProgress?: ProgressCallbackFnType; +}; + +/** + * Read chunks from a readable stream reader while reporting progress + */ +async function readChunks( + reader: ReadableStreamDefaultReader, + total: number, + { onChunk, onProgress = undefined }: CallbackType, +) { + let processed = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + await onChunk(value); + processed += value.length; + onProgress?.(processed / total); + } +} + +let root: FileSystemDirectoryHandle; + +const imageWorker = { + async init() { + if (root) { + console.warn("[ImageWorker] Already initialized"); + return; + } + + // TODO: check storage quota and report error if insufficient + root = await navigator.storage.getDirectory(); + console.info("[ImageWorker] Initialized"); + }, + + /** + * Download an image to persistent storage. + */ + async downloadImage( + image: ManifestImage, + onProgress?: ProgressCallbackFnType, + ) { + const { archiveFileName, archiveUrl } = image; + + let writable; + try { + const fileHandle = await root.getFileHandle(archiveFileName, { + create: true, + }); + writable = await fileHandle.createWritable(); + } catch (e) { + throw `Error opening file handle: ${e}`; + } + + console.debug("[ImageWorker] Downloading", archiveUrl); + const response = await fetch(archiveUrl, { mode: "cors" }); + if (!response.ok) { + throw `Fetch failed: ${response.status} ${response.statusText}`; + } + + try { + const contentLength = +response.headers.get("Content-Length")!; + const reader = response.body!.getReader(); + await readChunks(reader, contentLength, { + onChunk: async (chunk) => await writable.write(chunk), + onProgress, + }); + onProgress?.(1); + } catch (e) { + throw `Could not read response body: ${e}`; + } + + try { + await writable.close(); + } catch (e) { + throw `Error closing file handle: ${e}`; + } + }, + + /** + * Unpack and verify a downloaded image archive. + * + * Throws an error if the checksum does not match. + */ + async unpackImage(image: ManifestImage, onProgress?: ProgressCallbackFnType) { + const { + archiveFileName, + checksum: expectedChecksum, + fileName, + size: imageSize, + } = image; + + let archiveFile; + try { + const archiveFileHandle = await root.getFileHandle(archiveFileName, { + create: false, + }); + archiveFile = await archiveFileHandle.getFile(); + } catch (e) { + throw `Error opening archive file handle: ${e}`; + } + + let writable; + try { + const fileHandle = await root.getFileHandle(fileName, { create: true }); + writable = await fileHandle.createWritable(); + } catch (e) { + throw `Error opening output file handle: ${e}`; + } + + const shaObj = new jsSHA("SHA-256", "UINT8ARRAY"); + let complete; + try { + const reader = new XzReadableStream(archiveFile.stream()).getReader(); + + await readChunks(reader, imageSize, { + onChunk: async (chunk) => { + await writable.write(chunk); + shaObj.update(chunk); + }, + onProgress, + }); + + complete = true; + onProgress?.(1); + } catch (e) { + throw `Error unpacking archive: ${e}`; + } + + if (!complete) { + throw "Decompression error: unexpected end of stream"; + } + + try { + await writable.close(); + } catch (e) { + throw `Error closing file handle: ${e}`; + } + + const checksum = shaObj.getHash("HEX"); + if (checksum !== expectedChecksum) { + throw `Checksum mismatch: got ${checksum}, expected ${expectedChecksum}`; + } + }, + + /** + * Get a file handle for an image. + */ + async getImage(image: ManifestImage) { + const { fileName } = image; + + let fileHandle; + try { + fileHandle = await root.getFileHandle(fileName, { create: false }); + } catch (e) { + throw `Error getting file handle: ${e}`; + } + + return fileHandle; + }, +}; + +export type ImageWorkerType = typeof imageWorker; + +Comlink.expose(imageWorker); diff --git a/tailwind.config.js b/tailwind.config.js index f1f20e20..64a450c1 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,23 +1,18 @@ /** @type {import('tailwindcss').Config} */ export default { - content: [ - './index.html', - './src/**/*.{js,jsx}', - ], + content: ["./index.html", "./src/**/*.{ts,js}"], theme: { extend: { backgroundImage: { - 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', - 'gradient-conic': - 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", }, fontFamily: { - sans: ['Inter Variable', 'sans-serif'], - monospace: ['JetBrains Mono Variable', 'monospace'], + sans: ["Inter Variable", "sans-serif"], + monospace: ["JetBrains Mono Variable", "monospace"], }, }, }, - plugins: [ - require('@tailwindcss/typography'), - ], -} + plugins: [require("@tailwindcss/typography")], +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..1b8364c2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "allowJs": true, + "esModuleInterop": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index ba64cd9d..00000000 --- a/vite.config.js +++ /dev/null @@ -1,18 +0,0 @@ -import { fileURLToPath, URL } from 'node:url'; -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], - resolve: { - alias: [ - { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }, - ], - }, - test: { - globals: true, - environment: 'jsdom', - setupFiles: './src/test/setup.js', - }, -}) diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..026a016a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + target: "esnext", + }, + worker: { + format: "es", + }, +});