diff --git a/bun.lockb b/bun.lockb index d81811e4..0b03b559 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/index.html b/index.html index 3060b4a2..2da0072d 100644 --- a/index.html +++ b/index.html @@ -13,10 +13,5 @@
- diff --git a/package.json b/package.json index 7968bfd6..9f2a1444 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,11 @@ "jssha": "^3.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "solid-js": "^1.8.15", "xz-decompress": "^0.2.1" }, "devDependencies": { + "@solidjs/testing-library": "^0.8.10", "@tailwindcss/typography": "^0.5.13", "@testing-library/jest-dom": "^6.4.5", "@testing-library/react": "^15.0.7", @@ -39,6 +41,7 @@ "postcss": "^8.4.38", "tailwindcss": "^3.4.3", "vite": "^5.2.12", + "vite-plugin-solid": "^2.10.1", "vite-svg-loader": "^5.1.0", "vitest": "^1.6.0" } diff --git a/src/app/App.test.jsx b/src/app/App.test.jsx index 206de720..fd9b9d88 100644 --- a/src/app/App.test.jsx +++ b/src/app/App.test.jsx @@ -1,10 +1,17 @@ -import { Suspense } from 'react' -import { expect, test } from 'vitest' -import { render, screen } from '@testing-library/react' +import { describe, expect, test } from "vitest"; +import { render, screen } from "@solidjs/testing-library"; +import { Suspense } from "solid-js"; +import App from "."; -import App from '.' - -test('renders without crashing', () => { - render() - expect(screen.getByText('flash.comma.ai')).toBeInTheDocument() -}) +describe("App", () => { + test("renders without crashing", () => { + render(() => ( +
+ + + +
+ )); + expect(screen.getByText("flash.comma.ai")).toBeInTheDocument(); + }); +}); diff --git a/src/app/Flash.jsx b/src/app/Flash.jsx index 1bff6d1d..89650737 100644 --- a/src/app/Flash.jsx +++ b/src/app/Flash.jsx @@ -1,303 +1,212 @@ -import { useCallback, useState } from 'react' - -import { Step, Error, useQdl } from '@/utils/flash' - -import bolt from '@/assets/bolt.svg' -import cable from '@/assets/cable.svg' -import cloud from '@/assets/cloud.svg' -import cloudDownload from '@/assets/cloud_download.svg' -import cloudError from '@/assets/cloud_error.svg' -import deviceExclamation from '@/assets/device_exclamation_c3.svg' -import deviceQuestion from '@/assets/device_question_c3.svg' -import done from '@/assets/done.svg' -import exclamation from '@/assets/exclamation.svg' -import frameAlert from '@/assets/frame_alert.svg' -import systemUpdate from '@/assets/system_update_c3.svg' - - -const steps = { - [Step.INITIALIZING]: { - status: 'Initializing...', - bgColor: 'bg-gray-400 dark:bg-gray-700', - icon: cloud, - }, - [Step.READY]: { - status: 'Ready', - description: 'Tap the button above to begin', - bgColor: 'bg-[#51ff00]', - icon: bolt, - iconStyle: '', - }, - [Step.CONNECTING]: { - status: 'Waiting for connection', - description: 'Follow the instructions to connect your device to your computer', - bgColor: 'bg-yellow-500', - icon: cable, - }, - [Step.DOWNLOADING]: { - status: 'Downloading...', - bgColor: 'bg-blue-500', - icon: cloudDownload, - }, - [Step.UNPACKING]: { - status: 'Unpacking...', - bgColor: 'bg-blue-500', - icon: cloudDownload, - }, - [Step.FLASHING]: { - status: 'Flashing device...', - description: 'Do not unplug your device until the process is complete.', - bgColor: 'bg-lime-400', - icon: systemUpdate, - }, - [Step.ERASING]: { - status: 'Erasing device...', - bgColor: 'bg-lime-400', - icon: systemUpdate, - }, - [Step.DONE]: { - status: 'Done', - description: 'Your device has been updated successfully. You can now unplug the all cables from your device, ' - +'and wait for the light to stop blinking then plug the power cord in again. ' - +' To complete the system reset, follow the instructions on your device.', - bgColor: 'bg-green-500', - icon: done, - }, -} - -const errors = { - [Error.UNKNOWN]: { - status: 'Unknown error', - description: 'An unknown error has occurred. Unplug your device and wait for 20s. ' + - 'Restart your browser and try again.', - bgColor: 'bg-red-500', - icon: exclamation, - }, - [Error.UNRECOGNIZED_DEVICE]: { - status: 'Unrecognized device', - description: 'The device connected to your computer is not supported.', - bgColor: 'bg-yellow-500', - icon: deviceQuestion, - }, - [Error.LOST_CONNECTION]: { - status: 'Lost connection', - description: 'The connection to your device was lost. Check that your cables are connected properly and try again. ' + - 'Unplug your device and wait for around 20s.', - icon: cable, - }, - [Error.DOWNLOAD_FAILED]: { - status: 'Download failed', - description:'The system image could not be downloaded. Unplug your device and wait for 20s. ' + - 'Check your internet connection and try again.', - icon: cloudError, - }, - [Error.CHECKSUM_MISMATCH]: { - status: 'Download mismatch', - description: 'The system image downloaded does not match the expected checksum. Try again.', - icon: frameAlert, - }, - [Error.FLASH_FAILED]: { - status: 'Flash failed', - description: 'The system image could not be flashed to your device. Try using a different cable, USB port, or ' + - 'computer. If the problem persists, join the #hw-three-3x channel on Discord for help.', - icon: deviceExclamation, - }, - [Error.ERASE_FAILED]: { - status: 'Erase failed', - description: 'The device could not be erased. Try using a different cable, USB port, or computer. If the problem ' + - 'persists, join the #hw-three-3x channel on Discord for help.', - icon: deviceExclamation, - }, - [Error.REQUIREMENTS_NOT_MET]: { - status: 'Requirements not met', - description: 'Your system does not meet the requirements to flash your device. Make sure to use a browser which ' + - 'supports WebUSB and is up to date.', - }, -} +import { + createSignal, + onCleanup, + createEffect, + createMemo, + Show, +} from "solid-js"; + +import { Step, Error, useQdl } from "@/utils/flash"; +import { errors } from "../constants/errors"; +import { steps } from "../constants/steps"; const detachScript = [ - "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" + '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', ]; -const isLinux = navigator.userAgent.toLowerCase().includes('linux'); +const isLinux = navigator.userAgent.toLowerCase().includes("linux"); -function LinearProgress({ value, barColor }) { - if (value === -1 || value > 100) value = 100 +function LinearProgress(props) { + let value = props.value; + if (value === -1 || value > 100) value = 100; return (
- ) + ); } - function USBIndicator() { - return
- - - - Device connected -
+ return ( +
+ + + + Device connected +
+ ); } - -function SerialIndicator({ serial }) { - return
- - Serial: - {serial || 'unknown'} - -
+function SerialIndicator(props) { + return ( +
+ + Serial: + {props.serial() || "unknown"} + +
+ ); } - -function DeviceState({ serial }) { +function DeviceState(props) { 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?") + event.preventDefault(); + return (event.returnValue = + "Flash in progress. Are you sure you want to leave?"); } - export default function Flash() { const { step, message, progress, error, - onContinue, onRetry, - connected, serial, - } = useQdl() - - const handleContinue = useCallback(() => { - onContinue?.() - }, [onContinue]) - - const handleRetry = useCallback(() => { - onRetry?.() - }, [onRetry]) - - const uiState = steps[step] - if (error) { - Object.assign(uiState, errors[Error.UNKNOWN], errors[error]) - } - const { status, description, bgColor, icon, iconStyle = 'invert' } = uiState - - let title - if (message && !error) { - title = message + '...' - if (progress >= 0) { - title += ` (${(progress * 100).toFixed(0)}%)` - } - } else { - title = status - } + } = useQdl(); - // warn the user if they try to leave the page while flashing - if (Step.DOWNLOADING <= step && step <= Step.ERASING) { - window.addEventListener("beforeunload", beforeUnloadListener, { capture: true }) - } else { - window.removeEventListener("beforeunload", beforeUnloadListener, { capture: true }) - } + const [copied, setCopied] = createSignal(false); - const [copied, setCopied] = useState(false); const handleCopy = () => { setCopied(true); - setTimeout(() => { - setCopied(false); - }, 1000); + setTimeout(() => setCopied(false), 1000); }; + createEffect(() => { + if (Step.DOWNLOADING <= step() && step() <= Step.ERASING) { + window.addEventListener("beforeunload", beforeUnloadListener, { + capture: true, + }); + onCleanup(() => + window.removeEventListener("beforeunload", beforeUnloadListener, { + capture: true, + }), + ); + } + }); + + const uiState = createMemo(() => { + const state = steps[step()]; + if (error()) { + return { ...state, ...errors[Error.UNKNOWN], ...errors[error()] }; + } + return state; + }); + + const title = createMemo(() => { + if (message() && !error()) { + let text = message() + "..."; + if (progress() >= 0) { + text += ` (${(progress() * 100).toFixed(0)}%)`; + } + return text; + } + return uiState().status; + }); return ( -
+
onContinue()?.()} > cable
-
- + +
+
- {title} - {description} - {(title === "Lost connection" || title === "Ready") && isLinux && ( - <> - - It seems that you're on Linux, make sure to run the script below in your terminal after plugging in your device. - -
-
-
-
-                  {detachScript.map((line, index) => (
-                    
-                      {line}
-                    
-                  ))}
-                
-
- -
+ + + {title()} + + + {uiState().description} + + + {/* Linux instructions */} + + + It seems that you're on Linux, make sure to run the script below + in your terminal after plugging in your device. + +
+
+
+
+                {detachScript.map((line, index) => (
+                  
+                    {line}
+                  
+                ))}
+              
+
+
- - )} - {error && ( +
+
+ + - ) || false} - {connected && } + + + {connected() && }
- ) + ); } diff --git a/src/app/index.jsx b/src/app/index.jsx index cd4369f0..d12c7b01 100644 --- a/src/app/index.jsx +++ b/src/app/index.jsx @@ -1,26 +1,35 @@ -import { Suspense, lazy } from 'react' +import { Suspense, lazy } from "solid-js"; -import comma from '../assets/comma.svg' -import fastbootPorts from '../assets/fastboot-ports.svg' -import zadigCreateNewDevice from '../assets/zadig_create_new_device.png' -import zadigForm from '../assets/zadig_form.png' +import comma from "../assets/comma.svg"; +import fastbootPorts from "../assets/fastboot-ports.svg"; +import zadigCreateNewDevice from "../assets/zadig_create_new_device.png"; +import zadigForm from "../assets/zadig_form.png"; -const Flash = lazy(() => import('./Flash')) +const Flash = lazy(() => import("./Flash")); export default function App() { - const version = import.meta.env.VITE_PUBLIC_GIT_SHA || 'dev' + const version = import.meta.env.VITE_PUBLIC_GIT_SHA || "dev"; console.info(`flash.comma.ai version: ${version}`); return (
- comma + 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. + + comma 3/3X + + .


@@ -29,14 +38,12 @@ export default function App() {

Requirements

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

USB Driver

@@ -48,7 +55,8 @@ export default function App() { Download and install Zadig.

  • - Under Device in the menu bar, select Create New Device. + Under Device in the menu bar, select{" "} + Create New Device. Zadig 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 05C6 and 9008 respectively. - Press "Install Driver" and give it a few minutes to install. - Zadig Form + you can fill in anything. The next two fields are very important. + Fill them in with 05C6 and 9008{" "} + respectively. Press "Install Driver" and give it a few + minutes to install. + Zadig Form
  • -

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

    +

    No additional software is required for macOS or Linux.


    @@ -80,9 +82,17 @@ export default function App() {

    Follow these steps to put your device into QDL mode:

    1. Power off the device and wait for the LEDs to switch off.
    2. -
    3. Connect the device to your computer using the USB-C port (port 2).
    4. -
    5. Connect power to the OBD-C port (port 1).
    6. -
    7. The device then should be visible as an option when choosing the device to flash
    8. +
    9. + Connect the device to your computer using the USB-C port{" "} + (port 2). +
    10. +
    11. + Connect power to the OBD-C port (port 1). +
    12. +
    13. + The device then should be visible as an option when choosing the + device to flash +

    Flashing

    - After your device is in QDL mode, you can click the button to start flashing. A prompt may appear to - select a device; choose the device starts with QUSB_BULK. + After your device is in QDL mode, you can click the button to start + flashing. A prompt may appear to select a device; choose the device + 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. + The process can take 30+ minutes depending on your internet + connection and system performance. Do not unplug the device until + all steps are complete.


    @@ -110,33 +122,42 @@ export default function App() {

    Troubleshooting

    Too slow

    - It is recommended that you use a USB 3.0 cable when flashing since it will speed up the flashing time by a lot. + It is recommended that you use a USB 3.0 cable when flashing since + it will speed up the flashing time by a lot.

    Cannot enter QDL

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

    My device's screen is blank

    - The device screen will be blank in QDL mode, but you can verify that it is in QDL if the device shows up - when you press the Flash icon. + The device screen will be blank in QDL mode, but you can verify that + it is in QDL if the device shows up when you press the Flash icon.

    After flashing, device says unable to mount data partition

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

    General Tips

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

    Other questions

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

    @@ -147,7 +168,9 @@ export default function App() {
    - Loading...

    }> + Loading...

    } + >
    @@ -156,5 +179,5 @@ export default function App() { flash.comma.ai version: {version.substring(0, 7)}
    - ) + ); } diff --git a/src/constants/errors.js b/src/constants/errors.js new file mode 100644 index 00000000..078fba51 --- /dev/null +++ b/src/constants/errors.js @@ -0,0 +1,64 @@ +import exclamation from "@/assets/exclamation.svg"; +import deviceQuestion from "@/assets/device_question_c3.svg"; +import cable from "@/assets/cable.svg"; +import cloudError from "@/assets/cloud_error.svg"; +import frameAlert from "@/assets/frame_alert.svg"; +import deviceExclamation from "@/assets/device_exclamation_c3.svg"; +import { Error } from "@/utils/flash"; + +export const errors = { + [Error.UNKNOWN]: { + status: "Unknown error", + description: + "An unknown error has occurred. Unplug your device and wait for 20s. " + + "Restart your browser and try again.", + bgColor: "bg-red-500", + icon: exclamation, + }, + [Error.UNRECOGNIZED_DEVICE]: { + status: "Unrecognized device", + description: "The device connected to your computer is not supported.", + bgColor: "bg-yellow-500", + icon: deviceQuestion, + }, + [Error.LOST_CONNECTION]: { + status: "Lost connection", + description: + "The connection to your device was lost. Check that your cables are connected properly and try again. " + + "Unplug your device and wait for around 20s.", + icon: cable, + }, + [Error.DOWNLOAD_FAILED]: { + status: "Download failed", + description: + "The system image could not be downloaded. Unplug your device and wait for 20s. " + + "Check your internet connection and try again.", + icon: cloudError, + }, + [Error.CHECKSUM_MISMATCH]: { + status: "Download mismatch", + description: + "The system image downloaded does not match the expected checksum. Try again.", + icon: frameAlert, + }, + [Error.FLASH_FAILED]: { + status: "Flash failed", + description: + "The system image could not be flashed to your device. Try using a different cable, USB port, or " + + "computer. If the problem persists, join the #hw-three-3x channel on Discord for help.", + icon: deviceExclamation, + }, + [Error.ERASE_FAILED]: { + status: "Erase failed", + description: + "The device could not be erased. Try using a different cable, USB port, or computer. If the problem " + + "persists, join the #hw-three-3x channel on Discord for help.", + icon: deviceExclamation, + }, + [Error.REQUIREMENTS_NOT_MET]: { + status: "Requirements not met", + description: + "Your system does not meet the requirements to flash your device. Make sure to use a browser which " + + "supports WebUSB and is up to date.", + }, +}; diff --git a/src/constants/steps.js b/src/constants/steps.js new file mode 100644 index 00000000..1d93cef6 --- /dev/null +++ b/src/constants/steps.js @@ -0,0 +1,57 @@ +import bolt from "@/assets/bolt.svg"; +import cable from "@/assets/cable.svg"; +import cloud from "@/assets/cloud.svg"; +import cloudDownload from "@/assets/cloud_download.svg"; +import systemUpdate from "@/assets/system_update_c3.svg"; +import done from "@/assets/done.svg"; +import { Step } from "@/utils/flash"; + +export const steps = { + [Step.INITIALIZING]: { + status: "Initializing...", + bgColor: "bg-gray-400 dark:bg-gray-700", + icon: cloud, + }, + [Step.READY]: { + status: "Ready", + description: "Tap the button above to begin", + bgColor: "bg-[#51ff00]", + icon: bolt, + iconStyle: "", + }, + [Step.CONNECTING]: { + status: "Waiting for connection", + description: + "Follow the instructions to connect your device to your computer", + bgColor: "bg-yellow-500", + icon: cable, + }, + [Step.DOWNLOADING]: { + status: "Downloading...", + bgColor: "bg-blue-500", + icon: cloudDownload, + }, + [Step.UNPACKING]: { + status: "Unpacking...", + bgColor: "bg-blue-500", + icon: cloudDownload, + }, + [Step.FLASHING]: { + status: "Flashing device...", + description: "Do not unplug your device until the process is complete.", + bgColor: "bg-lime-400", + icon: systemUpdate, + }, + [Step.ERASING]: { + status: "Erasing device...", + bgColor: "bg-lime-400", + icon: systemUpdate, + }, + [Step.DONE]: { + status: "Done", + description: + "Your device has been updated successfully. You can now unplug the all cables from your device, and wait for the light to stop blinking then plug the power cord in again. To complete the system reset, follow the instructions on your device.", + bgColor: "bg-green-500", + icon: done, + }, +}; diff --git a/src/main.jsx b/src/main.jsx index 40fea3ef..a8746785 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,14 +1,19 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' +import { render } from "solid-js/web"; +import "@fontsource-variable/inter"; +import "@fontsource-variable/jetbrains-mono"; +import "./index.css"; +import config from "./config"; +import App from "./app"; -import '@fontsource-variable/inter' -import '@fontsource-variable/jetbrains-mono' +const manifestPromise = fetch(config.manifests.release).then((r) => r.text()); -import './index.css' -import App from './app' +render( + () => , + document.getElementById("root"), +); -ReactDOM.createRoot(document.getElementById('root')).render( - - - , -) +const script = document.createElement("script"); +script.defer = true; +script.dataset.domain = "flash.comma.ai"; +script.src = "https://plausible.io/js/script.outbound-links.js"; +document.body.appendChild(script); diff --git a/src/utils/flash.js b/src/utils/flash.js index 0fe27763..c0d62569 100644 --- a/src/utils/flash.js +++ b/src/utils/flash.js @@ -1,18 +1,12 @@ -import { useEffect, useRef, useState } from 'react' - -import { concatUint8Array } from '@/QDL/utils' -import { qdlDevice } from '@/QDL/qdl' -import * as Comlink from 'comlink' - -import config from '@/config' -import { download } from '@/utils/blob' -import { useImageWorker } from '@/utils/image' -import { createManifest } from '@/utils/manifest' -import { withProgress } from '@/utils/progress' - -/** - * @typedef {import('./manifest.js').Image} Image - */ +import { createSignal, createEffect } from "solid-js"; +import { concatUint8Array } from "@/QDL/utils"; +import { qdlDevice } from "@/QDL/qdl"; +import * as Comlink from "comlink"; +import config from "@/config"; +import { download } from "@/utils/blob"; +import { useImageWorker } from "@/utils/image"; +import { createManifest } from "@/utils/manifest"; +import { withProgress } from "@/utils/progress"; export const Step = { INITIALIZING: 0, @@ -23,7 +17,7 @@ export const Step = { FLASHING: 6, ERASING: 7, DONE: 8, -} +}; export const Error = { UNKNOWN: -1, @@ -36,307 +30,295 @@ export const Error = { FLASH_FAILED: 6, ERASE_FAILED: 7, REQUIREMENTS_NOT_MET: 8, -} +}; function isRecognizedDevice(slotCount, partitions) { - if (slotCount !== 2) { - console.error('[QDL] Unrecognised device (slotCount)') - return false + console.error("[QDL] Unrecognised device (slotCount)"); + return false; } - // check we have the expected partitions to make sure it's a comma three const expectedPartitions = [ - "ALIGN_TO_128K_1", "ALIGN_TO_128K_2", "ImageFv", "abl", "aop", "apdp", "bluetooth", "boot", "cache", - "cdt", "cmnlib", "cmnlib64", "ddr", "devcfg", "devinfo", "dip", "dsp", "fdemeta", "frp", "fsc", "fsg", - "hyp", "keymaster", "keystore", "limits", "logdump", "logfs", "mdtp", "mdtpsecapp", "misc", "modem", - "modemst1", "modemst2", "msadp", "persist", "qupfw", "rawdump", "sec", "splash", "spunvm", "ssd", - "sti", "storsec", "system", "systemrw", "toolsfv", "tz", "userdata", "vm-linux", "vm-system", "xbl", - "xbl_config" - ] - if (!partitions.every(partition => expectedPartitions.includes(partition))) { - console.error('[QDL] Unrecognised device (partitions)', partitions) - return false + "ALIGN_TO_128K_1", + "ALIGN_TO_128K_2", + "ImageFv", + "abl", + "aop", + "apdp", + "bluetooth", + "boot", + "cache", + "cdt", + "cmnlib", + "cmnlib64", + "ddr", + "devcfg", + "devinfo", + "dip", + "dsp", + "fdemeta", + "frp", + "fsc", + "fsg", + "hyp", + "keymaster", + "keystore", + "limits", + "logdump", + "logfs", + "mdtp", + "mdtpsecapp", + "misc", + "modem", + "modemst1", + "modemst2", + "msadp", + "persist", + "qupfw", + "rawdump", + "sec", + "splash", + "spunvm", + "ssd", + "sti", + "storsec", + "system", + "systemrw", + "toolsfv", + "tz", + "userdata", + "vm-linux", + "vm-system", + "xbl", + "xbl_config", + ]; + if ( + !partitions.every((partition) => expectedPartitions.includes(partition)) + ) { + console.error("[QDL] Unrecognised device (partitions)", partitions); + return false; } - return true + return true; } - -export function useQdl() { - const [step, _setStep] = useState(Step.INITIALIZING) - const [message, _setMessage] = useState('') - const [progress, setProgress] = useState(0) - const [error, _setError] = useState(Error.NONE) - - const [connected, setConnected] = useState(false) - const [serial, setSerial] = useState(null) - - const [onContinue, setOnContinue] = useState(null) - const [onRetry, setOnRetry] = useState(null) - - const imageWorker = useImageWorker() - const qdl = useRef(new qdlDevice()) - - /** @type {React.RefObject} */ - const manifest = useRef(null) - - function setStep(step) { - _setStep(step) - } - - function setMessage(message = '') { - if (message) console.info('[QDL]', message) - _setMessage(message) - } - - function setError(error) { - _setError(error) - } - useEffect(() => { - setProgress(-1) - setMessage() - - if (error) return - if (!imageWorker.current) { - console.debug('[QDL] Waiting for image worker') - return - } - - switch (step) { +export function useQdl(manifestPromise) { + const [step, setStep] = createSignal(Step.INITIALIZING); + const [message, setMessage] = createSignal(""); + const [progress, setProgress] = createSignal(0); + const [error, setError] = createSignal(Error.NONE); + const [connected, setConnected] = createSignal(false); + const [serial, setSerial] = createSignal(null); + const [onContinue, setOnContinue] = createSignal(null); + const [onRetry, setOnRetry] = createSignal(null); + + const imageWorker = useImageWorker(); + const qdl = new qdlDevice(); + let manifest = null; + + const updateMessage = (msg = "") => { + if (msg) console.info("[QDL]", msg); + setMessage(msg); + }; + + createEffect(() => { + setProgress(-1); + updateMessage(); + + if (error()) return; + if (!imageWorker) return; + + switch (step()) { case Step.INITIALIZING: { - // Check that the browser supports WebUSB - if (typeof navigator.usb === 'undefined') { - console.error('[QDL] WebUSB not supported') - setError(Error.REQUIREMENTS_NOT_MET) - break - } - - // Check that the browser supports Web Workers - if (typeof Worker === 'undefined') { - console.error('[QDL] Web Workers not supported') - setError(Error.REQUIREMENTS_NOT_MET) - break + if ( + typeof navigator.usb === "undefined" || + typeof Worker === "undefined" || + typeof Storage === "undefined" + ) { + console.error("[QDL] Requirements not met"); + setError(Error.REQUIREMENTS_NOT_MET); + break; } - // Check that the browser supports Storage API - if (typeof Storage === 'undefined') { - console.error('[QDL] Storage API not supported') - setError(Error.REQUIREMENTS_NOT_MET) - break - } - - imageWorker.current?.init() - .then(() => download(config.manifests['release'])) - .then(blob => blob.text()) - .then(text => { - manifest.current = createManifest(text) - - // sanity check - if (manifest.current.length === 0) { - throw 'Manifest is empty' - } - - console.debug('[QDL] Loaded manifest', manifest.current) - setStep(Step.READY) + imageWorker + .init() + .then( + () => + manifestPromise || + download(config.manifests["release"]).then((b) => b.text()), + ) + .then((text) => { + manifest = createManifest(text); + if (manifest.length === 0) throw "Manifest is empty"; + setStep(Step.READY); }) .catch((err) => { - console.error('[QDL] Initialization error', err) - setError(Error.UNKNOWN) - }) - break + console.error("[QDL] Initialization error", err); + setError(Error.UNKNOWN); + }); + break; } case Step.READY: { - // wait for user interaction (we can't use WebUSB without user event) setOnContinue(() => () => { - setOnContinue(null) - setStep(Step.CONNECTING) - }) - break + setOnContinue(null); + setStep(Step.CONNECTING); + }); + break; } case Step.CONNECTING: { - qdl.current.waitForConnect() + qdl + .waitForConnect() .then(() => { - console.info('[QDL] Connected') - return qdl.current.getDevicePartitionsInfo() + console.info("[QDL] Connected"); + return qdl + .getDevicePartitionsInfo() .then(([slotCount, partitions]) => { - const recognized = isRecognizedDevice(slotCount, partitions) - console.debug('[QDL] Device info', { recognized, partitions}) - + const recognized = isRecognizedDevice(slotCount, partitions); if (!recognized) { - setError(Error.UNRECOGNIZED_DEVICE) - return + setError(Error.UNRECOGNIZED_DEVICE); + return; } - - setSerial(qdl.current.sahara.serial || 'unknown') - setConnected(true) - setStep(Step.DOWNLOADING) - }) - .catch((err) => { - console.error('[QDL] Error getting device information', err) - setError(Error.UNKNOWN) - }) + setSerial(qdl.sahara.serial || "unknown"); + setConnected(true); + setStep(Step.DOWNLOADING); + }); }) .catch((err) => { - console.error('[QDL] Connection lost', err) - setError(Error.LOST_CONNECTION) - setConnected(false) - }) - qdl.current.connect() - .catch((err) => { - console.error('[QDL] Connection error', err) - setStep(Step.READY) - }) - break + console.error("[QDL] Connection error", err); + setError(Error.LOST_CONNECTION); + setConnected(false); + }); + qdl.connect().catch(() => setStep(Step.READY)); + break; } case Step.DOWNLOADING: { - setProgress(0) - + setProgress(0); async function downloadImages() { - for await (const [image, onProgress] of withProgress(manifest.current, setProgress)) { - setMessage(`Downloading ${image.name}`) - await imageWorker.current.downloadImage(image, Comlink.proxy(onProgress)) + for await (const [image, onProgress] of withProgress( + manifest, + setProgress, + )) { + updateMessage(`Downloading ${image.name}`); + await imageWorker.downloadImage(image, Comlink.proxy(onProgress)); } } downloadImages() - .then(() => { - console.debug('[QDL] Downloaded all images') - setStep(Step.UNPACKING) - }) + .then(() => setStep(Step.UNPACKING)) .catch((err) => { - console.error('[QDL] Download error', err) - setError(Error.DOWNLOAD_FAILED) - }) - break + console.error("[QDL] Download error", err); + setError(Error.DOWNLOAD_FAILED); + }); + break; } case Step.UNPACKING: { - setProgress(0) - + setProgress(0); async function unpackImages() { - for await (const [image, onProgress] of withProgress(manifest.current, setProgress)) { - setMessage(`Unpacking ${image.name}`) - await imageWorker.current.unpackImage(image, Comlink.proxy(onProgress)) + for await (const [image, onProgress] of withProgress( + manifest, + setProgress, + )) { + updateMessage(`Unpacking ${image.name}`); + await imageWorker.unpackImage(image, Comlink.proxy(onProgress)); } } unpackImages() - .then(() => { - console.debug('[QDL] Unpacked all images') - setStep(Step.FLASHING) - }) + .then(() => setStep(Step.FLASHING)) .catch((err) => { - console.error('[QDL] Unpack error', err) - if (err.startsWith('Checksum mismatch')) { - setError(Error.CHECKSUM_MISMATCH) - } else { - setError(Error.UNPACK_FAILED) - } - }) - break + console.error("[QDL] Unpack error", err); + setError( + err.startsWith("Checksum mismatch") + ? Error.CHECKSUM_MISMATCH + : Error.UNPACK_FAILED, + ); + }); + break; } case Step.FLASHING: { - setProgress(0) - + setProgress(0); async function flashDevice() { - const currentSlot = await qdl.current.getActiveSlot(); - if (!['a', 'b'].includes(currentSlot)) { - throw `Unknown current slot ${currentSlot}` + const currentSlot = await qdl.getActiveSlot(); + if (!["a", "b"].includes(currentSlot)) + throw `Unknown current slot ${currentSlot}`; + const otherSlot = currentSlot === "a" ? "b" : "a"; + + await qdl.erase("xbl" + `_${currentSlot}`); + + for await (const [image, onProgress] of withProgress( + manifest, + setProgress, + )) { + const fileHandle = await imageWorker.getImage(image); + const blob = await fileHandle.getFile(); + updateMessage(`Flashing ${image.name}`); + await qdl.flashBlob(image.name + `_${otherSlot}`, blob, onProgress); } - const otherSlot = currentSlot === 'a' ? 'b' : 'a' - - // Erase current xbl partition so if users try to power up device - // with corrupted primary gpt header, it would not update the backup - await qdl.current.erase("xbl"+`_${currentSlot}`) - - for await (const [image, onProgress] of withProgress(manifest.current, setProgress)) { - const fileHandle = await imageWorker.current.getImage(image) - const blob = await fileHandle.getFile() - setMessage(`Flashing ${image.name}`) - const partitionName = image.name + `_${otherSlot}` - await qdl.current.flashBlob(partitionName, blob, onProgress) - } - console.debug('[QDL] Flashed all partitions') - - setMessage(`Changing slot to ${otherSlot}`) - await qdl.current.setActiveSlot(otherSlot) + updateMessage(`Changing slot to ${otherSlot}`); + await qdl.setActiveSlot(otherSlot); } flashDevice() - .then(() => { - console.debug('[QDL] Flash complete') - setStep(Step.ERASING) - }) + .then(() => setStep(Step.ERASING)) .catch((err) => { - console.error('[QDL] Flashing error', err) - setError(Error.FLASH_FAILED) - }) - break + console.error("[QDL] Flashing error", err); + setError(Error.FLASH_FAILED); + }); + break; } case Step.ERASING: { - setProgress(0) - - async function resetUserdata() { - let wData = new TextEncoder().encode("COMMA_RESET") - wData = new Blob([concatUint8Array([wData, new Uint8Array(28 - wData.length).fill(0)])]) // make equal sparseHeaderSize - await qdl.current.flashBlob("userdata", wData) - } - + setProgress(0); async function eraseDevice() { - setMessage('Erasing userdata') - await resetUserdata() - setProgress(0.9) - - setMessage('Rebooting') - await qdl.current.reset() - setProgress(1) - setConnected(false) + updateMessage("Erasing userdata"); + let wData = new TextEncoder().encode("COMMA_RESET"); + wData = new Blob([ + concatUint8Array([ + wData, + new Uint8Array(28 - wData.length).fill(0), + ]), + ]); + await qdl.flashBlob("userdata", wData); + setProgress(0.9); + + updateMessage("Rebooting"); + await qdl.reset(); + setProgress(1); + setConnected(false); } eraseDevice() - .then(() => { - console.debug('[QDL] Erase complete') - setStep(Step.DONE) - }) + .then(() => setStep(Step.DONE)) .catch((err) => { - console.error('[QDL] Erase error', err) - setError(Error.ERASE_FAILED) - }) - break + console.error("[QDL] Erase error", err); + setError(Error.ERASE_FAILED); + }); + break; } } - }, [error, imageWorker, step]) + }); - useEffect(() => { - if (error !== Error.NONE) { - console.debug('[QDL] error', error) - setProgress(-1) - setOnContinue(null) - - setOnRetry(() => () => { - console.debug('[QDL] on retry') - window.location.reload() - }) + createEffect(() => { + if (error() !== Error.NONE) { + setProgress(-1); + setOnContinue(null); + setOnRetry(() => () => window.location.reload()); } - }, [error]) + }); return { - step, - message, - progress, - error, - - connected, - serial, - - onContinue, - onRetry, - } + step: step, + message: message, + progress: progress, + error: error, + connected: connected, + serial: serial, + onContinue: onContinue, + onRetry: onRetry, + }; } - diff --git a/src/utils/image.js b/src/utils/image.js index ed27f09f..285ec201 100644 --- a/src/utils/image.js +++ b/src/utils/image.js @@ -1,17 +1,22 @@ -import { useEffect, useRef } from 'react' - -import * as Comlink from 'comlink' +import { onCleanup } from "solid-js"; +import * as Comlink from "comlink"; export function useImageWorker() { - const apiRef = useRef() + let worker = new Worker(new URL("../workers/image.worker", import.meta.url), { + type: "module", + }); + + const api = Comlink.wrap(worker); - useEffect(() => { - const worker = new Worker(new URL('../workers/image.worker', import.meta.url), { - type: 'module', - }) - apiRef.current = Comlink.wrap(worker) - return () => worker.terminate() - }, []) + onCleanup(() => { + worker.terminate(); + worker = null; + }); - return apiRef + return { + init: () => api.init(), + downloadImage: (image, progress) => api.downloadImage(image, progress), + unpackImage: (image, progress) => api.unpackImage(image, progress), + getImage: (image) => api.getImage(image), + }; } diff --git a/src/utils/manifest.test.js b/src/utils/manifest.test.js index 94b22dff..e9057650 100644 --- a/src/utils/manifest.test.js +++ b/src/utils/manifest.test.js @@ -1,76 +1,83 @@ -import { describe, expect, test, vi } from 'vitest' +import { describe, expect, test, vi } from "vitest"; -import * as Comlink from 'comlink' +import * as Comlink from "comlink"; -import config from '../config' -import { getManifest } from './manifest' +import config from "../config"; +import { getManifest } from "./manifest"; async function getImageWorker() { - let imageWorker + let imageWorker; - vi.mock('comlink') - vi.mocked(Comlink.expose).mockImplementation(worker => { - imageWorker = worker - imageWorker.init() - }) + 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') + vi.resetModules(); // this makes the import be reevaluated on each call + await import("./../workers/image.worker"); - return imageWorker + return imageWorker; } for (const [branch, manifestUrl] of Object.entries(config.manifests)) { describe(`${branch} manifest`, async () => { - const images = await getManifest(manifestUrl) + const images = await getManifest(manifestUrl); // Check all images are present - expect(images.length).toBe(7) + expect(images.length).toBe(7); for (const image of images) { describe(`${image.name} image`, async () => { - test('xz archive', () => { - expect(image.archiveFileName, 'archive to be in xz format').toContain('.xz') - expect(image.archiveUrl, 'archive url to be in xz format').toContain('.xz') - }) - - if (image.name === 'system') { - test('alt image', () => { - expect(image.sparse, 'system image to be sparse').toBe(true) - expect(image.fileName, 'system image to be skip chunks').toContain('-skip-chunks-') - expect(image.archiveUrl, 'system image to point to skip chunks').toContain('-skip-chunks-') - }) + test("xz archive", () => { + expect(image.archiveFileName, "archive to be in xz format").toContain( + ".xz", + ); + expect(image.archiveUrl, "archive url to be in xz format").toContain( + ".xz", + ); + }); + + if (image.name === "system" && image.sparse) { + test("alt image", () => { + expect(image.fileName).toContain("-skip-chunks-"); + expect(image.archiveUrl).toContain("-skip-chunks-"); + }); } - test('image and checksum', async () => { - const imageWorkerFileHandler = { - getFile: vi.fn(), - createWritable: vi.fn().mockImplementation(() => ({ - write: vi.fn(), - close: vi.fn(), - })), - } - - globalThis.navigator = { - storage: { - getDirectory: () => ({ - getFileHandle: () => imageWorkerFileHandler, - }) - } - } - - imageWorkerFileHandler.getFile.mockImplementation(async () => { - const response = await fetch(image.archiveUrl) - expect(response.ok, 'to be uploaded').toBe(true) - - return response.blob() - }) - - const imageWorker = await getImageWorker() - - await imageWorker.unpackImage(image) - }, 8 * 60 * 1000) - }) + test( + "image and checksum", + async () => { + const imageWorkerFileHandler = { + getFile: vi.fn(), + createWritable: vi.fn().mockImplementation(() => ({ + write: vi.fn(), + close: vi.fn(), + })), + }; + + globalThis.navigator = { + storage: { + getDirectory: () => ({ + getFileHandle: () => imageWorkerFileHandler, + }), + }, + }; + + imageWorkerFileHandler.getFile.mockImplementation(async () => { + const response = await fetch(image.archiveUrl); + expect(response.ok, "to be uploaded").toBe(true); + + return response.blob(); + }); + + const imageWorker = await getImageWorker(); + + await imageWorker.unpackImage(image); + }, + 8 * 60 * 1000, + ); + }); } - }) + }); } diff --git a/vite.config.js b/vite.config.js index ba64cd9d..3a54ae62 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,18 +1,21 @@ -import { fileURLToPath, URL } from 'node:url'; -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { fileURLToPath, URL } from "node:url"; +import { defineConfig } from "vite"; +import solid from "vite-plugin-solid"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [solid()], resolve: { alias: [ - { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }, + { + find: "@", + replacement: fileURLToPath(new URL("./src", import.meta.url)), + }, ], }, test: { globals: true, - environment: 'jsdom', - setupFiles: './src/test/setup.js', + environment: "jsdom", + setupFiles: "./src/test/setup.js", }, -}) +});