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
+
+
+
+
+
+
+
+ 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.
+
+
+
+ Download and install
+ Zadig .
+
+
+ Under Device
in the menu bar, select
+ Create New Device
.
+
+
+
+ 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.
+
+
+
+
+ No additional software is required for macOS or Linux.
+
+
+
+
+
+ Fastboot
+
+ Follow these steps to put your device into fastboot
+ mode:
+
+
+
+ Power off the device and wait for the LEDs to switch
+ off.
+
+
+ Connect power to the OBD-C port
+ (port 1) .
+
+
+ Then,
+
+ quickly
+
+ connect the device to your computer using the USB-C
+ port
+ (port 2) .
+
+
+ After a few seconds, the device should indicate
+ it's in fastboot mode and show its serial
+ number.
+
+
+
+
+ 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: dev
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 (
-
-
-
-
-
-
-
-
{title}
-
{description}
- {error && (
-
- Retry
-
- ) || 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 (
-
-
-
-
- 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.
-
-
-
- Download and install Zadig .
-
-
- Under Device
in the menu bar, select Create New Device
.
-
-
-
- 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.
-
-
-
-
- No additional software is required for macOS or Linux.
-
-
-
-
-
- Fastboot
- Follow these steps to put your device into fastboot mode:
-
- Power off the device and wait for the LEDs to switch off.
- Connect power to the OBD-C port (port 1) .
- Then, quickly connect
- the device to your computer using the USB-C port (port 2) .
- After a few seconds, the device should indicate it's in fastboot mode and show its serial number.
-
-
-
- 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",
+ `
+
+ Retry
+
+ `,
+ );
+ 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",
+ },
+});