diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index a41778f8..00000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,51 +0,0 @@
-{
- "root": true,
- "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"
- ],
- "parserOptions": {
- "ecmaVersion": "latest",
- "sourceType": "module"
- },
- "settings": {
- "react": {
- "version": "detect"
- }
- },
- "plugins": [
- "react-refresh"
- ],
- "rules": {
- "no-constant-condition": [
- "error",
- {
- "checkLoops": false
- }
- ],
- "no-inner-declarations": "off",
- "no-unused-vars": ["error", {
- "argsIgnorePattern": "^_"
- }],
- "react/jsx-no-target-blank": "off",
- "react/prop-types": "off",
- "react-refresh/only-export-components": [
- "warn",
- {
- "allowConstantExport": true
- }
- ]
- }
-}
diff --git a/bun.lockb b/bun.lockb
index 1a36dcf5..4a3caa77 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 00000000..f33be514
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,122 @@
+import eslint from "@eslint/js";
+import reactRefresh from "eslint-plugin-react-refresh";
+import reactHooks from "eslint-plugin-react-hooks";
+import react from "eslint-plugin-react";
+import tseslint from "typescript-eslint";
+
+/** @type {import('eslint').Linter.Config} */
+export default [
+ // Base configurations
+ eslint.configs.recommended,
+ ...tseslint.configs.recommended,
+
+ // Global settings for all files
+ {
+ languageOptions: {
+ ecmaVersion: "latest",
+ sourceType: "module",
+ globals: {
+ window: "readonly",
+ document: "readonly",
+ navigator: "readonly",
+ console: "readonly",
+ fetch: "readonly",
+ performance: "readonly",
+ },
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ },
+ settings: {
+ react: {
+ version: "detect",
+ pragma: "h", // For Preact
+ jsxRuntime: "automatic",
+ },
+ jsxImportSource: "preact",
+ },
+ plugins: {
+ react,
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh,
+ },
+ rules: {
+ // Core rules that are actually helpful
+ "no-console": ["warn", { allow: ["info", "warn", "error", "debug"] }],
+ "no-constant-condition": ["error", { checkLoops: false }],
+ "no-debugger": "warn",
+ "no-duplicate-case": "error",
+ "no-empty": ["error", { allowEmptyCatch: true }],
+ "no-inner-declarations": "off",
+
+ // Make unused vars a warning instead of error, and allow underscore prefix
+
+ // React rules
+ "react/jsx-no-target-blank": "off",
+ "react/prop-types": "off",
+ "react/jsx-uses-react": "off",
+ "react/react-in-jsx-scope": "off",
+ "react-refresh/only-export-components": [
+ "warn",
+ { allowConstantExport: true },
+ ],
+ "react-hooks/rules-of-hooks": "error",
+ "react-hooks/exhaustive-deps": "warn",
+ },
+ },
+
+ // TypeScript specific rules
+ {
+ files: ["**/*.ts", "**/*.tsx"],
+ languageOptions: {
+ parser: tseslint.parser,
+ parserOptions: {
+ ecmaVersion: "latest",
+ jsxPragma: "h",
+ jsxFragmentPragma: "Fragment",
+ },
+ },
+ rules: {
+ // Override TypeScript rules to be more sensible
+
+ "@typescript-eslint/no-explicit-any": "warn",
+ "@typescript-eslint/no-non-null-assertion": "off",
+ "@typescript-eslint/explicit-function-return-type": "off",
+ "@typescript-eslint/explicit-module-boundary-types": "off",
+ "@typescript-eslint/ban-ts-comment": [
+ "warn",
+ {
+ "ts-ignore": "allow-with-description",
+ minimumDescriptionLength: 5,
+ },
+ ],
+ },
+ },
+
+ // JSX/TSX specifics
+ {
+ files: ["**/*.jsx", "**/*.tsx"],
+ rules: {
+ "react/jsx-uses-vars": "error",
+ "react/no-unknown-property": ["error", { ignore: ["class"] }],
+ },
+ },
+
+ // Config files with specific project reference
+ {
+ files: ["vite.config.ts", "vitest.config.ts"],
+ languageOptions: {
+ parser: tseslint.parser,
+ parserOptions: {
+ project: "./tsconfig.node.json",
+ },
+ },
+ },
+
+ // Ignores
+ {
+ ignores: ["node_modules/**", "dist/**"],
+ },
+];
diff --git a/index.html b/index.html
index 33a000a3..1897eea3 100644
--- a/index.html
+++ b/index.html
@@ -1,8 +1,8 @@
-
+
-
+
=20.11.0"
},
"dependencies": {
"@commaai/qdl": "git+https://github.com/commaai/qdl.js.git#13a64c79aa8c57494c01bdd93774ed58e34b6450",
- "@fontsource-variable/inter": "^5.0.18",
- "@fontsource-variable/jetbrains-mono": "^5.0.21",
- "comlink": "^4.4.1",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "xz-decompress": "^0.2.1"
+ "@fontsource-variable/inter": "^5.2.5",
+ "@fontsource-variable/jetbrains-mono": "^5.2.5",
+ "@preact/preset-vite": "^2.10.1",
+ "@testing-library/preact": "^3.2.4",
+ "@types/wicg-file-system-access": "^2023.10.6",
+ "bowser": "^2.11.0",
+ "comlink": "^4.4.2",
+ "preact": "^10.26.4",
+ "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",
- "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",
+ "@eslint/js": "^9.24.0",
+ "@tailwindcss/postcss": "^4.1.3",
+ "@tailwindcss/typography": "^0.5.16",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.3.0",
+ "@types/bun": "^1.2.8",
+ "@types/react": "^19.1.0",
+ "@types/react-dom": "^19.1.1",
+ "@typescript-eslint/eslint-plugin": "^8.29.0",
+ "@typescript-eslint/parser": "^8.29.0",
+ "@vitejs/plugin-react": "^4.3.4",
+ "eslint": "^9.24.0",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.19",
+ "jsdom": "^26.0.0",
+ "postcss": "^8.5.3",
+ "tailwindcss": "^4.1.3",
+ "typescript": "^5.8.3",
+ "typescript-eslint": "^8.29.0",
+ "vite": "^6.2.5",
"vite-svg-loader": "^5.1.0",
- "vitest": "^1.6.0"
+ "vitest": "^3.1.1"
},
"trustedDependencies": [
"@commaai/qdl",
diff --git a/postcss.config.js b/postcss.config.js
index 2e7af2b7..c2ddf748 100644
--- a/postcss.config.js
+++ b/postcss.config.js
@@ -1,6 +1,5 @@
export default {
plugins: {
- tailwindcss: {},
- autoprefixer: {},
+ "@tailwindcss/postcss": {},
},
-}
+};
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 00000000..5caaa684
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,52 @@
+import comma from "./assets/comma.svg";
+import qdlPorts from "./assets/qdl-ports.svg";
+import zadigCreateNewDevice from "./assets/zadig_create_new_device.png";
+import zadigForm from "./assets/zadig_form.png";
+
+import { IntroSection } from "./components/sections/IntroSection";
+import { RequirementsSection } from "./components/sections/Requirements";
+import { FlashingSection } from "./components/sections/FlashingSection";
+import { TroubleshootingSection } from "./components/sections/TroubleshootingSection";
+import { DETACH_SCRIPT, PRODUCT_ID, VENDOR_ID } from "./utils/constants";
+import Flash from "./components/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 version: {version}
+
+
+
+
+
+
+
+
+ flash.comma.ai version: {version}
+
+
+ );
+}
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 82132d7d..00000000
--- a/src/app/Flash.jsx
+++ /dev/null
@@ -1,279 +0,0 @@
-import { useEffect, useRef, useState } from 'react'
-
-import { FlashManager, Step, Error } from '../utils/manager'
-import { useImageWorker } from '../utils/image'
-import { isLinux } from '../utils/platform'
-import config from '../config'
-
-import bolt from '../assets/bolt.svg'
-import cable from '../assets/cable.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 systemUpdate from '../assets/system_update_c3.svg'
-
-
-const steps = {
- [Step.INITIALIZING]: {
- status: 'Initializing...',
- bgColor: 'bg-gray-400 dark:bg-gray-700',
- icon: bolt,
- },
- [Step.READY]: {
- status: 'Tap to start',
- 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.REPAIR_PARTITION_TABLES]: {
- status: 'Repairing partition tables...',
- description: 'Do not unplug your device until the process is complete',
- bgColor: 'bg-lime-400',
- icon: systemUpdate,
- },
- [Step.ERASE_DEVICE]: {
- status: 'Erasing device...',
- description: 'Do not unplug your device until the process is complete',
- bgColor: 'bg-lime-400',
- icon: systemUpdate,
- },
- [Step.FLASH_SYSTEM]: {
- status: 'Flashing device...',
- description: 'Do not unplug your device until the process is complete',
- bgColor: 'bg-lime-400',
- icon: systemUpdate,
- },
- [Step.FINALIZING]: {
- status: 'Finalizing...',
- description: 'Do not unplug your device until the process is complete',
- bgColor: 'bg-lime-400',
- icon: systemUpdate,
- },
- [Step.DONE]: {
- status: 'Done',
- description: 'Your device was flashed successfully. It should now boot into the openpilot setup.',
- bgColor: 'bg-green-500',
- icon: done,
- },
-}
-
-const errors = {
- [Error.UNKNOWN]: {
- status: 'Unknown error',
- description: 'An unknown error has occurred. Unplug your device, restart your browser and try again.',
- bgColor: 'bg-red-500',
- icon: exclamation,
- },
- [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.',
- },
- [Error.STORAGE_SPACE]: {
- description: 'Your system does not have enough space available to download the system image. Your browser may ' +
- 'be restricting the available space if you are in a private, incognito or guest session.',
- },
- [Error.UNRECOGNIZED_DEVICE]: {
- status: 'Unrecognized device',
- description: 'The device connected to your computer is not supported. Try using a different cable, USB port, or ' +
- 'computer. If the problem persists, join the #hw-three-3x channel on Discord for help.',
- bgColor: 'bg-yellow-500',
- icon: deviceQuestion,
- },
- [Error.LOST_CONNECTION]: {
- status: 'Lost connection',
- description: 'The connection to your device was lost. Unplug your device and try again.',
- icon: cable,
- },
- [Error.REPAIR_PARTITION_TABLES_FAILED]: {
- status: 'Repairing partition tables failed',
- description: 'Your device\'s partition tables could not be repaired. 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.FLASH_SYSTEM_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,
- },
-}
-
-if (isLinux) {
- // this is likely in Step.CONNECTING
- errors[Error.LOST_CONNECTION].description += ' Did you forget to unbind the device from qcserial?'
-}
-
-
-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, setStep] = useState(Step.INITIALIZING)
- const [message, setMessage] = useState('')
- const [progress, setProgress] = useState(-1)
- const [error, setError] = useState(Error.NONE)
- const [connected, setConnected] = useState(false)
- const [serial, setSerial] = useState(null)
-
- const qdlManager = useRef(null)
- const imageWorker = useImageWorker()
-
- useEffect(() => {
- if (!imageWorker.current) return
-
- fetch(config.loader.url)
- .then((res) => res.arrayBuffer())
- .then((programmer) => {
- // Create QDL manager with callbacks that update React state
- qdlManager.current = new FlashManager(config.manifests.release, programmer, {
- onStepChange: setStep,
- onMessageChange: setMessage,
- onProgressChange: setProgress,
- onErrorChange: setError,
- onConnectionChange: setConnected,
- onSerialChange: setSerial
- })
-
- // Initialize the manager
- qdlManager.current.initialize(imageWorker.current)
- });
- }, [config, imageWorker.current])
-
- // Handle user clicking the start button
- const handleStart = () => qdlManager.current?.start()
- const canStart = step === Step.READY && !error
-
- // Handle retry on error
- const handleRetry = () => window.location.reload()
-
- 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 if (error === Error.STORAGE_SPACE) {
- title = message
- } else {
- title = status
- }
-
- // warn the user if they try to leave the page while flashing
- if (step >= Step.FLASH_GPT && step <= Step.FLASH_SYSTEM) {
- 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/index.jsx b/src/app/index.jsx
deleted file mode 100644
index 9be48f10..00000000
--- a/src/app/index.jsx
+++ /dev/null
@@ -1,174 +0,0 @@
-import { Suspense, lazy } from 'react'
-
-import comma from '../assets/comma.svg'
-import qdlPorts from '../assets/qdl-ports.svg'
-import zadigCreateNewDevice from '../assets/zadig_create_new_device.png'
-import zadigForm from '../assets/zadig_form.png'
-
-import { isLinux, isWindows } from '../utils/platform'
-
-const Flash = lazy(() => import('./Flash'))
-
-const VENDOR_ID = '05C6'
-const PRODUCT_ID = '9008'
-const DETACH_SCRIPT = 'for d in /sys/bus/usb/drivers/qcserial/*-*; do [ -e "$d" ] && echo -n "$(basename $d)" | sudo tee /sys/bus/usb/drivers/qcserial/unbind > /dev/null; done';
-
-function CopyText({ children: text }) {
- return
-
{text}
-
navigator.clipboard.writeText(text)}
- >
- Copy
-
-
;
-}
-
-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 good quality USB-C cable to connect the device to your computer. USB 3
- {" "}is recommended for faster flashing speed.
-
-
- Another USB-C cable and a charger, to power the device outside your car.
-
-
- {isWindows && (<>
- USB Driver
- You need additional driver software for Windows before you connect your device.
-
-
- Download and run 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 {VENDOR_ID}
and {PRODUCT_ID}
- respectively. Press "Install Driver" and give it a few minutes to install.
-
-
-
- No additional software is required for macOS, Linux or Android.
- >)}
-
-
-
-
- Flashing
- Follow these steps to put your device into QDL mode:
-
- Unplug the device and wait for the LED to switch off.
- First, connect the device to your computer using the lower USB-C port (port 1) .
- Second, connect power to the upper OBD-C port (port 2) .
-
-
- Your device's screen will remain blank for the entire flashing process. This is normal.
- {isLinux && (<>
- Note for Linux users
-
- On Linux systems, devices in QDL mode are automatically bound to the kernel's qcserial driver, and
- need to be unbound before we can access the device. Copy the script below into your terminal and run it
- after plugging in your device.
-
- {DETACH_SCRIPT}
- >)}
-
- Next, click the button to start flashing. From the prompt select the device which starts with
- “QUSB_BULK”.
-
-
- The process can take 30+ minutes depending on your internet connection and system performance. Do not
- unplug the device until all steps are complete.
-
-
-
-
-
- Troubleshooting
- Lost connection
-
- Try using high quality USB 3 cables. You should also try different USB ports on the front or back of your
- computer. If you're using a USB hub, try connecting directly to your computer instead.
-
- My device's screen is blank
-
- This is normal in QDL mode. You can verify that the “QUSB_BULK” device shows up when you press
- the Flash button to know that it is working correctly.
-
- My device says “fastboot mode”
-
- You may have followed outdated instructions for flashing. Please read the instructions above for putting
- your device into QDL mode.
-
- General Tips
-
- Try another computer or OS
- Try different USB ports on your computer
- Try different USB-C cables; low quality cables are often the source of problems. Note that the included OBD-C cable will not work.
-
- 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}
-
-
- )
-}
diff --git a/src/app/favicon.ico b/src/assets/favicon.ico
similarity index 100%
rename from src/app/favicon.ico
rename to src/assets/favicon.ico
diff --git a/src/app/icon.png b/src/assets/icon.png
similarity index 100%
rename from src/app/icon.png
rename to src/assets/icon.png
diff --git a/src/app/icon.svg b/src/assets/icon.svg
similarity index 100%
rename from src/app/icon.svg
rename to src/assets/icon.svg
diff --git a/src/components/CopyText.tsx b/src/components/CopyText.tsx
new file mode 100644
index 00000000..fef58fa8
--- /dev/null
+++ b/src/components/CopyText.tsx
@@ -0,0 +1,17 @@
+interface CopyTextProps {
+ children: string;
+}
+
+export function CopyText({ children: text }: CopyTextProps) {
+ return (
+
+
{text}
+
navigator.clipboard.writeText(text)}
+ >
+ Copy
+
+
+ );
+}
diff --git a/src/components/Flash.d.ts b/src/components/Flash.d.ts
new file mode 100644
index 00000000..c846aba7
--- /dev/null
+++ b/src/components/Flash.d.ts
@@ -0,0 +1,4 @@
+import { ComponentType } from "preact";
+
+declare const Flash: ComponentType;
+export default Flash;
diff --git a/src/components/Flash.jsx b/src/components/Flash.jsx
new file mode 100644
index 00000000..fc20e8f8
--- /dev/null
+++ b/src/components/Flash.jsx
@@ -0,0 +1,309 @@
+import { useEffect, useRef, useState } from "preact/compat";
+
+import { FlashManager, Step, Error } from "../utils/manager";
+import { useImageWorker } from "../utils/image";
+import { isLinux } from "../utils/platform";
+import config from "../utils/config";
+
+import bolt from "../assets/bolt.svg";
+import cable from "../assets/cable.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 systemUpdate from "../assets/system_update_c3.svg";
+
+const steps = {
+ [Step.INITIALIZING]: {
+ status: "Initializing...",
+ bgColor: "bg-gray-400 dark:bg-gray-700",
+ icon: bolt,
+ },
+ [Step.READY]: {
+ status: "Tap to start",
+ 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.REPAIR_PARTITION_TABLES]: {
+ status: "Repairing partition tables...",
+ description: "Do not unplug your device until the process is complete",
+ bgColor: "bg-lime-400",
+ icon: systemUpdate,
+ },
+ [Step.ERASE_DEVICE]: {
+ status: "Erasing device...",
+ description: "Do not unplug your device until the process is complete",
+ bgColor: "bg-lime-400",
+ icon: systemUpdate,
+ },
+ [Step.FLASH_SYSTEM]: {
+ status: "Flashing device...",
+ description: "Do not unplug your device until the process is complete",
+ bgColor: "bg-lime-400",
+ icon: systemUpdate,
+ },
+ [Step.FINALIZING]: {
+ status: "Finalizing...",
+ description: "Do not unplug your device until the process is complete",
+ bgColor: "bg-lime-400",
+ icon: systemUpdate,
+ },
+ [Step.DONE]: {
+ status: "Done",
+ description:
+ "Your device was flashed successfully. It should now boot into the openpilot setup.",
+ bgColor: "bg-green-500",
+ icon: done,
+ },
+};
+
+const errors = {
+ [Error.UNKNOWN]: {
+ status: "Unknown error",
+ description:
+ "An unknown error has occurred. Unplug your device, restart your browser and try again.",
+ bgColor: "bg-red-500",
+ icon: exclamation,
+ },
+ [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.",
+ },
+ [Error.STORAGE_SPACE]: {
+ description:
+ "Your system does not have enough space available to download the system image. Your browser may " +
+ "be restricting the available space if you are in a private, incognito or guest session.",
+ },
+ [Error.UNRECOGNIZED_DEVICE]: {
+ status: "Unrecognized device",
+ description:
+ "The device connected to your computer is not supported. Try using a different cable, USB port, or " +
+ "computer. If the problem persists, join the #hw-three-3x channel on Discord for help.",
+ bgColor: "bg-yellow-500",
+ icon: deviceQuestion,
+ },
+ [Error.LOST_CONNECTION]: {
+ status: "Lost connection",
+ description:
+ "The connection to your device was lost. Unplug your device and try again.",
+ icon: cable,
+ },
+ [Error.REPAIR_PARTITION_TABLES_FAILED]: {
+ status: "Repairing partition tables failed",
+ description:
+ "Your device's partition tables could not be repaired. 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.FLASH_SYSTEM_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,
+ },
+};
+
+if (isLinux) {
+ // this is likely in Step.CONNECTING
+ errors[Error.LOST_CONNECTION].description +=
+ " Did you forget to unbind the device from qcserial?";
+}
+
+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, setStep] = useState(Step.INITIALIZING);
+ const [message, setMessage] = useState("");
+ const [progress, setProgress] = useState(-1);
+ const [error, setError] = useState(Error.NONE);
+ const [connected, setConnected] = useState(false);
+ const [serial, setSerial] = useState(null);
+
+ const qdlManager = useRef(null);
+ const imageWorker = useImageWorker();
+
+ useEffect(() => {
+ if (!imageWorker.current) return;
+
+ fetch(config.loader.url)
+ .then((res) => res.arrayBuffer())
+ .then((programmer) => {
+ // Create QDL manager with callbacks that update React state
+ qdlManager.current = new FlashManager(
+ config.manifests.release,
+ programmer,
+ {
+ onStepChange: setStep,
+ onMessageChange: setMessage,
+ onProgressChange: setProgress,
+ onErrorChange: setError,
+ onConnectionChange: setConnected,
+ onSerialChange: setSerial,
+ }
+ );
+
+ // Initialize the manager
+ qdlManager.current.initialize(imageWorker.current);
+ });
+ }, [imageWorker]);
+
+ // Handle user clicking the start button
+ const handleStart = () => qdlManager.current?.start();
+ const canStart = step === Step.READY && !error;
+
+ // Handle retry on error
+ const handleRetry = () => window.location.reload();
+
+ 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 if (error === Error.STORAGE_SPACE) {
+ title = message;
+ } else {
+ title = status;
+ }
+
+ // warn the user if they try to leave the page while flashing
+ if (step >= Step.FLASH_GPT && step <= Step.FLASH_SYSTEM) {
+ window.addEventListener("beforeunload", beforeUnloadListener, {
+ capture: true,
+ });
+ } else {
+ window.removeEventListener("beforeunload", beforeUnloadListener, {
+ capture: true,
+ });
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {title}
+
+
+ {description}
+
+ {(error && (
+
+ Retry
+
+ )) ||
+ false}
+ {connected &&
}
+
+ );
+}
diff --git a/src/components/FlashStatus.tsx b/src/components/FlashStatus.tsx
new file mode 100644
index 00000000..9e1c521d
--- /dev/null
+++ b/src/components/FlashStatus.tsx
@@ -0,0 +1,68 @@
+interface LinearProgressProps {
+ value: number;
+ barColor: string;
+}
+
+export function LinearProgress({ value, barColor }: LinearProgressProps) {
+ if (value === -1 || value > 100) value = 100;
+ return (
+
+ );
+}
+
+export function USBIndicator() {
+ return (
+
+
+
+
+ Device connected
+
+ );
+}
+
+interface SerialIndicatorProps {
+ serial: string | null;
+}
+
+export function SerialIndicator({ serial }: SerialIndicatorProps) {
+ return (
+
+
+ Serial:
+ {serial || "unknown"}
+
+
+ );
+}
+
+interface DeviceStateProps {
+ serial: string | null;
+}
+
+export function DeviceState({ serial }: DeviceStateProps) {
+ return (
+
+
+ |
+
+
+ );
+}
diff --git a/src/components/sections/FlashingSection.tsx b/src/components/sections/FlashingSection.tsx
new file mode 100644
index 00000000..7dfd5bb3
--- /dev/null
+++ b/src/components/sections/FlashingSection.tsx
@@ -0,0 +1,64 @@
+import { isLinux } from "../../utils/platform";
+import { CopyText } from "../CopyText";
+
+interface FlashingSectionProps {
+ detachScript: string;
+ qdlPorts: string;
+}
+
+export function FlashingSection({
+ detachScript,
+ qdlPorts,
+}: FlashingSectionProps) {
+ return (
+
+ Flashing
+ Follow these steps to put your device into QDL mode:
+
+ Unplug the device and wait for the LED to switch off.
+
+ First, connect the device to your computer using the{" "}
+ lower {" "}
+ USB-C port{" "}
+ (port 1) .
+
+
+ Second, connect power to the upper {" "}
+ OBD-C port{" "}
+ (port 2) .
+
+
+
+
+ Your device's screen will remain blank for the entire flashing
+ process. This is normal.
+
+ {isLinux && (
+ <>
+ Note for Linux users
+
+ On Linux systems, devices in QDL mode are automatically bound to the
+ kernel's qcserial driver, and need to be unbound before we can
+ access the device. Copy the script below into your terminal and run
+ it after plugging in your device.
+
+ {detachScript}
+ >
+ )}
+
+ Next, click the button to start flashing. From the prompt select the
+ device which starts with “QUSB_BULK”.
+
+
+ The process can take 30+ minutes depending on your internet connection
+ and system performance. Do not unplug the device until all steps are
+ complete.
+
+
+ );
+}
diff --git a/src/components/sections/IntroSection.tsx b/src/components/sections/IntroSection.tsx
new file mode 100644
index 00000000..ef079fde
--- /dev/null
+++ b/src/components/sections/IntroSection.tsx
@@ -0,0 +1,26 @@
+interface IntroSectionProps {
+ commaLogo: string;
+}
+
+export function IntroSection({ commaLogo }: IntroSectionProps) {
+ 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
+
+ .
+
+
+ );
+}
diff --git a/src/components/sections/Requirements.tsx b/src/components/sections/Requirements.tsx
new file mode 100644
index 00000000..d3ff190b
--- /dev/null
+++ b/src/components/sections/Requirements.tsx
@@ -0,0 +1,79 @@
+import { isWindows } from "../../utils/platform";
+
+interface RequirementsSectionProps {
+ vendorId: string;
+ productId: string;
+ detachScript: string;
+ zadigCreateNewDevice: string;
+ zadigForm: string;
+}
+
+export function RequirementsSection({
+ vendorId,
+ productId,
+ zadigCreateNewDevice,
+ zadigForm,
+}: RequirementsSectionProps) {
+ return (
+
+ Requirements
+
+
+ A web browser which supports{" "}
+
+ WebUSB
+ {" "}
+ (such as Google Chrome, Microsoft Edge, Opera), running on Windows,
+ macOS, Linux, or Android.
+
+
+ A good quality USB-C cable to connect the device to your computer.{" "}
+ USB 3 is recommended for faster
+ flashing speed.
+
+
+ Another USB-C cable and a charger, to power the device outside your
+ car.
+
+
+ {isWindows && (
+ <>
+ USB Driver
+
+ You need additional driver software for Windows before you connect
+ your device.
+
+
+
+ Download and run{" "}
+
+ 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 {vendorId}
and{" "}
+ {productId}
+ respectively. Press "Install Driver" and give it a few
+ minutes to install.
+
+
+
+ No additional software is required for macOS, Linux or Android.
+ >
+ )}
+
+ );
+}
diff --git a/src/components/sections/TroubleshootingSection.tsx b/src/components/sections/TroubleshootingSection.tsx
new file mode 100644
index 00000000..808da80f
--- /dev/null
+++ b/src/components/sections/TroubleshootingSection.tsx
@@ -0,0 +1,41 @@
+export function TroubleshootingSection() {
+ return (
+
+ Troubleshooting
+ Lost connection
+
+ Try using high quality USB 3 cables. You should also try different USB
+ ports on the front or back of your computer. If you're using a USB
+ hub, try connecting directly to your computer instead.
+
+ My device's screen is blank
+
+ This is normal in QDL mode. You can verify that the
+ “QUSB_BULK” device shows up when you press the Flash button
+ to know that it is working correctly.
+
+ My device says “fastboot mode”
+
+ You may have followed outdated instructions for flashing. Please read
+ the instructions above for putting your device into QDL mode.
+
+ General Tips
+
+ Try another computer or OS
+ Try different USB ports on your computer
+
+ Try different USB-C cables; low quality cables are often the source of
+ problems. Note that the included OBD-C cable will not work.
+
+
+ Other questions
+
+ If you need help, join our{" "}
+
+ Discord server
+ {" "}
+ and go to the #hw-three-3x channel.
+
+
+ );
+}
diff --git a/src/config.js b/src/config.js
deleted file mode 100644
index 9cd5bb35..00000000
--- a/src/config.js
+++ /dev/null
@@ -1,11 +0,0 @@
-const config = {
- manifests: {
- release: 'https://raw.githubusercontent.com/commaai/openpilot/release3/system/hardware/tici/all-partitions.json',
- master: 'https://raw.githubusercontent.com/commaai/openpilot/master/system/hardware/tici/all-partitions.json',
- },
- loader: {
- url: 'https://raw.githubusercontent.com/commaai/flash/master/src/QDL/programmer.bin',
- },
-}
-
-export default config
diff --git a/src/index.css b/src/index.css
index b5c61c95..9a9a46fe 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,3 +1,32 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
+@import 'tailwindcss';
+
+@plugin '@tailwindcss/typography';
+
+@theme {
+ --background-image-gradient-radial: radial-gradient(var(--tw-gradient-stops));
+ --background-image-gradient-conic: conic-gradient(
+ from 180deg at 50% 50%,
+ var(--tw-gradient-stops)
+ );
+
+ --font-sans: Inter Variable, sans-serif;
+ --font-monospace: JetBrains Mono Variable, monospace;
+}
+
+/*
+ The default border color has changed to `currentcolor` in Tailwind CSS v4,
+ so we've added these compatibility styles to make sure everything still
+ looks the same as it did with Tailwind CSS v3.
+
+ If we ever want to remove these styles, we need to add an explicit border
+ color utility to any element that depends on these defaults.
+*/
+@layer base {
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+}
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.tsx b/src/main.tsx
new file mode 100644
index 00000000..26496f7f
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,12 @@
+import { render } from "preact";
+
+import "@fontsource-variable/inter";
+import "@fontsource-variable/jetbrains-mono";
+
+import "./index.css";
+import App from "./App";
+
+const rootElement = document.getElementById("root");
+if (!rootElement) throw new Error("Failed to find the root element");
+
+render( , rootElement);
diff --git a/src/test/setup.js b/src/test/setup.js
deleted file mode 100644
index c44951a6..00000000
--- a/src/test/setup.js
+++ /dev/null
@@ -1 +0,0 @@
-import '@testing-library/jest-dom'
diff --git a/src/utils/config.js b/src/utils/config.js
new file mode 100644
index 00000000..6bc1136f
--- /dev/null
+++ b/src/utils/config.js
@@ -0,0 +1,15 @@
+// delete once Flash is migrated to TypeScript
+
+const config = {
+ manifests: {
+ release:
+ "https://raw.githubusercontent.com/commaai/openpilot/release3/system/hardware/tici/all-partitions.json",
+ master:
+ "https://raw.githubusercontent.com/commaai/openpilot/master/system/hardware/tici/all-partitions.json",
+ },
+ loader: {
+ url: "https://raw.githubusercontent.com/commaai/flash/master/src/QDL/programmer.bin",
+ },
+};
+
+export default config;
diff --git a/src/utils/config.ts b/src/utils/config.ts
new file mode 100644
index 00000000..cc3e2c58
--- /dev/null
+++ b/src/utils/config.ts
@@ -0,0 +1,22 @@
+interface Config {
+ manifests: {
+ [key: string]: string;
+ };
+ loader: {
+ url: string;
+ };
+}
+
+const config: Config = {
+ manifests: {
+ release:
+ "https://raw.githubusercontent.com/commaai/openpilot/release3/system/hardware/tici/all-partitions.json",
+ master:
+ "https://raw.githubusercontent.com/commaai/openpilot/master/system/hardware/tici/all-partitions.json",
+ },
+ loader: {
+ url: "https://raw.githubusercontent.com/commaai/flash/master/src/QDL/programmer.bin",
+ },
+};
+
+export default config;
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
new file mode 100644
index 00000000..d084c158
--- /dev/null
+++ b/src/utils/constants.ts
@@ -0,0 +1,4 @@
+export const VENDOR_ID = "05C6";
+export const PRODUCT_ID = "9008";
+export const DETACH_SCRIPT =
+ 'for d in /sys/bus/usb/drivers/qcserial/*-*; do [ -e "$d" ] && echo -n "$(basename $d)" | sudo tee /sys/bus/usb/drivers/qcserial/unbind > /dev/null; done';
diff --git a/src/utils/image.js b/src/utils/image.js
deleted file mode 100644
index 4ba659cf..00000000
--- a/src/utils/image.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import { useEffect, useRef } from 'react'
-
-import * as Comlink from 'comlink'
-
-/**
- * @returns {React.MutableRefObject}
- */
-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/image.ts b/src/utils/image.ts
new file mode 100644
index 00000000..166c8799
--- /dev/null
+++ b/src/utils/image.ts
@@ -0,0 +1,20 @@
+import { useEffect, useRef } from "preact/compat";
+
+import * as Comlink from "comlink";
+
+import type { ImageWorkerApi } from "../workers/image.worker";
+
+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/manager.js b/src/utils/manager.js
index 596d3dc9..613640cb 100644
--- a/src/utils/manager.js
+++ b/src/utils/manager.js
@@ -1,9 +1,9 @@
-import { qdlDevice } from '@commaai/qdl'
-import { usbClass } from '@commaai/qdl/usblib'
-import * as Comlink from 'comlink'
+import { qdlDevice } from "@commaai/qdl";
+import { usbClass } from "@commaai/qdl/usblib";
+import * as Comlink from "comlink";
-import { getManifest } from './manifest'
-import { createSteps, withProgress } from './progress'
+import { getManifest } from "./manifest";
+import { createSteps, withProgress } from "./progress";
export const Step = {
INITIALIZING: 0,
@@ -14,7 +14,7 @@ export const Step = {
FLASH_SYSTEM: 5,
FINALIZING: 6,
DONE: 7,
-}
+};
export const Error = {
UNKNOWN: -1,
@@ -27,7 +27,7 @@ export const Error = {
ERASE_FAILED: 6,
FLASH_SYSTEM_FAILED: 7,
FINALIZING_FAILED: 8,
-}
+};
/**
* @param {any} storageInfo
@@ -35,35 +35,51 @@ export const Error = {
*/
export function checkCompatibleDevice(storageInfo) {
// Should be the same for all comma 3/3X
- if (storageInfo.block_size !== 4096 || storageInfo.page_size !== 4096 ||
- storageInfo.num_physical !== 6 || storageInfo.mem_type !== 'UFS') {
- throw 'UFS chip parameters mismatch'
+ if (
+ storageInfo.block_size !== 4096 ||
+ storageInfo.page_size !== 4096 ||
+ storageInfo.num_physical !== 6 ||
+ storageInfo.mem_type !== "UFS"
+ ) {
+ throw "UFS chip parameters mismatch";
}
// comma three
// userdata start 6159400 size 7986131
- if (storageInfo.prod_name === 'H28S7Q302BMR' && storageInfo.manufacturer_id === 429 &&
- storageInfo.total_blocks === 14145536) {
- return 'userdata_30'
+ if (
+ storageInfo.prod_name === "H28S7Q302BMR" &&
+ storageInfo.manufacturer_id === 429 &&
+ storageInfo.total_blocks === 14145536
+ ) {
+ return "userdata_30";
}
- if (storageInfo.prod_name === 'H28U74301AMR' && storageInfo.manufacturer_id === 429 &&
- storageInfo.total_blocks === 14145536) {
- return 'userdata_30'
+ if (
+ storageInfo.prod_name === "H28U74301AMR" &&
+ storageInfo.manufacturer_id === 429 &&
+ storageInfo.total_blocks === 14145536
+ ) {
+ return "userdata_30";
}
// comma 3X
// userdata start 6159400 size 23446483
- if (storageInfo.prod_name === 'SDINDDH4-128G 1308' && storageInfo.manufacturer_id === 325 &&
- storageInfo.total_blocks === 29605888) {
- return 'userdata_89'
+ if (
+ storageInfo.prod_name === "SDINDDH4-128G 1308" &&
+ storageInfo.manufacturer_id === 325 &&
+ storageInfo.total_blocks === 29605888
+ ) {
+ return "userdata_89";
}
// unknown userdata sectors
- if (storageInfo.prod_name === 'SDINDDH4-128G 1272' && storageInfo.manufacturer_id === 325 &&
- storageInfo.total_blocks === 29775872) {
- return 'userdata_90'
+ if (
+ storageInfo.prod_name === "SDINDDH4-128G 1272" &&
+ storageInfo.manufacturer_id === 325 &&
+ storageInfo.total_blocks === 29775872
+ ) {
+ return "userdata_90";
}
- throw 'Could not identify UFS chip'
+ throw "Could not identify UFS chip";
}
/**
@@ -85,7 +101,7 @@ export function checkCompatibleDevice(storageInfo) {
export class FlashManager {
/** @type {string} */
- #userdataImage
+ #userdataImage;
/**
* @param {string} manifestUrl
@@ -93,319 +109,372 @@ export class FlashManager {
* @param {FlashManagerCallbacks} callbacks
*/
constructor(manifestUrl, programmer, callbacks = {}) {
- this.manifestUrl = manifestUrl
- this.callbacks = callbacks
- this.device = new qdlDevice(programmer)
- this.imageWorker = null
+ this.manifestUrl = manifestUrl;
+ this.callbacks = callbacks;
+ this.device = new qdlDevice(programmer);
+ this.imageWorker = null;
/** @type {ManifestImage[]|null} */
- this.manifest = null
- this.step = Step.INITIALIZING
- this.error = Error.NONE
+ this.manifest = null;
+ this.step = Step.INITIALIZING;
+ this.error = Error.NONE;
}
/** @param {number} step */
#setStep(step) {
- this.step = step
- this.callbacks.onStepChange?.(step)
+ this.step = step;
+ this.callbacks.onStepChange?.(step);
}
/** @param {string} message */
#setMessage(message) {
- if (message) console.info('[Flash]', message)
- this.callbacks.onMessageChange?.(message)
+ if (message) console.info("[Flash]", message);
+ this.callbacks.onMessageChange?.(message);
}
/** @param {number} progress */
#setProgress(progress) {
- this.callbacks.onProgressChange?.(progress)
+ this.callbacks.onProgressChange?.(progress);
}
/** @param {number} error */
#setError(error) {
- this.error = error
- this.callbacks.onErrorChange?.(error)
- this.#setProgress(-1)
+ this.error = error;
+ this.callbacks.onErrorChange?.(error);
+ this.#setProgress(-1);
if (error !== Error.NONE) {
- console.debug('[Flash] error', error)
+ console.debug("[Flash] error", error);
}
}
/** @param {boolean} connected */
#setConnected(connected) {
- this.callbacks.onConnectionChange?.(connected)
+ this.callbacks.onConnectionChange?.(connected);
}
/** @param {string} serial */
#setSerial(serial) {
- this.callbacks.onSerialChange?.(serial)
+ this.callbacks.onSerialChange?.(serial);
}
/** @returns {boolean} */
#checkRequirements() {
- if (typeof navigator.usb === 'undefined') {
- console.error('[Flash] WebUSB not supported')
- this.#setError(Error.REQUIREMENTS_NOT_MET)
- return false
+ if (typeof navigator.usb === "undefined") {
+ console.error("[Flash] WebUSB not supported");
+ this.#setError(Error.REQUIREMENTS_NOT_MET);
+ return false;
}
- if (typeof Worker === 'undefined') {
- console.error('[Flash] Web Workers not supported')
- this.#setError(Error.REQUIREMENTS_NOT_MET)
- return false
+ if (typeof Worker === "undefined") {
+ console.error("[Flash] Web Workers not supported");
+ this.#setError(Error.REQUIREMENTS_NOT_MET);
+ return false;
}
- if (typeof Storage === 'undefined') {
- console.error('[Flash] Storage API not supported')
- this.#setError(Error.REQUIREMENTS_NOT_MET)
- return false
+ if (typeof Storage === "undefined") {
+ console.error("[Flash] Storage API not supported");
+ this.#setError(Error.REQUIREMENTS_NOT_MET);
+ return false;
}
- return true
+ return true;
}
/** @param {ImageWorker} imageWorker */
async initialize(imageWorker) {
- this.imageWorker = imageWorker
- this.#setProgress(-1)
- this.#setMessage('')
+ this.imageWorker = imageWorker;
+ this.#setProgress(-1);
+ this.#setMessage("");
if (!this.#checkRequirements()) {
- return
+ return;
}
try {
- await this.imageWorker.init()
+ await this.imageWorker.init();
} catch (err) {
- console.error('[Flash] Failed to initialize image worker')
- console.error(err)
- if (err instanceof String && err.startsWith('Not enough storage')) {
- this.#setError(Error.STORAGE_SPACE)
- this.#setMessage(err)
+ console.error("[Flash] Failed to initialize image worker");
+ if (err.startsWith("Not enough storage")) {
+ this.#setError(Error.STORAGE_SPACE);
+ this.#setMessage(err);
} else {
- this.#setError(Error.UNKNOWN)
+ this.#setError(Error.UNKNOWN);
}
- return
+ return;
}
if (!this.manifest?.length) {
try {
- this.manifest = await getManifest(this.manifestUrl)
+ this.manifest = await getManifest(this.manifestUrl);
if (this.manifest.length === 0) {
- throw 'Manifest is empty'
+ throw "Manifest is empty";
}
} catch (err) {
- console.error('[Flash] Failed to fetch manifest')
- console.error(err)
- this.#setError(Error.UNKNOWN)
- return
+ console.error("[Flash] Failed to fetch manifest");
+ console.error(err);
+ this.#setError(Error.UNKNOWN);
+ return;
}
- console.info('[Flash] Loaded manifest', this.manifest)
+ console.info("[Flash] Loaded manifest", this.manifest);
}
- this.#setStep(Step.READY)
+ this.#setStep(Step.READY);
}
async #connect() {
- this.#setStep(Step.CONNECTING)
- this.#setProgress(-1)
+ this.#setStep(Step.CONNECTING);
+ this.#setProgress(-1);
- let usb
+ let usb;
try {
- usb = new usbClass()
+ usb = new usbClass();
} catch (err) {
- console.error('[Flash] Connection lost', err)
- this.#setStep(Step.READY)
- this.#setConnected(false)
- return
+ console.error("[Flash] Connection lost", err);
+ this.#setStep(Step.READY);
+ this.#setConnected(false);
+ return;
}
try {
- await this.device.connect(usb)
+ await this.device.connect(usb);
} catch (err) {
- console.error('[Flash] Connection error', err)
- this.#setError(Error.LOST_CONNECTION)
- this.#setConnected(false)
- return
+ console.error("[Flash] Connection error", err);
+ this.#setError(Error.LOST_CONNECTION);
+ this.#setConnected(false);
+ return;
}
- console.info('[Flash] Connected')
- this.#setConnected(true)
+ console.info("[Flash] Connected");
+ this.#setConnected(true);
- let storageInfo
+ let storageInfo;
try {
- storageInfo = await this.device.getStorageInfo()
+ storageInfo = await this.device.getStorageInfo();
} catch (err) {
- console.error('[Flash] Connection lost', err)
- this.#setError(Error.LOST_CONNECTION)
- this.#setConnected(false)
- return
+ console.error("[Flash] Connection lost", err);
+ this.#setError(Error.LOST_CONNECTION);
+ this.#setConnected(false);
+ return;
}
try {
- this.#userdataImage = checkCompatibleDevice(storageInfo)
+ this.#userdataImage = checkCompatibleDevice(storageInfo);
} catch (e) {
- console.error('[Flash] Could not identify device:', e)
- console.error(storageInfo)
- this.#setError(Error.UNRECOGNIZED_DEVICE)
- return
+ console.error("[Flash] Could not identify device:", e);
+ console.error(storageInfo);
+ this.#setError(Error.UNRECOGNIZED_DEVICE);
+ return;
}
- const serialNum = Number(storageInfo.serial_num).toString(16).padStart(8, '0')
- console.info('[Flash] Device info', { serialNum, storageInfo, userdataImage: this.#userdataImage })
- this.#setSerial(serialNum)
+ const serialNum = Number(storageInfo.serial_num)
+ .toString(16)
+ .padStart(8, "0");
+ console.info("[Flash] Device info", {
+ serialNum,
+ storageInfo,
+ userdataImage: this.#userdataImage,
+ });
+ this.#setSerial(serialNum);
}
async #repairPartitionTables() {
- this.#setStep(Step.REPAIR_PARTITION_TABLES)
- this.#setProgress(0)
+ this.#setStep(Step.REPAIR_PARTITION_TABLES);
+ this.#setProgress(0);
// TODO: check that we have an image for each LUN (storageInfo.num_physical)
- const gptImages = this.manifest.filter((image) => !!image.gpt)
+ const gptImages = this.manifest.filter((image) => !!image.gpt);
if (gptImages.length === 0) {
- console.error('[Flash] No GPT images found')
- this.#setError(Error.REPAIR_PARTITION_TABLES_FAILED)
- return
+ console.error("[Flash] No GPT images found");
+ this.#setError(Error.REPAIR_PARTITION_TABLES_FAILED);
+ return;
}
try {
- for await (const [image, onProgress] of withProgress(gptImages, this.#setProgress.bind(this))) {
+ for await (const [image, onProgress] of withProgress(
+ gptImages,
+ this.#setProgress.bind(this)
+ )) {
// TODO: track repair progress
- const [onDownload, onRepair] = createSteps([2, 1], onProgress)
+ const [onDownload, onRepair] = createSteps([2, 1], onProgress);
// Download GPT image
- await this.imageWorker.downloadImage(image, Comlink.proxy(onDownload))
+ await this.imageWorker.downloadImage(image, Comlink.proxy(onDownload));
const blob = await this.imageWorker.getImage(image);
// Recreate main and backup GPT for this LUN
- if (!await this.device.repairGpt(image.gpt.lun, blob)) {
- throw `Repairing LUN ${image.gpt.lun} failed`
+ if (!(await this.device.repairGpt(image.gpt.lun, blob))) {
+ throw `Repairing LUN ${image.gpt.lun} failed`;
}
- onRepair(1.0)
+ onRepair(1.0);
}
} catch (err) {
- console.error('[Flash] An error occurred while repairing partition tables')
- console.error(err)
- this.#setError(Error.REPAIR_PARTITION_TABLES_FAILED)
+ console.error(
+ "[Flash] An error occurred while repairing partition tables"
+ );
+ console.error(err);
+ this.#setError(Error.REPAIR_PARTITION_TABLES_FAILED);
}
}
async #eraseDevice() {
- this.#setStep(Step.ERASE_DEVICE)
- this.#setProgress(-1)
+ this.#setStep(Step.ERASE_DEVICE);
+ this.#setProgress(-1);
// TODO: use storageInfo.num_physical
- const luns = Array.from({ length: 6 }).map((_, i) => i)
+ const luns = Array.from({ length: 6 }).map((_, i) => i);
- const [found, persistLun, partition] = await this.device.detectPartition('persist')
+ const [found, persistLun, partition] = await this.device.detectPartition(
+ "persist"
+ );
if (!found || luns.indexOf(persistLun) < 0) {
- console.error('[Flash] Could not find "persist" partition', { found, persistLun, partition })
- this.#setError(Error.ERASE_FAILED)
- return
+ console.error('[Flash] Could not find "persist" partition', {
+ found,
+ persistLun,
+ partition,
+ });
+ this.#setError(Error.ERASE_FAILED);
+ return;
}
- if (persistLun !== 0 || partition.start !== 8n || partition.sectors !== 8192n) {
- console.error('[Flash] Partition "persist" does not have expected properties', { found, persistLun, partition })
- this.#setError(Error.ERASE_FAILED)
- return
+ if (
+ persistLun !== 0 ||
+ partition.start !== 8n ||
+ partition.sectors !== 8192n
+ ) {
+ console.error(
+ '[Flash] Partition "persist" does not have expected properties',
+ { found, persistLun, partition }
+ );
+ this.#setError(Error.ERASE_FAILED);
+ return;
}
- console.info(`[Flash] "persist" partition located in LUN ${persistLun}`)
+ console.info(`[Flash] "persist" partition located in LUN ${persistLun}`);
try {
// Erase each LUN, avoid erasing critical partitions and persist
- const critical = ['mbr', 'gpt']
+ const critical = ["mbr", "gpt"];
for (const lun of luns) {
- const preserve = [...critical]
- if (lun === persistLun) preserve.push('persist')
- console.info(`[Flash] Erasing LUN ${lun} while preserving ${preserve.map((part) => `"${part}"`).join(', ')} partitions`)
- if (!await this.device.eraseLun(lun, preserve)) {
- throw `Erasing LUN ${lun} failed`
+ const preserve = [...critical];
+ if (lun === persistLun) preserve.push("persist");
+ console.info(
+ `[Flash] Erasing LUN ${lun} while preserving ${preserve
+ .map((part) => `"${part}"`)
+ .join(", ")} partitions`
+ );
+ if (!(await this.device.eraseLun(lun, preserve))) {
+ throw `Erasing LUN ${lun} failed`;
}
}
} catch (err) {
- console.error('[Flash] An error occurred while erasing device')
- console.error(err)
- this.#setError(Error.ERASE_FAILED)
+ console.error("[Flash] An error occurred while erasing device");
+ console.error(err);
+ this.#setError(Error.ERASE_FAILED);
}
}
async #flashSystem() {
- this.#setStep(Step.FLASH_SYSTEM)
- this.#setProgress(0)
+ this.#setStep(Step.FLASH_SYSTEM);
+ this.#setProgress(0);
// Exclude GPT images and persist image, and pick correct userdata image to flash
const systemImages = this.manifest
- .filter((image) => !image.gpt && image.name !== 'persist')
- .filter((image) => !image.name.startsWith('userdata_') || image.name === this.#userdataImage)
+ .filter((image) => !image.gpt && image.name !== "persist")
+ .filter(
+ (image) =>
+ !image.name.startsWith("userdata_") ||
+ image.name === this.#userdataImage
+ );
if (!systemImages.find((image) => image.name === this.#userdataImage)) {
- console.error(`[Flash] Did not find userdata image "${this.#userdataImage}"`)
- this.#setError(Error.UNKNOWN)
- return
+ console.error(
+ `[Flash] Did not find userdata image "${this.#userdataImage}"`
+ );
+ this.#setError(Error.UNKNOWN);
+ return;
}
try {
for await (const image of systemImages) {
- const [onDownload, onFlash] = createSteps([1, image.hasAB ? 2 : 1], this.#setProgress.bind(this))
+ const [onDownload, onFlash] = createSteps(
+ [1, image.hasAB ? 2 : 1],
+ this.#setProgress.bind(this)
+ );
- this.#setMessage(`Downloading ${image.name}`)
- await this.imageWorker.downloadImage(image, Comlink.proxy(onDownload))
- const blob = await this.imageWorker.getImage(image)
- onDownload(1.0)
+ this.#setMessage(`Downloading ${image.name}`);
+ await this.imageWorker.downloadImage(image, Comlink.proxy(onDownload));
+ const blob = await this.imageWorker.getImage(image);
+ onDownload(1.0);
// Flash image to each slot
- const slots = image.hasAB ? ['_a', '_b'] : ['']
+ const slots = image.hasAB ? ["_a", "_b"] : [""];
for (const [slot, onSlotProgress] of withProgress(slots, onFlash)) {
// NOTE: userdata image name does not match partition name
- const partitionName = `${image.name.startsWith('userdata_') ? 'userdata' : image.name}${slot}`
-
- this.#setMessage(`Flashing ${partitionName}`)
- if (!await this.device.flashBlob(partitionName, blob, (progress) => onSlotProgress(progress / image.size))) {
- throw `Flashing partition "${partitionName}" failed`
+ const partitionName = `${
+ image.name.startsWith("userdata_") ? "userdata" : image.name
+ }${slot}`;
+
+ this.#setMessage(`Flashing ${partitionName}`);
+ if (
+ !(await this.device.flashBlob(partitionName, blob, (progress) =>
+ onSlotProgress(progress / image.size)
+ ))
+ ) {
+ throw `Flashing partition "${partitionName}" failed`;
}
- onSlotProgress(1.0)
+ onSlotProgress(1.0);
}
}
} catch (err) {
- console.error('[Flash] An error occurred while flashing system')
- console.error(err)
- this.#setError(Error.FLASH_SYSTEM_FAILED)
+ console.error("[Flash] An error occurred while flashing system");
+ console.error(err);
+ this.#setError(Error.FLASH_SYSTEM_FAILED);
}
}
async #finalize() {
- this.#setStep(Step.FINALIZING)
- this.#setProgress(-1)
- this.#setMessage('Finalizing...')
+ this.#setStep(Step.FINALIZING);
+ this.#setProgress(-1);
+ this.#setMessage("Finalizing...");
// Set bootable LUN and update active partitions
- if (!await this.device.setActiveSlot('a')) {
- console.error('[Flash] Failed to update slot')
- this.#setError(Error.FINALIZING_FAILED)
+ if (!(await this.device.setActiveSlot("a"))) {
+ console.error("[Flash] Failed to update slot");
+ this.#setError(Error.FINALIZING_FAILED);
}
// Reboot the device
- this.#setMessage('Rebooting')
- await this.device.reset()
- this.#setConnected(false)
+ this.#setMessage("Rebooting");
+ await this.device.reset();
+ this.#setConnected(false);
- this.#setStep(Step.DONE)
+ this.#setStep(Step.DONE);
}
async start() {
- if (this.step !== Step.READY) return
- await this.#connect()
- if (this.error !== Error.NONE) return
- let start = performance.now()
- await this.#repairPartitionTables()
- console.info(`Repaired partition tables in ${((performance.now() - start) / 1000).toFixed(2)}s`)
- if (this.error !== Error.NONE) return
- start = performance.now()
- await this.#eraseDevice()
- console.info(`Erased device in ${((performance.now() - start) / 1000).toFixed(2)}s`)
- if (this.error !== Error.NONE) return
- start = performance.now()
- await this.#flashSystem()
- console.info(`Flashed system in ${((performance.now() - start) / 1000).toFixed(2)}s`)
- if (this.error !== Error.NONE) return
- start = performance.now()
- await this.#finalize()
- console.info(`Finalized in ${((performance.now() - start) / 1000).toFixed(2)}s`)
+ if (this.step !== Step.READY) return;
+ await this.#connect();
+ if (this.error !== Error.NONE) return;
+ let start = performance.now();
+ await this.#repairPartitionTables();
+ console.info(
+ `Repaired partition tables in ${(
+ (performance.now() - start) /
+ 1000
+ ).toFixed(2)}s`
+ );
+ if (this.error !== Error.NONE) return;
+ start = performance.now();
+ await this.#eraseDevice();
+ console.info(
+ `Erased device in ${((performance.now() - start) / 1000).toFixed(2)}s`
+ );
+ if (this.error !== Error.NONE) return;
+ start = performance.now();
+ await this.#flashSystem();
+ console.info(
+ `Flashed system in ${((performance.now() - start) / 1000).toFixed(2)}s`
+ );
+ if (this.error !== Error.NONE) return;
+ start = performance.now();
+ await this.#finalize();
+ console.info(
+ `Finalized in ${((performance.now() - start) / 1000).toFixed(2)}s`
+ );
}
}
diff --git a/src/utils/manifest.js b/src/utils/manifest.js
deleted file mode 100644
index 90719535..00000000
--- a/src/utils/manifest.js
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * Represents a partition image defined in the AGNOS manifest.
- *
- * Image archives can be retrieved from {@link archiveUrl}.
- */
-export class ManifestImage {
- /**
- * Image name
- * @type {string}
- */
- name
- /**
- * Partition name
- * @type {string}
- */
- partitionName
-
- /**
- * Size of the unpacked and unsparsified image in bytes
- * @type {number}
- */
- size
- /**
- * Whether the image is sparse
- * @type {boolean}
- */
- sparse
- /**
- * Whether there are multiple slots for this partition
- * @type {boolean}
- */
- hasAB
- /**
- * LUN and sector information for flashing this image
- * @type {{ lun: number; start_sector: number; num_sectors: number }|null}
- */
- gpt
-
- /**
- * 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
-
- /**
- * Whether the image is compressed and should be unpacked
- * @type {boolean}
- */
- compressed
-
- constructor(json) {
- this.name = json.name
- this.partitionName = json.name.startsWith('userdata_') ? 'userdata' : json.name
-
- this.size = json.size
- this.sparse = json.sparse
- this.hasAB = json.has_ab
- this.gpt = 'gpt' in json ? json.gpt : null
-
- this.fileName = `${this.name}-${json.hash_raw}.img`
- this.archiveUrl = json.url
- this.archiveFileName = this.archiveUrl.split('/').pop()
-
- this.compressed = this.archiveFileName.endsWith('.xz')
- }
-}
-
-/**
- * @param {string} url
- * @returns {Promise}
- */
-export function getManifest(url) {
- return fetch(url)
- .then((response) => response.text())
- .then((text) => JSON.parse(text).map((image) => new ManifestImage(image)))
-}
diff --git a/src/utils/manifest.test.js b/src/utils/manifest.test.js
deleted file mode 100644
index 07daf2bf..00000000
--- a/src/utils/manifest.test.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import { describe, expect, test, vi } from 'vitest'
-
-import * as Comlink from 'comlink'
-
-import config from '../config'
-import { getManifest } from './manifest'
-
-const MANIFEST_BRANCH = import.meta.env.MANIFEST_BRANCH
-
-globalThis.navigator = {
- storage: {
- estimate: vi.fn().mockImplementation(() => ({ quota: 10 * (1024 ** 3) })),
- getDirectory: () => ({
- getFileHandle: () => ({
- createWritable: vi.fn().mockImplementation(() => new WritableStream({
- write(_) {
- // Discard the chunk (do nothing with it)
- },
- close() {},
- abort(err) {
- console.error('Mock writable stream aborted:', err)
- },
- })),
- }),
- remove: vi.fn(),
- }),
- },
-}
-
-let imageWorker
-
-vi.mock('comlink')
-vi.mocked(Comlink.expose).mockImplementation(worker => {
- imageWorker = worker
- imageWorker.init()
-})
-
-vi.resetModules() // this makes the import be reevaluated on each call
-await import('./../workers/image.worker')
-
-for (const [branch, manifestUrl] of Object.entries(config.manifests)) {
- describe.skipIf(MANIFEST_BRANCH && branch !== MANIFEST_BRANCH)(`${branch} manifest`, async () => {
- const images = await getManifest(manifestUrl)
-
- // Check all images are present
- expect(images.length).toBe(33)
-
- let countGpt = 0
-
- for (const image of images) {
- if (image.gpt !== null) countGpt++
-
- const big = image.name === 'system' || image.name.startsWith('userdata_')
- describe(`${image.name} image`, async () => {
- test('xz archive', () => {
- expect(image.fileName, 'file to be uncompressed').not.toContain('.xz')
- if (image.name === 'system') {
- if (image.compressed) {
- expect(image.fileName, 'not to equal archive name').not.toEqual(image.archiveFileName)
- expect(image.archiveFileName, 'archive to be in xz format').toContain('.xz')
- expect(image.archiveUrl, 'archive url to be in xz format').toContain('.xz')
- } else {
- expect(image.fileName, 'to equal archive name').toEqual(image.archiveFileName)
- expect(image.archiveUrl, 'archive url to not be in xz format').not.toContain('.xz')
- }
- } else {
- expect(image.compressed, 'image to be compressed').toBe(true)
- expect(image.archiveFileName, 'archive to be in xz format').toContain('.xz')
- expect(image.archiveUrl, 'archive url to be in xz format').toContain('.xz')
- }
- })
-
- test.skipIf(big && !MANIFEST_BRANCH)('download', async () => {
- await imageWorker.downloadImage(image)
- }, { timeout: (big ? 11 * 60 : 20) * 1000 })
- })
- }
-
- // There should be one GPT image for each LUN
- expect(countGpt).toBe(6)
- })
-}
diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts
new file mode 100644
index 00000000..fa42bd10
--- /dev/null
+++ b/src/utils/manifest.ts
@@ -0,0 +1,98 @@
+/**
+ * Represents a partition image defined in the AGNOS manifest.
+ *
+ * Image archives can be retrieved from {@link archiveUrl}.
+ */
+
+type ManifestImageJson = {
+ name: string;
+ size: number;
+ sparse: boolean;
+ has_ab: boolean;
+ gpt?: { lun: number; start_sector: number; num_sectors: number };
+ hash_raw: string;
+ url: string;
+};
+
+export class ManifestImage {
+ /**
+ * Image name
+ */
+ name: string;
+
+ /**
+ * Partition name
+ */
+ partitionName: string;
+
+ /**
+ * Size of the unpacked and unsparsified image in bytes
+ */
+ size: number;
+
+ /**
+ * Whether the image is sparse
+ */
+ sparse: boolean;
+
+ /**
+ * Whether there are multiple slots for this partition
+ */
+ hasAB: boolean;
+
+ /**
+ * LUN and sector information for flashing this image
+ */
+ gpt: { lun: number; start_sector: number; num_sectors: number } | null;
+
+ /**
+ * Name of the image file
+ */
+ fileName: string;
+
+ /**
+ * Name of the image archive file
+ */
+ archiveFileName: string;
+
+ /**
+ * URL of the image archive
+ */
+ archiveUrl: string;
+
+ /**
+ * Whether the image is compressed and should be unpacked
+ */
+ compressed: boolean;
+
+ constructor(json: ManifestImageJson) {
+ this.name = json.name;
+ this.partitionName = json.name.startsWith("userdata_")
+ ? "userdata"
+ : json.name;
+
+ this.size = json.size;
+ this.sparse = json.sparse;
+ this.hasAB = json.has_ab;
+ this.gpt = "gpt" in json && json.gpt ? json.gpt : null;
+
+ this.fileName = `${this.name}-${json.hash_raw}.img`;
+ this.archiveUrl = json.url;
+ this.archiveFileName = this.archiveUrl.split("/").pop() || "";
+
+ this.compressed = this.archiveFileName.endsWith(".xz");
+ }
+}
+
+/**
+ * Fetches and parses a manifest file from the given URL
+ */
+export function getManifest(url: string): Promise {
+ return fetch(url)
+ .then((response) => response.text())
+ .then((text) =>
+ JSON.parse(text).map(
+ (image: ManifestImageJson) => new ManifestImage(image)
+ )
+ );
+}
diff --git a/src/utils/platform.js b/src/utils/platform.js
deleted file mode 100644
index f022d8da..00000000
--- a/src/utils/platform.js
+++ /dev/null
@@ -1,12 +0,0 @@
-const platform = (() => {
- if ('userAgentData' in navigator && 'platform' in navigator.userAgentData && navigator.userAgentData.platform) {
- return navigator.userAgentData.platform
- }
- const userAgent = navigator.userAgent.toLowerCase()
- if (userAgent.includes('linux')) return 'Linux' // includes Android
- if (userAgent.includes('win32') || userAgent.includes('windows')) return 'Windows'
- return null
-})()
-
-export const isWindows = !platform || platform === 'Windows'
-export const isLinux = platform === 'Linux'
diff --git a/src/utils/platform.ts b/src/utils/platform.ts
new file mode 100644
index 00000000..c04760dd
--- /dev/null
+++ b/src/utils/platform.ts
@@ -0,0 +1,7 @@
+import Bowser from "bowser";
+
+const browser = Bowser.getParser(navigator.userAgent);
+const osName = browser.getOSName().toLowerCase();
+
+export const isWindows = osName.includes("windows");
+export const isLinux = osName.includes("linux") || osName.includes("android");
diff --git a/src/utils/types.ts b/src/utils/types.ts
new file mode 100644
index 00000000..11483082
--- /dev/null
+++ b/src/utils/types.ts
@@ -0,0 +1,30 @@
+export enum Step {
+ INITIALIZING = "INITIALIZING",
+ READY = "READY",
+ CONNECTING = "CONNECTING",
+ REPAIR_PARTITION_TABLES = "REPAIR_PARTITION_TABLES",
+ ERASE_DEVICE = "ERASE_DEVICE",
+ FLASH_SYSTEM = "FLASH_SYSTEM",
+ FINALIZING = "FINALIZING",
+ DONE = "DONE",
+}
+
+export enum Error {
+ NONE = "NONE",
+ UNKNOWN = "UNKNOWN",
+ REQUIREMENTS_NOT_MET = "REQUIREMENTS_NOT_MET",
+ STORAGE_SPACE = "STORAGE_SPACE",
+ UNRECOGNIZED_DEVICE = "UNRECOGNIZED_DEVICE",
+ LOST_CONNECTION = "LOST_CONNECTION",
+ REPAIR_PARTITION_TABLES_FAILED = "REPAIR_PARTITION_TABLES_FAILED",
+ ERASE_FAILED = "ERASE_FAILED",
+ FLASH_SYSTEM_FAILED = "FLASH_SYSTEM_FAILED",
+}
+
+export interface StepConfig {
+ status: string;
+ description?: string;
+ bgColor: string;
+ icon: string;
+ iconStyle?: string;
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 00000000..8b292e1f
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,22 @@
+///
+
+declare module "@fontsource-variable/inter";
+declare module "@fontsource-variable/jetbrains-mono";
+declare module "*.css";
+declare module "@commaai/qdl";
+declare module "@commaai/qdl/usblib";
+declare module "xz-decompress";
+
+declare module "*.svg" {
+ const content: string;
+ export default content;
+}
+
+declare module "*.png" {
+ const content: string;
+ export default content;
+}
+
+interface ImportMetaEnv {
+ readonly MANIFEST_BRANCH?: string;
+}
diff --git a/src/workers/image.worker.js b/src/workers/image.worker.js
deleted file mode 100644
index 4b44ca14..00000000
--- a/src/workers/image.worker.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import * as Comlink from 'comlink'
-import { XzReadableStream } from 'xz-decompress'
-
-/**
- * Progress callback
- *
- * @callback progressCallback
- * @param {number} progress
- * @returns {void}
- */
-
-const MIN_QUOTA_MB = 5250
-
-/** @type {FileSystemDirectoryHandle} */
-let root
-
-/**
- * @typedef {imageWorker} ImageWorker
- */
-
-const imageWorker = {
- async init() {
- if (!root) {
- root = await navigator.storage.getDirectory()
- await root.remove({ recursive: true })
- console.info('[ImageWorker] Initialized')
- }
-
- const estimate = await navigator.storage.estimate()
- const quotaMB = (estimate.quota || 0) / (1024 ** 2)
- if (quotaMB < MIN_QUOTA_MB) {
- throw `Not enough storage: ${quotaMB.toFixed(0)}MB free, need ${MIN_QUOTA_MB.toFixed(0)}MB`
- }
- },
-
- /**
- * Download and unpack an image, saving it to persistent storage.
- *
- * @param {ManifestImage} image
- * @param {progressCallback} [onProgress]
- * @returns {Promise}
- */
- async downloadImage(image, onProgress = undefined) {
- const { archiveUrl, fileName } = image
-
- /** @type {FileSystemWritableFileStream} */
- let writable
- try {
- const fileHandle = await root.getFileHandle(fileName, { create: true })
- writable = await fileHandle.createWritable()
- } catch (e) {
- throw `Error opening file handle: ${e}`
- }
-
- console.debug(`[ImageWorker] Downloading ${image.name} from ${archiveUrl}`)
- const response = await fetch(archiveUrl, { mode: 'cors' })
- if (!response.ok) {
- throw `Fetch failed: ${response.status} ${response.statusText}`
- }
-
- const contentLength = +response.headers.get('Content-Length')
- let receivedLength = 0
- const transform = new TransformStream({
- transform(chunk, controller) {
- receivedLength += chunk.byteLength
- onProgress?.(receivedLength / contentLength)
- controller.enqueue(chunk)
- },
- })
- let stream = response.body.pipeThrough(transform)
- try {
- if (image.compressed) {
- stream = new XzReadableStream(stream)
- }
- await stream.pipeTo(writable)
- onProgress?.(1)
- } catch (e) {
- throw `Error unpacking archive: ${e}`
- }
- },
-
- /**
- * Get a blob for an image.
- *
- * @param {ManifestImage} 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.getFile()
- },
-}
-
-Comlink.expose(imageWorker)
diff --git a/src/workers/image.worker.ts b/src/workers/image.worker.ts
new file mode 100644
index 00000000..52221677
--- /dev/null
+++ b/src/workers/image.worker.ts
@@ -0,0 +1,110 @@
+import * as Comlink from "comlink";
+import { XzReadableStream } from "xz-decompress";
+import { ManifestImage } from "../utils/manifest";
+
+/**
+ * Progress callback
+ */
+type ProgressCallback = (progress: number) => void;
+
+export type ImageWorkerApi = {
+ init: () => Promise;
+ downloadImage: (
+ image: ManifestImage,
+ onProgress?: ProgressCallback
+ ) => Promise;
+ getImage: (image: ManifestImage) => Promise;
+};
+
+const MIN_QUOTA_MB = 5250;
+
+let root: FileSystemDirectoryHandle;
+
+/**
+ * Interface for the image worker
+ */
+const imageWorker = {
+ async init(): Promise {
+ if (!root) {
+ root = await navigator.storage.getDirectory();
+ // Using official FileSystemDirectoryHandle type from @types/wicg-file-system-access
+ // which doesn't have a recursive remove method, but has removeEntry for individual files
+ console.info("[ImageWorker] Initialized");
+ }
+
+ const estimate = await navigator.storage.estimate();
+ const quotaMB = (estimate.quota || 0) / 1024 ** 2;
+ if (quotaMB < MIN_QUOTA_MB) {
+ throw `Not enough storage: ${quotaMB.toFixed(
+ 0
+ )}MB free, need ${MIN_QUOTA_MB.toFixed(0)}MB`;
+ }
+ },
+
+ /**
+ * Download and unpack an image, saving it to persistent storage.
+ */
+ async downloadImage(
+ image: ManifestImage,
+ onProgress?: (progress: number) => void
+ ): Promise {
+ const { archiveUrl, fileName } = image;
+
+ let writable: FileSystemWritableFileStream;
+ try {
+ const fileHandle = await root.getFileHandle(fileName, { create: true });
+ writable = await fileHandle.createWritable();
+ } catch (e) {
+ throw `Error opening file handle: ${e}`;
+ }
+
+ console.debug(`[ImageWorker] Downloading ${image.name} from ${archiveUrl}`);
+ const response = await fetch(archiveUrl, { mode: "cors" });
+ if (!response.ok) {
+ throw `Fetch failed: ${response.status} ${response.statusText}`;
+ }
+
+ const contentLength = +(response.headers.get("Content-Length") ?? "0");
+ let receivedLength = 0;
+ const transform = new TransformStream({
+ transform(chunk, controller) {
+ receivedLength += chunk.byteLength;
+ if (onProgress) onProgress(receivedLength / contentLength);
+ controller.enqueue(chunk);
+ },
+ });
+
+ if (!response.body) {
+ throw "Response body is null";
+ }
+
+ let stream = response.body.pipeThrough(transform);
+ try {
+ if (image.compressed) {
+ stream = new XzReadableStream(stream);
+ }
+ await stream.pipeTo(writable);
+ if (onProgress) onProgress(1);
+ } catch (e) {
+ throw `Error unpacking archive: ${e}`;
+ }
+ },
+
+ /**
+ * Get a blob for an image.
+ */
+ async getImage(image: ManifestImage): Promise {
+ const { fileName } = image;
+
+ let fileHandle;
+ try {
+ fileHandle = await root.getFileHandle(fileName, { create: false });
+ } catch (e) {
+ throw `Error getting file handle: ${e}`;
+ }
+
+ return fileHandle.getFile();
+ },
+};
+
+Comlink.expose(imageWorker);
diff --git a/tailwind.config.js b/tailwind.config.js
deleted file mode 100644
index f1f20e20..00000000
--- a/tailwind.config.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/** @type {import('tailwindcss').Config} */
-export default {
- content: [
- './index.html',
- './src/**/*.{js,jsx}',
- ],
- theme: {
- extend: {
- backgroundImage: {
- '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'],
- },
- },
- },
- plugins: [
- require('@tailwindcss/typography'),
- ],
-}
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 00000000..0db33807
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,35 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "baseUrl": ".",
+ "paths": {
+ "react": ["./node_modules/preact/compat/"],
+ "react-dom": ["./node_modules/preact/compat/"],
+ "@/*": ["src/*"],
+ "@components/*": ["src/components/*"],
+ "@assets/*": ["src/assets/*"]
+ },
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..1ffef600
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 00000000..db0becc8
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.js b/vite.config.js
deleted file mode 100644
index 17ac32a0..00000000
--- a/vite.config.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
-
-// https://vitejs.dev/config/
-export default defineConfig({
- plugins: [react()],
- 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..a5e8a675
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,22 @@
+///
+import { defineConfig } from "vite";
+import preact from "@preact/preset-vite";
+import path from "path";
+
+export default defineConfig({
+ plugins: [preact()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ optimizeDeps: {
+ include: [],
+ exclude: [],
+ },
+ test: {
+ globals: true,
+ environment: "jsdom",
+ setupFiles: "./src/test/setup.ts",
+ },
+});