diff --git a/.eslintrc.json b/.eslintrc.json index dc9027b..ebfca5d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -7,9 +7,7 @@ }, "extends": [ "eslint:recommended", - "plugin:react/recommended", - "plugin:react/jsx-runtime", - "plugin:react-hooks/recommended" + "plugin:solid/recommended" ], "ignorePatterns": [ "node_modules", @@ -20,13 +18,8 @@ "ecmaVersion": "latest", "sourceType": "module" }, - "settings": { - "react": { - "version": "detect" - } - }, "plugins": [ - "react-refresh" + "solid" ], "rules": { "no-constant-condition": [ @@ -35,14 +28,6 @@ "checkLoops": false } ], - "no-inner-declarations": "off", - "react/jsx-no-target-blank": "off", - "react/prop-types": "off", - "react-refresh/only-export-components": [ - "warn", - { - "allowConstantExport": true - } - ] + "no-inner-declarations": "off" } } diff --git a/bun.lockb b/bun.lockb index d81811e..b4d3165 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 7968bfd..c6d1c7e 100644 --- a/package.json +++ b/package.json @@ -19,26 +19,21 @@ "comlink": "^4.4.1", "crc-32": "^1.2.2", "jssha": "^3.3.1", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "solid-js": "^1.8.15", "xz-decompress": "^0.2.1" }, "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", + "eslint-plugin-solid": "^0.13.1", "jsdom": "^22.1.0", "postcss": "^8.4.38", + "@solidjs/testing-library": "^0.5.0", "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 206de72..ee2a3df 100644 --- a/src/app/App.test.jsx +++ b/src/app/App.test.jsx @@ -1,10 +1,19 @@ -import { Suspense } from 'react' -import { expect, test } from 'vitest' -import { render, screen } from '@testing-library/react' +import { Suspense } from 'solid-js' +import { describe, it, expect } from 'vitest' +import { render, screen } from '@solidjs/testing-library' import App from '.' -test('renders without crashing', () => { - render() - expect(screen.getByText('flash.comma.ai')).toBeInTheDocument() +//todo-breaking test due to React -> SolidJS migration +describe('App', () => { + it('renders without crashing', () => { + const { unmount } = render(() => ( + + + + )) + + expect(screen.getByText('flash.comma.ai')).toBeInTheDocument() + unmount() + }) }) diff --git a/src/app/Flash.jsx b/src/app/Flash.jsx index 1bff6d1..856322f 100644 --- a/src/app/Flash.jsx +++ b/src/app/Flash.jsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { createSignal, createMemo, onCleanup, createEffect } from 'solid-js' import { Step, Error, useQdl } from '@/utils/flash' @@ -121,12 +121,13 @@ const detachScript = [ const isLinux = navigator.userAgent.toLowerCase().includes('linux'); -function LinearProgress({ value, barColor }) { +function LinearProgress(props) { + let value = props.value if (value === -1 || value > 100) value = 100 return ( -
+
@@ -135,11 +136,11 @@ function LinearProgress({ value, barColor }) { function USBIndicator() { - return
+ return
@@ -153,32 +154,31 @@ function USBIndicator() { } -function SerialIndicator({ serial }) { - return
+function SerialIndicator(props) { + return
Serial: - {serial || 'unknown'} + {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?") } @@ -190,46 +190,14 @@ export default function Flash() { message, progress, error, - onContinue, onRetry, - connected, serial, } = useQdl() - const handleContinue = useCallback(() => { - onContinue?.() - }, [onContinue]) - - const handleRetry = useCallback(() => { - onRetry?.() - }, [onRetry]) + const [copied, setCopied] = createSignal(false); - const uiState = steps[step] - if (error) { - Object.assign(uiState, errors[Error.UNKNOWN], errors[error]) - } - const { status, description, bgColor, icon, iconStyle = 'invert' } = uiState - - let title - if (message && !error) { - title = message + '...' - if (progress >= 0) { - title += ` (${(progress * 100).toFixed(0)}%)` - } - } else { - title = status - } - - // warn the user if they try to leave the page while flashing - if (Step.DOWNLOADING <= step && step <= Step.ERASING) { - window.addEventListener("beforeunload", beforeUnloadListener, { capture: true }) - } else { - window.removeEventListener("beforeunload", beforeUnloadListener, { capture: true }) - } - - const [copied, setCopied] = useState(false); const handleCopy = () => { setCopied(true); setTimeout(() => { @@ -237,49 +205,83 @@ export default function Flash() { }, 1000); }; + const uiState = createMemo(() => { + const currentStep = steps[step()] + if (error()) { + return { ...currentStep, ...errors[Error.UNKNOWN], ...errors[error()] } + } + return currentStep + }) + + const title = createMemo(() => { + if (message() && !error()) { + let t = message() + '...' + if (progress() >= 0) { + t += ` (${(progress() * 100).toFixed(0)}%)` + } + return t + } + return uiState().status + }) + + createEffect(() => { + if (Step.DOWNLOADING <= step() && step() <= Step.ERASING) { + window.addEventListener("beforeunload", beforeUnloadListener, { capture: true }) + } else { + window.removeEventListener("beforeunload", beforeUnloadListener, { capture: true }) + } + }) + + onCleanup(() => { + window.removeEventListener("beforeunload", beforeUnloadListener, { capture: true }) + }) return ( -
+
+ onClick={onContinue} +> cable
-
- +
progress() === -1 ? 0 : 1 }}> + progress() * 100} barColor={() => uiState().bgColor} />
- {title} - {description} - {(title === "Lost connection" || title === "Ready") && isLinux && ( + {title()} + {() => uiState().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. + + 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}
                     
                   ))}
                 
-
+
@@ -289,15 +291,15 @@ export default function Flash() {
)} - {error && ( + {error() && ( - ) || false} - {connected && } + )} + {connected() && }
) -} +} \ No newline at end of file diff --git a/src/app/index.jsx b/src/app/index.jsx index cd4369f..235879c 100644 --- a/src/app/index.jsx +++ b/src/app/index.jsx @@ -1,4 +1,4 @@ -import { Suspense, lazy } from 'react' +import { createResource, lazy, Suspense } from 'solid-js' import comma from '../assets/comma.svg' import fastbootPorts from '../assets/fastboot-ports.svg' @@ -9,12 +9,13 @@ const Flash = lazy(() => import('./Flash')) export default function App() { const version = import.meta.env.VITE_PUBLIC_GIT_SHA || 'dev' - console.info(`flash.comma.ai version: ${version}`); + console.info(`flash.comma.ai version: ${version}`) + return ( -
-
+
+
- comma + comma

flash.comma.ai

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

@@ -140,19 +141,19 @@ export default function App() {

-
+
-
- Loading...

}> +
+ Loading...

}>
-
+
flash.comma.ai version: {version.substring(0, 7)}
diff --git a/src/main.jsx b/src/main.jsx index 40fea3e..07c6732 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,5 +1,4 @@ -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' @@ -7,8 +6,12 @@ import '@fontsource-variable/jetbrains-mono' import './index.css' import App from './app' -ReactDOM.createRoot(document.getElementById('root')).render( - - - , -) +const root = document.getElementById('root') + +if (import.meta.env.DEV && !(root instanceof HTMLElement)) { + throw new Error( + 'Root element not found', + ) +} + +render(() => , root) diff --git a/src/utils/flash.js b/src/utils/flash.js index 0fe2776..2edee36 100644 --- a/src/utils/flash.js +++ b/src/utils/flash.js @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { createSignal, createEffect, onCleanup } from 'solid-js' import { concatUint8Array } from '@/QDL/utils' import { qdlDevice } from '@/QDL/qdl' @@ -10,10 +10,6 @@ import { useImageWorker } from '@/utils/image' import { createManifest } from '@/utils/manifest' import { withProgress } from '@/utils/progress' -/** - * @typedef {import('./manifest.js').Image} Image - */ - export const Step = { INITIALIZING: 0, READY: 1, @@ -39,13 +35,11 @@ export const Error = { } function isRecognizedDevice(slotCount, partitions) { - if (slotCount !== 2) { 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", @@ -61,82 +55,77 @@ function isRecognizedDevice(slotCount, partitions) { 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 [step, _setStep] = createSignal(Step.INITIALIZING) + const [message, _setMessage] = createSignal('') + const [progress, setProgress] = createSignal(0) + const [error, _setError] = createSignal(Error.NONE) - const [connected, setConnected] = useState(false) - const [serial, setSerial] = useState(null) + const [connected, setConnected] = createSignal(false) + const [serial, setSerial] = createSignal(null) - const [onContinue, setOnContinue] = useState(null) - const [onRetry, setOnRetry] = useState(null) + const [onContinue, setOnContinue] = createSignal(null) + const [onRetry, setOnRetry] = createSignal(null) const imageWorker = useImageWorker() - const qdl = useRef(new qdlDevice()) + const [qdl] = createSignal(new qdlDevice()) - /** @type {React.RefObject} */ - const manifest = useRef(null) + const [manifest, setManifest] = createSignal(null) - function setStep(step) { - _setStep(step) + function setStep(newStep) { + _setStep(newStep) } - function setMessage(message = '') { - if (message) console.info('[QDL]', message) - _setMessage(message) + function setMessage(newMessage = '') { + if (newMessage) console.info('[QDL]', newMessage) + _setMessage(newMessage) } - function setError(error) { - _setError(error) + function setError(newError) { + _setError(newError) } - useEffect(() => { + + createEffect(() => { setProgress(-1) setMessage() - if (error) return - if (!imageWorker.current) { + if (error()) return + if (!imageWorker().current) { console.debug('[QDL] Waiting for image worker') return } - switch (step) { + 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 } - // 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() + imageWorker().current?.init() .then(() => download(config.manifests['release'])) .then(blob => blob.text()) .then(text => { - manifest.current = createManifest(text) + setManifest(createManifest(text)) - // sanity check - if (manifest.current.length === 0) { + if (manifest().length === 0) { throw 'Manifest is empty' } - console.debug('[QDL] Loaded manifest', manifest.current) + console.debug('[QDL] Loaded manifest', manifest()) setStep(Step.READY) }) .catch((err) => { @@ -147,7 +136,6 @@ export function useQdl() { } case Step.READY: { - // wait for user interaction (we can't use WebUSB without user event) setOnContinue(() => () => { setOnContinue(null) setStep(Step.CONNECTING) @@ -156,10 +144,10 @@ export function useQdl() { } case Step.CONNECTING: { - qdl.current.waitForConnect() + qdl().waitForConnect() .then(() => { console.info('[QDL] Connected') - return qdl.current.getDevicePartitionsInfo() + return qdl().getDevicePartitionsInfo() .then(([slotCount, partitions]) => { const recognized = isRecognizedDevice(slotCount, partitions) console.debug('[QDL] Device info', { recognized, partitions}) @@ -169,7 +157,7 @@ export function useQdl() { return } - setSerial(qdl.current.sahara.serial || 'unknown') + setSerial(qdl().sahara.serial || 'unknown') setConnected(true) setStep(Step.DOWNLOADING) }) @@ -183,7 +171,7 @@ export function useQdl() { setError(Error.LOST_CONNECTION) setConnected(false) }) - qdl.current.connect() + qdl().connect() .catch((err) => { console.error('[QDL] Connection error', err) setStep(Step.READY) @@ -195,9 +183,9 @@ export function useQdl() { setProgress(0) async function downloadImages() { - for await (const [image, onProgress] of withProgress(manifest.current, setProgress)) { + for await (const [image, onProgress] of withProgress(manifest(), setProgress)) { setMessage(`Downloading ${image.name}`) - await imageWorker.current.downloadImage(image, Comlink.proxy(onProgress)) + await imageWorker().current.downloadImage(image, Comlink.proxy(onProgress)) } } @@ -217,9 +205,9 @@ export function useQdl() { setProgress(0) async function unpackImages() { - for await (const [image, onProgress] of withProgress(manifest.current, setProgress)) { + for await (const [image, onProgress] of withProgress(manifest(), setProgress)) { setMessage(`Unpacking ${image.name}`) - await imageWorker.current.unpackImage(image, Comlink.proxy(onProgress)) + await imageWorker().current.unpackImage(image, Comlink.proxy(onProgress)) } } @@ -243,28 +231,26 @@ export function useQdl() { setProgress(0) async function flashDevice() { - const currentSlot = await qdl.current.getActiveSlot(); + const currentSlot = await qdl().getActiveSlot(); if (!['a', 'b'].includes(currentSlot)) { throw `Unknown current slot ${currentSlot}` } 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}`) + await qdl().erase("xbl"+`_${currentSlot}`) - for await (const [image, onProgress] of withProgress(manifest.current, setProgress)) { - const fileHandle = await imageWorker.current.getImage(image) + for await (const [image, onProgress] of withProgress(manifest(), 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) + await qdl().flashBlob(partitionName, blob, onProgress) } console.debug('[QDL] Flashed all partitions') setMessage(`Changing slot to ${otherSlot}`) - await qdl.current.setActiveSlot(otherSlot) + await qdl().setActiveSlot(otherSlot) } flashDevice() @@ -284,8 +270,8 @@ export function useQdl() { 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) + wData = new Blob([concatUint8Array([wData, new Uint8Array(28 - wData.length).fill(0)])]) + await qdl().flashBlob("userdata", wData) } async function eraseDevice() { @@ -294,7 +280,7 @@ export function useQdl() { setProgress(0.9) setMessage('Rebooting') - await qdl.current.reset() + await qdl().reset() setProgress(1) setConnected(false) } @@ -311,11 +297,11 @@ export function useQdl() { break } } - }, [error, imageWorker, step]) + }) - useEffect(() => { - if (error !== Error.NONE) { - console.debug('[QDL] error', error) + createEffect(() => { + if (error() !== Error.NONE) { + console.debug('[QDL] error', error()) setProgress(-1) setOnContinue(null) @@ -324,19 +310,16 @@ export function useQdl() { window.location.reload() }) } - }, [error]) + }) return { step, message, progress, error, - connected, serial, - onContinue, onRetry, } -} - +} \ No newline at end of file diff --git a/src/utils/image.js b/src/utils/image.js index ed27f09..f781b66 100644 --- a/src/utils/image.js +++ b/src/utils/image.js @@ -1,17 +1,21 @@ -import { useEffect, useRef } from 'react' - +import { createSignal, createEffect, onCleanup } from 'solid-js' import * as Comlink from 'comlink' export function useImageWorker() { - const apiRef = useRef() + const [apiRef, setApiRef] = createSignal({ current: null }) - useEffect(() => { + createEffect(() => { const worker = new Worker(new URL('../workers/image.worker', import.meta.url), { type: 'module', }) - apiRef.current = Comlink.wrap(worker) - return () => worker.terminate() - }, []) + const wrappedWorker = Comlink.wrap(worker) + setApiRef({ current: wrappedWorker }) + + onCleanup(() => { + worker.terminate() + setApiRef({ current: null }) + }) + }) return apiRef } diff --git a/vite.config.js b/vite.config.js index ba64cd9..1542842 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,10 +1,10 @@ import { fileURLToPath, URL } from 'node:url'; import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import solidPlugin from 'vite-plugin-solid' // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [solidPlugin()], resolve: { alias: [ { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },