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 ( -
-
- cable -
-
- -
- {title} - {description} - {error && ( - - ) || 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}
- -
; -} - -export default function App() { - const version = import.meta.env.VITE_PUBLIC_GIT_SHA || 'dev' - console.info(`flash.comma.ai version: ${version}`) - return ( -
-
-
- comma -

flash.comma.ai

-

- This tool allows you to flash AGNOS onto your comma device. AGNOS is the Ubuntu-based operating system for - your comma 3/3X. -

-
-
- -
-

Requirements

-
    -
  • - A web browser which supports WebUSB - {" "}(such as Google Chrome, Microsoft Edge, Opera), running on Windows, macOS, Linux, or Android. -
  • -
  • - A 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.

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

No additional software is required for macOS, Linux or Android.

- )} -
-
- -
-

Flashing

-

Follow these steps to put your device into QDL mode:

-
    -
  1. Unplug the device and wait for the LED to switch off.
  2. -
  3. First, connect the device to your computer using the lower USB-C port (port 1).
  4. -
  5. Second, connect power to the upper OBD-C port (port 2).
  6. -
- image showing comma three and two ports. the lower port is labeled 1. the upper port is labeled 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}
+ +
+ ); +} 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 ( +
+
+ cable +
+
+ +
+ + {title} + + + {description} + + {(error && ( + + )) || + 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:

+
    +
  1. Unplug the device and wait for the LED to switch off.
  2. +
  3. + First, connect the device to your computer using the{" "} + lower{" "} + USB-C port{" "} + (port 1). +
  4. +
  5. + Second, connect power to the upper{" "} + OBD-C port{" "} + (port 2). +
  6. +
+ image showing comma three and two ports. the lower port is labeled 1. the upper port is labeled 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 ( +
+ comma +

flash.comma.ai

+

+ This tool allows you to flash AGNOS onto your comma device. AGNOS is the + Ubuntu-based operating system for your{" "} + + comma 3/3X + + . +

+
+ ); +} 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. +

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

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", + }, +});