├── .devcontainer └── devcontainer.json ├── .github ├── PULL_REQUEST_TEMPLATE │ └── bump_qdl.md └── workflows │ ├── deploy.yml │ └── main.yaml ├── .gitignore ├── .npmrc ├── README.md ├── bun.lockb ├── index.html ├── package.json ├── postcss.config.js ├── src ├── QDL │ └── programmer.bin ├── app │ ├── App.test.jsx │ ├── Flash.jsx │ ├── favicon.ico │ ├── icon.png │ ├── icon.svg │ └── index.jsx ├── assets │ ├── bolt.svg │ ├── cable.svg │ ├── comma.svg │ ├── device_exclamation_c3.svg │ ├── device_question_c3.svg │ ├── done.svg │ ├── exclamation.svg │ ├── qdl-ports.svg │ ├── system_update_c3.svg │ ├── zadig_create_new_device.png │ └── zadig_form.png ├── config.js ├── index.css ├── main.jsx ├── test │ └── setup.js └── utils │ ├── image.js │ ├── manager.js │ ├── manifest.js │ ├── manifest.test.js │ ├── platform.js │ ├── progress.js │ ├── stream.js │ └── stream.test.js ├── tailwind.config.js └── vite.config.js /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flash.comma.ai", 3 | "image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye", 4 | "features": { 5 | "ghcr.io/shyim/devcontainers-features/bun:0": {} 6 | }, 7 | "postCreateCommand": "bun install" 8 | } 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/bump_qdl.md: -------------------------------------------------------------------------------- 1 | ## Testing Checklist 2 | Unplug your device for a short while so it's in a normal state 3 | - [ ] Make sure flash can connect to the device 4 | - [ ] Refresh the page and check it can still connect 5 | - [ ] Start a timer, allow the flash to complete, it should take less than 5 minutes 6 | - [ ] Make sure the device boots into the setup 7 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Pages 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | permissions: 8 | contents: read 9 | pages: write 10 | id-token: write 11 | 12 | concurrency: 13 | group: "pages" 14 | cancel-in-progress: false 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: oven-sh/setup-bun@v1 22 | 23 | - id: vars 24 | run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 25 | 26 | - run: bun install 27 | - run: bun run build 28 | env: 29 | VITE_PUBLIC_GIT_SHA: ${{ steps.vars.outputs.sha_short }} 30 | 31 | - uses: actions/upload-pages-artifact@v3 32 | with: 33 | path: ./dist 34 | 35 | deploy: 36 | environment: 37 | name: github-pages 38 | url: ${{ steps.deployment.outputs.page_url }} 39 | runs-on: ubuntu-latest 40 | if: github.ref == 'refs/heads/master' 41 | needs: build 42 | steps: 43 | - name: Deploy to GitHub Pages 44 | id: deployment 45 | uses: actions/deploy-pages@v4 46 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.ref == 'refs/heads/master' && github.run_id || github.head_ref || github.ref }}-${{ github.workflow }}-${{ github.event_name }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 1 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: oven-sh/setup-bun@v1 20 | 21 | - run: bun install 22 | - run: bun run test 23 | 24 | manifest: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 15 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: oven-sh/setup-bun@v1 30 | 31 | - run: bun install 32 | - name: test manifest in master 33 | run: bun run test "manifest" 34 | env: 35 | MANIFEST_BRANCH: master 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | *.swp 3 | 4 | # IDEs 5 | .idea 6 | *.iml 7 | .vscode 8 | 9 | # dependencies 10 | /node_modules 11 | /.pnp 12 | .pnp.js 13 | /.pnpm-store 14 | 15 | # testing 16 | /coverage 17 | 18 | # next.js 19 | /.next/ 20 | /out/ 21 | 22 | # production 23 | /build 24 | /dist 25 | 26 | # misc 27 | .DS_Store 28 | *.pem 29 | 30 | # debug 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | 35 | # local env files 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | next-env.d.ts 44 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @jsr:registry=https://npm.jsr.io 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flash 2 | 3 | ► [flash.comma.ai](https://flash.comma.ai) 4 | 5 | This tool allows you to flash AGNOS onto your comma device. Uses [qdl.js](https://github.com/commaai/qdl.js). 6 | 7 | ## Development 8 | 9 | ```bash 10 | bun install 11 | bun dev 12 | ``` 13 | 14 | Open [http://localhost:5173](http://localhost:5173) with your browser to see the result. 15 | 16 | You can start editing the page by modifying `src/app/index.jsx`. The page auto-updates as you edit the file. 17 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commaai/flash/34c1db0d3ed6563eb54323290b82bddaecc30286/bun.lockb -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | flash.comma.ai 12 | 13 | 14 |
15 | 16 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@commaai/flash", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "start": "vite preview", 10 | "test": "vitest" 11 | }, 12 | "engines": { 13 | "node": ">=20.11.0" 14 | }, 15 | "dependencies": { 16 | "@commaai/qdl": "git+https://github.com/commaai/qdl.js.git#52021f0b1ace58673ebca1fae740f6900ebff707", 17 | "@fontsource-variable/inter": "^5.2.5", 18 | "@fontsource-variable/jetbrains-mono": "^5.2.5", 19 | "react": "^18.3.1", 20 | "react-dom": "^18.3.1", 21 | "xz-decompress": "^0.2.2" 22 | }, 23 | "devDependencies": { 24 | "@tailwindcss/typography": "^0.5.16", 25 | "@testing-library/jest-dom": "^6.6.3", 26 | "@testing-library/react": "^16.3.0", 27 | "@types/react": "^18.3.20", 28 | "@types/react-dom": "^18.3.6", 29 | "@vitejs/plugin-react": "^4.3.4", 30 | "autoprefixer": "10.4.21", 31 | "jsdom": "^26.0.0", 32 | "postcss": "^8.5.3", 33 | "tailwindcss": "^3.4.17", 34 | "vite": "^6.2.6", 35 | "vite-svg-loader": "^5.1.0", 36 | "vitest": "^3.1.1" 37 | }, 38 | "trustedDependencies": [ 39 | "@commaai/qdl", 40 | "esbuild", 41 | "usb" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/QDL/programmer.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commaai/flash/34c1db0d3ed6563eb54323290b82bddaecc30286/src/QDL/programmer.bin -------------------------------------------------------------------------------- /src/app/App.test.jsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react' 2 | import { expect, test } from 'vitest' 3 | import { render, screen } from '@testing-library/react' 4 | 5 | import App from '.' 6 | 7 | test('renders without crashing', () => { 8 | render() 9 | expect(screen.getByText('flash.comma.ai')).toBeInTheDocument() 10 | }) 11 | -------------------------------------------------------------------------------- /src/app/Flash.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | 3 | import { FlashManager, StepCode, ErrorCode } from '../utils/manager' 4 | import { useImageManager } from '../utils/image' 5 | import { isLinux } from '../utils/platform' 6 | import config from '../config' 7 | 8 | import bolt from '../assets/bolt.svg' 9 | import cable from '../assets/cable.svg' 10 | import deviceExclamation from '../assets/device_exclamation_c3.svg' 11 | import deviceQuestion from '../assets/device_question_c3.svg' 12 | import done from '../assets/done.svg' 13 | import exclamation from '../assets/exclamation.svg' 14 | import systemUpdate from '../assets/system_update_c3.svg' 15 | 16 | 17 | const steps = { 18 | [StepCode.INITIALIZING]: { 19 | status: 'Initializing...', 20 | bgColor: 'bg-gray-400 dark:bg-gray-700', 21 | icon: bolt, 22 | }, 23 | [StepCode.READY]: { 24 | status: 'Tap to start', 25 | bgColor: 'bg-[#51ff00]', 26 | icon: bolt, 27 | iconStyle: '', 28 | }, 29 | [StepCode.CONNECTING]: { 30 | status: 'Waiting for connection', 31 | description: 'Follow the instructions to connect your device to your computer', 32 | bgColor: 'bg-yellow-500', 33 | icon: cable, 34 | }, 35 | [StepCode.REPAIR_PARTITION_TABLES]: { 36 | status: 'Repairing partition tables...', 37 | description: 'Do not unplug your device until the process is complete', 38 | bgColor: 'bg-lime-400', 39 | icon: systemUpdate, 40 | }, 41 | [StepCode.ERASE_DEVICE]: { 42 | status: 'Erasing device...', 43 | description: 'Do not unplug your device until the process is complete', 44 | bgColor: 'bg-lime-400', 45 | icon: systemUpdate, 46 | }, 47 | [StepCode.FLASH_SYSTEM]: { 48 | status: 'Flashing device...', 49 | description: 'Do not unplug your device until the process is complete', 50 | bgColor: 'bg-lime-400', 51 | icon: systemUpdate, 52 | }, 53 | [StepCode.FINALIZING]: { 54 | status: 'Finalizing...', 55 | description: 'Do not unplug your device until the process is complete', 56 | bgColor: 'bg-lime-400', 57 | icon: systemUpdate, 58 | }, 59 | [StepCode.DONE]: { 60 | status: 'Done', 61 | description: 'Your device was flashed successfully. It should now boot into the openpilot setup.', 62 | bgColor: 'bg-green-500', 63 | icon: done, 64 | }, 65 | } 66 | 67 | const errors = { 68 | [ErrorCode.UNKNOWN]: { 69 | status: 'Unknown error', 70 | description: 'An unknown error has occurred. Unplug your device, restart your browser and try again.', 71 | bgColor: 'bg-red-500', 72 | icon: exclamation, 73 | }, 74 | [ErrorCode.REQUIREMENTS_NOT_MET]: { 75 | status: 'Requirements not met', 76 | description: 'Your system does not meet the requirements to flash your device. Make sure to use a browser which ' + 77 | 'supports WebUSB and is up to date.', 78 | }, 79 | [ErrorCode.STORAGE_SPACE]: { 80 | description: 'Your system does not have enough space available to download AGNOS. Your browser may be restricting' + 81 | ' the available space if you are in a private, incognito or guest session.', 82 | }, 83 | [ErrorCode.UNRECOGNIZED_DEVICE]: { 84 | status: 'Unrecognized device', 85 | description: 'The device connected to your computer is not supported. Try using a different cable, USB port, or ' + 86 | 'computer. If the problem persists, join the #hw-three-3x channel on Discord for help.', 87 | bgColor: 'bg-yellow-500', 88 | icon: deviceQuestion, 89 | }, 90 | [ErrorCode.LOST_CONNECTION]: { 91 | status: 'Lost connection', 92 | description: 'The connection to your device was lost. Unplug your device and try again.', 93 | icon: cable, 94 | }, 95 | [ErrorCode.REPAIR_PARTITION_TABLES_FAILED]: { 96 | status: 'Repairing partition tables failed', 97 | description: 'Your device\'s partition tables could not be repaired. Try using a different cable, USB port, or ' + 98 | 'computer. If the problem persists, join the #hw-three-3x channel on Discord for help.', 99 | icon: deviceExclamation, 100 | }, 101 | [ErrorCode.ERASE_FAILED]: { 102 | status: 'Erase failed', 103 | description: 'The device could not be erased. Try using a different cable, USB port, or computer. If the problem ' + 104 | 'persists, join the #hw-three-3x channel on Discord for help.', 105 | icon: deviceExclamation, 106 | }, 107 | [ErrorCode.FLASH_SYSTEM_FAILED]: { 108 | status: 'Flash failed', 109 | description: 'AGNOS could not be flashed to your device. Try using a different cable, USB port, or computer. If ' + 110 | 'the problem persists, join the #hw-three-3x channel on Discord for help.', 111 | icon: deviceExclamation, 112 | }, 113 | } 114 | 115 | if (isLinux) { 116 | // this is likely in StepCode.CONNECTING 117 | errors[ErrorCode.LOST_CONNECTION].description += ' Did you forget to unbind the device from qcserial?' 118 | } 119 | 120 | 121 | function LinearProgress({ value, barColor }) { 122 | if (value === -1 || value > 100) value = 100 123 | return ( 124 |
125 |
129 |
130 | ) 131 | } 132 | 133 | 134 | function DeviceState({ serial }) { 135 | return ( 136 |
140 |
141 | 148 | 152 | 153 | Device connected 154 |
155 | | 156 |
157 | 158 | Serial: 159 | {serial || 'unknown'} 160 | 161 |
162 |
163 | ) 164 | } 165 | 166 | 167 | function beforeUnloadListener(event) { 168 | // NOTE: not all browsers will show this message 169 | event.preventDefault() 170 | return (event.returnValue = "Flash in progress. Are you sure you want to leave?") 171 | } 172 | 173 | 174 | export default function Flash() { 175 | const [step, setStep] = useState(StepCode.INITIALIZING) 176 | const [message, setMessage] = useState('') 177 | const [progress, setProgress] = useState(-1) 178 | const [error, setError] = useState(ErrorCode.NONE) 179 | const [connected, setConnected] = useState(false) 180 | const [serial, setSerial] = useState(null) 181 | 182 | const qdlManager = useRef(null) 183 | const imageManager = useImageManager() 184 | 185 | useEffect(() => { 186 | if (!imageManager.current) return 187 | 188 | fetch(config.loader.url) 189 | .then((res) => res.arrayBuffer()) 190 | .then((programmer) => { 191 | // Create QDL manager with callbacks that update React state 192 | qdlManager.current = new FlashManager(config.manifests.release, programmer, { 193 | onStepChange: setStep, 194 | onMessageChange: setMessage, 195 | onProgressChange: setProgress, 196 | onErrorChange: setError, 197 | onConnectionChange: setConnected, 198 | onSerialChange: setSerial 199 | }) 200 | 201 | // Initialize the manager 202 | return qdlManager.current.initialize(imageManager.current) 203 | }) 204 | .catch((err) => { 205 | console.error('Error initializing Flash manager:', err) 206 | setError(ErrorCode.UNKNOWN) 207 | }) 208 | }, [config, imageManager.current]) 209 | 210 | // Handle user clicking the start button 211 | const handleStart = () => qdlManager.current?.start() 212 | const canStart = step === StepCode.READY && !error 213 | 214 | // Handle retry on error 215 | const handleRetry = () => window.location.reload() 216 | 217 | const uiState = steps[step] 218 | if (error) { 219 | Object.assign(uiState, errors[ErrorCode.UNKNOWN], errors[error]) 220 | } 221 | const { status, description, bgColor, icon, iconStyle = 'invert' } = uiState 222 | 223 | let title 224 | if (message && !error) { 225 | title = message + '...' 226 | if (progress >= 0) { 227 | title += ` (${(progress * 100).toFixed(0)}%)` 228 | } 229 | } else if (error === ErrorCode.STORAGE_SPACE) { 230 | title = message 231 | } else { 232 | title = status 233 | } 234 | 235 | // warn the user if they try to leave the page while flashing 236 | if (step >= StepCode.REPAIR_PARTITION_TABLES && step <= StepCode.FINALIZING) { 237 | window.addEventListener("beforeunload", beforeUnloadListener, { capture: true }) 238 | } else { 239 | window.removeEventListener("beforeunload", beforeUnloadListener, { capture: true }) 240 | } 241 | 242 | return ( 243 |
244 |
249 | cable 256 |
257 |
258 | 259 |
260 | {title} 261 | {description} 262 | {error && ( 263 | 269 | ) || false} 270 | {connected && } 271 |
272 | ) 273 | } 274 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commaai/flash/34c1db0d3ed6563eb54323290b82bddaecc30286/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commaai/flash/34c1db0d3ed6563eb54323290b82bddaecc30286/src/app/icon.png -------------------------------------------------------------------------------- /src/app/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/index.jsx: -------------------------------------------------------------------------------- 1 | import { Suspense, lazy } from 'react' 2 | 3 | import comma from '../assets/comma.svg' 4 | import qdlPorts from '../assets/qdl-ports.svg' 5 | import zadigCreateNewDevice from '../assets/zadig_create_new_device.png' 6 | import zadigForm from '../assets/zadig_form.png' 7 | 8 | import { isLinux, isWindows } from '../utils/platform' 9 | 10 | const Flash = lazy(() => import('./Flash')) 11 | 12 | const VENDOR_ID = '05C6' 13 | const PRODUCT_ID = '9008' 14 | 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'; 15 | 16 | function CopyText({ children: text }) { 17 | return
18 |
{text}
19 | 25 |
; 26 | } 27 | 28 | export default function App() { 29 | const version = import.meta.env.VITE_PUBLIC_GIT_SHA || 'dev' 30 | console.info(`flash.comma.ai version: ${version}`) 31 | return ( 32 |
33 |
34 |
35 | comma 36 |

flash.comma.ai

37 |

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

41 |
42 |
43 | 44 |
45 |

Requirements

46 |
    47 |
  • 48 | A web browser which supports WebUSB 49 | {" "}(such as Google Chrome, Microsoft Edge, Opera), running on Windows, macOS, Linux, or Android. 50 |
  • 51 |
  • 52 | A good quality USB-C cable to connect the device to your computer. USB 3 53 | {" "}is recommended for faster flashing speed. 54 |
  • 55 |
  • 56 | Another USB-C cable and a charger, to power the device outside your car. 57 |
  • 58 |
59 | {isWindows && (<> 60 |

USB Driver

61 |

You need additional driver software for Windows before you connect your device.

62 |
    63 |
  1. 64 | Download and run Zadig. 65 |
  2. 66 |
  3. 67 | Under Device in the menu bar, select Create New Device. 68 | Zadig Create New Device 74 |
  4. 75 |
  5. 76 | Fill in three fields. The first field is just a description and you can fill in anything. The next two 77 | fields are very important. Fill them in with {VENDOR_ID} and {PRODUCT_ID} 78 | respectively. Press "Install Driver" and give it a few minutes to install. 79 | Zadig Form 85 |
  6. 86 |
87 |

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

88 | )} 89 |
90 |
91 | 92 |
93 |

Flashing

94 |

Follow these steps to put your device into QDL mode:

95 |
    96 |
  1. Unplug the device and wait for the LED to switch off.
  2. 97 |
  3. First, connect the device to your computer using the lower USB-C port (port 1).
  4. 98 |
  5. Second, connect power to the upper OBD-C port (port 2).
  6. 99 |
100 | image showing comma three and two ports. the lower port is labeled 1. the upper port is labeled 2. 106 |

Your device's screen will remain blank for the entire flashing process. This is normal.

107 | {isLinux && (<> 108 | Note for Linux users 109 |

110 | On Linux systems, devices in QDL mode are automatically bound to the kernel's qcserial driver, and 111 | need to be unbound before we can access the device. Copy the script below into your terminal and run it 112 | after plugging in your device. 113 |

114 | {DETACH_SCRIPT} 115 | )} 116 |

117 | Next, click the button to start flashing. From the prompt select the device which starts with 118 | “QUSB_BULK”. 119 |

120 |

121 | The process can take 30+ minutes depending on your internet connection and system performance. Do not 122 | unplug the device until all steps are complete. 123 |

124 |
125 |
126 | 127 |
128 |

Troubleshooting

129 |

Lost connection

130 |

131 | Try using high quality USB 3 cables. You should also try different USB ports on the front or back of your 132 | computer. If you're using a USB hub, try connecting directly to your computer instead. 133 |

134 |

My device's screen is blank

135 |

136 | This is normal in QDL mode. You can verify that the “QUSB_BULK” device shows up when you press 137 | the Flash button to know that it is working correctly. 138 |

139 |

My device says “fastboot mode”

140 |

141 | You may have followed outdated instructions for flashing. Please read the instructions above for putting 142 | your device into QDL mode. 143 |

144 |

General Tips

145 |
    146 |
  • Try another computer or OS
  • 147 |
  • Try different USB ports on your computer
  • 148 |
  • Try different USB-C cables; low quality cables are often the source of problems. Note that the included OBD-C cable will not work.
  • 149 |
150 |

Other questions

151 |

152 | If you need help, join our Discord server and go to 153 | the #hw-three-3x channel. 154 |

155 |
156 | 157 |
158 |
159 | flash.comma.ai version: {version} 160 |
161 |
162 | 163 |
164 | Loading...

}> 165 | 166 |
167 |
168 | 169 |
170 | flash.comma.ai version: {version} 171 |
172 |
173 | ) 174 | } 175 | -------------------------------------------------------------------------------- /src/assets/bolt.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/cable.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/comma.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/device_exclamation_c3.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 35 | 38 | 42 | 46 | 47 | -------------------------------------------------------------------------------- /src/assets/device_question_c3.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 35 | 38 | 42 | 43 | -------------------------------------------------------------------------------- /src/assets/done.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/exclamation.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/qdl-ports.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/assets/system_update_c3.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/zadig_create_new_device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commaai/flash/34c1db0d3ed6563eb54323290b82bddaecc30286/src/assets/zadig_create_new_device.png -------------------------------------------------------------------------------- /src/assets/zadig_form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commaai/flash/34c1db0d3ed6563eb54323290b82bddaecc30286/src/assets/zadig_form.png -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | manifests: { 3 | release: 'https://raw.githubusercontent.com/commaai/openpilot/release3/system/hardware/tici/all-partitions.json', 4 | master: 'https://raw.githubusercontent.com/commaai/openpilot/master/system/hardware/tici/all-partitions.json', 5 | }, 6 | loader: { 7 | url: 'https://raw.githubusercontent.com/commaai/flash/master/src/QDL/programmer.bin', 8 | }, 9 | } 10 | 11 | export default config 12 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | 4 | import '@fontsource-variable/inter' 5 | import '@fontsource-variable/jetbrains-mono' 6 | 7 | import './index.css' 8 | import App from './app' 9 | 10 | ReactDOM.createRoot(document.getElementById('root')).render( 11 | 12 | 13 | , 14 | ) 15 | -------------------------------------------------------------------------------- /src/test/setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | -------------------------------------------------------------------------------- /src/utils/image.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import { XzReadableStream } from 'xz-decompress' 3 | 4 | import { fetchStream } from './stream' 5 | 6 | /** 7 | * Progress callback 8 | * 9 | * @callback progressCallback 10 | * @param {number} progress 11 | * @returns {void} 12 | */ 13 | 14 | const MIN_QUOTA_MB = 5250 15 | 16 | export class ImageManager { 17 | /** @type {FileSystemDirectoryHandle} */ 18 | root 19 | 20 | async init() { 21 | if (!this.root) { 22 | this.root = await navigator.storage.getDirectory() 23 | await this.root.remove({ recursive: true }) 24 | console.info('[ImageManager] Initialized') 25 | } 26 | 27 | const estimate = await navigator.storage.estimate() 28 | const quotaMB = (estimate.quota || 0) / (1024 ** 2) 29 | if (quotaMB < MIN_QUOTA_MB) { 30 | throw new Error(`Not enough storage: ${quotaMB.toFixed(0)}MB free, need ${MIN_QUOTA_MB.toFixed(0)}MB`) 31 | } 32 | } 33 | 34 | /** 35 | * Download and unpack an image, saving it to persistent storage. 36 | * 37 | * @param {ManifestImage} image 38 | * @param {progressCallback} [onProgress] 39 | * @returns {Promise} 40 | */ 41 | async downloadImage(image, onProgress = undefined) { 42 | const { archiveUrl, fileName } = image 43 | 44 | /** @type {FileSystemWritableFileStream} */ 45 | let writable 46 | try { 47 | const fileHandle = await this.root.getFileHandle(fileName, { create: true }) 48 | writable = await fileHandle.createWritable() 49 | } catch (e) { 50 | throw new Error(`Error opening file handle: ${e}`, { cause: e }) 51 | } 52 | 53 | console.debug(`[ImageManager] Downloading ${image.name} from ${archiveUrl}`) 54 | let stream = await fetchStream(archiveUrl, { mode: 'cors' }, { onProgress }) 55 | try { 56 | if (image.compressed) { 57 | stream = new XzReadableStream(stream) 58 | } 59 | await stream.pipeTo(writable) 60 | onProgress?.(1) 61 | } catch (e) { 62 | throw new Error(`Error unpacking archive: ${e}`, { cause: e }) 63 | } 64 | } 65 | 66 | /** 67 | * Get a blob for an image. 68 | * 69 | * @param {ManifestImage} image 70 | * @returns {Promise} 71 | */ 72 | async getImage(image) { 73 | const { fileName } = image 74 | 75 | let fileHandle 76 | try { 77 | fileHandle = await this.root.getFileHandle(fileName, { create: false }) 78 | } catch (e) { 79 | throw new Error(`Error getting file handle: ${e}`, { cause: e }) 80 | } 81 | 82 | return fileHandle.getFile() 83 | } 84 | } 85 | 86 | /** @returns {React.MutableRefObject} */ 87 | export function useImageManager() { 88 | const apiRef = useRef() 89 | 90 | useEffect(() => { 91 | const worker = new ImageManager() 92 | apiRef.current = worker 93 | }, []) 94 | 95 | return apiRef 96 | } 97 | -------------------------------------------------------------------------------- /src/utils/manager.js: -------------------------------------------------------------------------------- 1 | import { qdlDevice } from '@commaai/qdl' 2 | import { usbClass } from '@commaai/qdl/usblib' 3 | 4 | import { getManifest } from './manifest' 5 | import { createSteps, withProgress } from './progress' 6 | 7 | export const StepCode = { 8 | INITIALIZING: 0, 9 | READY: 1, 10 | CONNECTING: 2, 11 | REPAIR_PARTITION_TABLES: 3, 12 | ERASE_DEVICE: 4, 13 | FLASH_SYSTEM: 5, 14 | FINALIZING: 6, 15 | DONE: 7, 16 | } 17 | 18 | export const ErrorCode = { 19 | UNKNOWN: -1, 20 | NONE: 0, 21 | REQUIREMENTS_NOT_MET: 1, 22 | STORAGE_SPACE: 2, 23 | UNRECOGNIZED_DEVICE: 3, 24 | LOST_CONNECTION: 4, 25 | REPAIR_PARTITION_TABLES_FAILED: 5, 26 | ERASE_FAILED: 6, 27 | FLASH_SYSTEM_FAILED: 7, 28 | FINALIZING_FAILED: 8, 29 | } 30 | 31 | /** 32 | * @param {any} storageInfo 33 | * @returns {string|null} 34 | */ 35 | export function checkCompatibleDevice(storageInfo) { 36 | // Should be the same for all comma 3/3X 37 | if (storageInfo.block_size !== 4096 || storageInfo.page_size !== 4096 || 38 | storageInfo.num_physical !== 6 || storageInfo.mem_type !== 'UFS') { 39 | throw new Error('UFS chip parameters mismatch') 40 | } 41 | 42 | // comma three 43 | // userdata start 6159400 size 7986131 44 | if (storageInfo.prod_name === 'H28S7Q302BMR' && storageInfo.manufacturer_id === 429 && 45 | storageInfo.total_blocks === 14145536) { 46 | return 'userdata_30' 47 | } 48 | if (storageInfo.prod_name === 'H28U74301AMR' && storageInfo.manufacturer_id === 429 && 49 | storageInfo.total_blocks === 14145536) { 50 | return 'userdata_30' 51 | } 52 | if (/64GB-UFS-MT( +)8QSP/.test(storageInfo.prod_name) && storageInfo.manufacturer_id === 300 && 53 | storageInfo.total_blocks === 14143488) { 54 | return 'userdata_30' 55 | } 56 | 57 | // comma 3X 58 | // userdata start 6159400 size 23446483 59 | if (storageInfo.prod_name === 'SDINDDH4-128G 1308' && storageInfo.manufacturer_id === 325 && 60 | storageInfo.total_blocks === 29605888) { 61 | return 'userdata_89' 62 | } 63 | // unknown userdata sectors 64 | if (storageInfo.prod_name === 'SDINDDH4-128G 1272' && storageInfo.manufacturer_id === 325 && 65 | storageInfo.total_blocks === 29775872) { 66 | return 'userdata_90' 67 | } 68 | 69 | throw new Error('Could not identify UFS chip') 70 | } 71 | 72 | /** 73 | * @template T 74 | * @callback ChangeCallback 75 | * @param {T} value 76 | * @returns {void} 77 | */ 78 | 79 | /** 80 | * @typedef {object} FlashManagerCallbacks 81 | * @property {ChangeCallback} [onStepChange] 82 | * @property {ChangeCallback} [onMessageChange] 83 | * @property {ChangeCallback} [onProgressChange] 84 | * @property {ChangeCallback} [onErrorChange] 85 | * @property {ChangeCallback} [onConnectionChange] 86 | * @property {ChangeCallback} [onSerialChange] 87 | */ 88 | 89 | export class FlashManager { 90 | /** @type {string} */ 91 | #userdataImage 92 | 93 | /** 94 | * @param {string} manifestUrl 95 | * @param {ArrayBuffer} programmer 96 | * @param {FlashManagerCallbacks} callbacks 97 | */ 98 | constructor(manifestUrl, programmer, callbacks = {}) { 99 | this.manifestUrl = manifestUrl 100 | this.callbacks = callbacks 101 | this.device = new qdlDevice(programmer) 102 | /** @type {import('./image').ImageManager|null} */ 103 | this.imageManager = null 104 | /** @type {ManifestImage[]|null} */ 105 | this.manifest = null 106 | this.step = StepCode.INITIALIZING 107 | this.error = ErrorCode.NONE 108 | } 109 | 110 | /** @param {number} step */ 111 | #setStep(step) { 112 | this.step = step 113 | this.callbacks.onStepChange?.(step) 114 | } 115 | 116 | /** @param {string} message */ 117 | #setMessage(message) { 118 | if (message) console.info('[Flash]', message) 119 | this.callbacks.onMessageChange?.(message) 120 | } 121 | 122 | /** @param {number} progress */ 123 | #setProgress(progress) { 124 | this.callbacks.onProgressChange?.(progress) 125 | } 126 | 127 | /** @param {number} error */ 128 | #setError(error) { 129 | this.error = error 130 | this.callbacks.onErrorChange?.(error) 131 | this.#setProgress(-1) 132 | 133 | if (error !== ErrorCode.NONE) { 134 | console.debug('[Flash] error', error) 135 | } 136 | } 137 | 138 | /** @param {boolean} connected */ 139 | #setConnected(connected) { 140 | this.callbacks.onConnectionChange?.(connected) 141 | } 142 | 143 | /** @param {string} serial */ 144 | #setSerial(serial) { 145 | this.callbacks.onSerialChange?.(serial) 146 | } 147 | 148 | /** @returns {boolean} */ 149 | #checkRequirements() { 150 | if (typeof navigator.usb === 'undefined') { 151 | console.error('[Flash] WebUSB not supported') 152 | this.#setError(ErrorCode.REQUIREMENTS_NOT_MET) 153 | return false 154 | } 155 | if (typeof Worker === 'undefined') { 156 | console.error('[Flash] Web Workers not supported') 157 | this.#setError(ErrorCode.REQUIREMENTS_NOT_MET) 158 | return false 159 | } 160 | if (typeof Storage === 'undefined') { 161 | console.error('[Flash] Storage API not supported') 162 | this.#setError(ErrorCode.REQUIREMENTS_NOT_MET) 163 | return false 164 | } 165 | return true 166 | } 167 | 168 | /** @param {import('./image').ImageManager} imageManager */ 169 | async initialize(imageManager) { 170 | this.imageManager = imageManager 171 | this.#setProgress(-1) 172 | this.#setMessage('') 173 | 174 | if (!this.#checkRequirements()) { 175 | return 176 | } 177 | 178 | try { 179 | await this.imageManager.init() 180 | } catch (err) { 181 | console.error('[Flash] Failed to initialize image worker') 182 | console.error(err) 183 | if (err instanceof String && err.startsWith('Not enough storage')) { 184 | this.#setError(ErrorCode.STORAGE_SPACE) 185 | this.#setMessage(err) 186 | } else { 187 | this.#setError(ErrorCode.UNKNOWN) 188 | } 189 | return 190 | } 191 | 192 | if (!this.manifest?.length) { 193 | try { 194 | this.manifest = await getManifest(this.manifestUrl) 195 | if (this.manifest.length === 0) { 196 | throw new Error('Manifest is empty') 197 | } 198 | } catch (err) { 199 | console.error('[Flash] Failed to fetch manifest') 200 | console.error(err) 201 | this.#setError(ErrorCode.UNKNOWN) 202 | return 203 | } 204 | console.info('[Flash] Loaded manifest', this.manifest) 205 | } 206 | 207 | this.#setStep(StepCode.READY) 208 | } 209 | 210 | async #connect() { 211 | this.#setStep(StepCode.CONNECTING) 212 | this.#setProgress(-1) 213 | 214 | let usb 215 | try { 216 | usb = new usbClass() 217 | } catch (err) { 218 | console.error('[Flash] Connection lost', err) 219 | this.#setStep(StepCode.READY) 220 | this.#setConnected(false) 221 | return 222 | } 223 | 224 | try { 225 | await this.device.connect(usb) 226 | } catch (err) { 227 | console.error('[Flash] Connection error', err) 228 | this.#setError(ErrorCode.LOST_CONNECTION) 229 | this.#setConnected(false) 230 | return 231 | } 232 | 233 | console.info('[Flash] Connected') 234 | this.#setConnected(true) 235 | 236 | let storageInfo 237 | try { 238 | storageInfo = await this.device.getStorageInfo() 239 | } catch (err) { 240 | console.error('[Flash] Connection lost', err) 241 | this.#setError(ErrorCode.LOST_CONNECTION) 242 | this.#setConnected(false) 243 | return 244 | } 245 | 246 | try { 247 | this.#userdataImage = checkCompatibleDevice(storageInfo) 248 | } catch (e) { 249 | console.error('[Flash] Could not identify device:', e) 250 | console.error(storageInfo) 251 | this.#setError(ErrorCode.UNRECOGNIZED_DEVICE) 252 | return 253 | } 254 | 255 | const serialNum = Number(storageInfo.serial_num).toString(16).padStart(8, '0') 256 | console.info('[Flash] Device info', { serialNum, storageInfo, userdataImage: this.#userdataImage }) 257 | this.#setSerial(serialNum) 258 | } 259 | 260 | async #repairPartitionTables() { 261 | this.#setStep(StepCode.REPAIR_PARTITION_TABLES) 262 | this.#setProgress(0) 263 | 264 | // TODO: check that we have an image for each LUN (storageInfo.num_physical) 265 | const gptImages = this.manifest.filter((image) => !!image.gpt) 266 | if (gptImages.length === 0) { 267 | console.error('[Flash] No GPT images found') 268 | this.#setError(ErrorCode.REPAIR_PARTITION_TABLES_FAILED) 269 | return 270 | } 271 | 272 | try { 273 | for await (const [image, onProgress] of withProgress(gptImages, this.#setProgress.bind(this))) { 274 | // TODO: track repair progress 275 | const [onDownload, onRepair] = createSteps([2, 1], onProgress) 276 | 277 | // Download GPT image 278 | await this.imageManager.downloadImage(image, onDownload) 279 | const blob = await this.imageManager.getImage(image); 280 | 281 | // Recreate main and backup GPT for this LUN 282 | if (!await this.device.repairGpt(image.gpt.lun, blob)) { 283 | throw new Error(`Repairing LUN ${image.gpt.lun} failed`) 284 | } 285 | onRepair(1.0) 286 | } 287 | } catch (err) { 288 | console.error('[Flash] An error occurred while repairing partition tables') 289 | console.error(err) 290 | this.#setError(ErrorCode.REPAIR_PARTITION_TABLES_FAILED) 291 | } 292 | } 293 | 294 | async #eraseDevice() { 295 | this.#setStep(StepCode.ERASE_DEVICE) 296 | this.#setProgress(-1) 297 | 298 | // TODO: use storageInfo.num_physical 299 | const luns = Array.from({ length: 6 }).map((_, i) => i) 300 | 301 | const [found, persistLun, partition] = await this.device.detectPartition('persist') 302 | if (!found || luns.indexOf(persistLun) < 0) { 303 | console.error('[Flash] Could not find "persist" partition', { found, persistLun, partition }) 304 | this.#setError(ErrorCode.ERASE_FAILED) 305 | return 306 | } 307 | if (persistLun !== 0 || partition.start !== 8n || partition.sectors !== 8192n) { 308 | console.error('[Flash] Partition "persist" does not have expected properties', { found, persistLun, partition }) 309 | this.#setError(ErrorCode.ERASE_FAILED) 310 | return 311 | } 312 | console.info(`[Flash] "persist" partition located in LUN ${persistLun}`) 313 | 314 | try { 315 | // Erase each LUN, avoid erasing critical partitions and persist 316 | const critical = ['mbr', 'gpt'] 317 | for (const lun of luns) { 318 | const preserve = [...critical] 319 | if (lun === persistLun) preserve.push('persist') 320 | console.info(`[Flash] Erasing LUN ${lun} while preserving ${preserve.map((part) => `"${part}"`).join(', ')} partitions`) 321 | if (!await this.device.eraseLun(lun, preserve)) { 322 | throw new Error(`Erasing LUN ${lun} failed`) 323 | } 324 | } 325 | } catch (err) { 326 | console.error('[Flash] An error occurred while erasing device') 327 | console.error(err) 328 | this.#setError(ErrorCode.ERASE_FAILED) 329 | } 330 | } 331 | 332 | async #flashSystem() { 333 | this.#setStep(StepCode.FLASH_SYSTEM) 334 | this.#setProgress(0) 335 | 336 | // Exclude GPT images and persist image, and pick correct userdata image to flash 337 | const systemImages = this.manifest 338 | .filter((image) => !image.gpt && image.name !== 'persist') 339 | .filter((image) => !image.name.startsWith('userdata_') || image.name === this.#userdataImage) 340 | 341 | if (!systemImages.find((image) => image.name === this.#userdataImage)) { 342 | console.error(`[Flash] Did not find userdata image "${this.#userdataImage}"`) 343 | this.#setError(ErrorCode.UNKNOWN) 344 | return 345 | } 346 | 347 | try { 348 | for await (const image of systemImages) { 349 | const [onDownload, onFlash] = createSteps([1, image.hasAB ? 2 : 1], this.#setProgress.bind(this)) 350 | 351 | this.#setMessage(`Downloading ${image.name}`) 352 | await this.imageManager.downloadImage(image, onDownload) 353 | const blob = await this.imageManager.getImage(image) 354 | onDownload(1.0) 355 | 356 | // Flash image to each slot 357 | const slots = image.hasAB ? ['_a', '_b'] : [''] 358 | for (const [slot, onSlotProgress] of withProgress(slots, onFlash)) { 359 | // NOTE: userdata image name does not match partition name 360 | const partitionName = `${image.name.startsWith('userdata_') ? 'userdata' : image.name}${slot}` 361 | 362 | this.#setMessage(`Flashing ${partitionName}`) 363 | if (!await this.device.flashBlob(partitionName, blob, (progress) => onSlotProgress(progress / image.size), false)) { 364 | throw new Error(`Flashing partition "${partitionName}" failed`) 365 | } 366 | onSlotProgress(1.0) 367 | } 368 | } 369 | } catch (err) { 370 | console.error('[Flash] An error occurred while flashing system') 371 | console.error(err) 372 | this.#setError(ErrorCode.FLASH_SYSTEM_FAILED) 373 | } 374 | } 375 | 376 | async #finalize() { 377 | this.#setStep(StepCode.FINALIZING) 378 | this.#setProgress(-1) 379 | this.#setMessage('Finalizing...') 380 | 381 | // Set bootable LUN and update active partitions 382 | if (!await this.device.setActiveSlot('a')) { 383 | console.error('[Flash] Failed to update slot') 384 | this.#setError(ErrorCode.FINALIZING_FAILED) 385 | } 386 | 387 | // Reboot the device 388 | this.#setMessage('Rebooting') 389 | await this.device.reset() 390 | this.#setConnected(false) 391 | 392 | this.#setStep(StepCode.DONE) 393 | } 394 | 395 | async start() { 396 | if (this.step !== StepCode.READY) return 397 | await this.#connect() 398 | if (this.error !== ErrorCode.NONE) return 399 | let start = performance.now() 400 | await this.#repairPartitionTables() 401 | console.info(`Repaired partition tables in ${((performance.now() - start) / 1000).toFixed(2)}s`) 402 | if (this.error !== ErrorCode.NONE) return 403 | start = performance.now() 404 | await this.#eraseDevice() 405 | console.info(`Erased device in ${((performance.now() - start) / 1000).toFixed(2)}s`) 406 | if (this.error !== ErrorCode.NONE) return 407 | start = performance.now() 408 | await this.#flashSystem() 409 | console.info(`Flashed system in ${((performance.now() - start) / 1000).toFixed(2)}s`) 410 | if (this.error !== ErrorCode.NONE) return 411 | start = performance.now() 412 | await this.#finalize() 413 | console.info(`Finalized in ${((performance.now() - start) / 1000).toFixed(2)}s`) 414 | } 415 | } 416 | -------------------------------------------------------------------------------- /src/utils/manifest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a partition image defined in the AGNOS manifest. 3 | * 4 | * Image archives can be retrieved from {@link archiveUrl}. 5 | */ 6 | export class ManifestImage { 7 | /** 8 | * Image name 9 | * @type {string} 10 | */ 11 | name 12 | /** 13 | * Partition name 14 | * @type {string} 15 | */ 16 | partitionName 17 | 18 | /** 19 | * Size of the unpacked and unsparsified image in bytes 20 | * @type {number} 21 | */ 22 | size 23 | /** 24 | * Whether the image is sparse 25 | * @type {boolean} 26 | */ 27 | sparse 28 | /** 29 | * Whether there are multiple slots for this partition 30 | * @type {boolean} 31 | */ 32 | hasAB 33 | /** 34 | * LUN and sector information for flashing this image 35 | * @type {{ lun: number; start_sector: number; num_sectors: number }|null} 36 | */ 37 | gpt 38 | 39 | /** 40 | * Name of the image file 41 | * @type {string} 42 | */ 43 | fileName 44 | /** 45 | * Name of the image archive file 46 | * @type {string} 47 | */ 48 | archiveFileName 49 | /** 50 | * URL of the image archive 51 | * @type {string} 52 | */ 53 | archiveUrl 54 | 55 | /** 56 | * Whether the image is compressed and should be unpacked 57 | * @type {boolean} 58 | */ 59 | compressed 60 | 61 | constructor(json) { 62 | this.name = json.name 63 | this.partitionName = json.name.startsWith('userdata_') ? 'userdata' : json.name 64 | 65 | this.size = json.size 66 | this.sparse = json.sparse 67 | this.hasAB = json.has_ab 68 | this.gpt = 'gpt' in json ? json.gpt : null 69 | 70 | this.fileName = `${this.name}-${json.hash_raw}.img` 71 | this.archiveUrl = json.url 72 | this.archiveFileName = this.archiveUrl.split('/').pop() 73 | 74 | this.compressed = this.archiveFileName.endsWith('.xz') 75 | } 76 | } 77 | 78 | /** 79 | * @param {string} url 80 | * @returns {Promise} 81 | */ 82 | export function getManifest(url) { 83 | return fetch(url) 84 | .then((response) => response.text()) 85 | .then((text) => JSON.parse(text).map((image) => new ManifestImage(image))) 86 | } 87 | -------------------------------------------------------------------------------- /src/utils/manifest.test.js: -------------------------------------------------------------------------------- 1 | import { beforeAll, describe, expect, test, vi } from 'vitest' 2 | 3 | import config from '../config' 4 | import { ImageManager } from './image' 5 | import { getManifest } from './manifest' 6 | 7 | const CI = import.meta.env.CI 8 | const MANIFEST_BRANCH = import.meta.env.MANIFEST_BRANCH 9 | 10 | const imageManager = new ImageManager() 11 | 12 | beforeAll(async () => { 13 | globalThis.navigator = { 14 | storage: { 15 | estimate: vi.fn().mockImplementation(() => ({ quota: 10 * (1024 ** 3) })), 16 | getDirectory: () => ({ 17 | getFileHandle: () => ({ 18 | createWritable: vi.fn().mockImplementation(() => new WritableStream({ 19 | write(_) { 20 | // Discard the chunk (do nothing with it) 21 | }, 22 | close() { }, 23 | abort(err) { 24 | console.error('Mock writable stream aborted:', err) 25 | }, 26 | })), 27 | }), 28 | remove: vi.fn(), 29 | }), 30 | }, 31 | } 32 | 33 | await imageManager.init() 34 | }) 35 | 36 | for (const [branch, manifestUrl] of Object.entries(config.manifests)) { 37 | describe.skipIf(MANIFEST_BRANCH && branch !== MANIFEST_BRANCH)(`${branch} manifest`, async () => { 38 | const images = await getManifest(manifestUrl) 39 | 40 | // Check all images are present 41 | expect(images.length).toBe(33) 42 | 43 | let countGpt = 0 44 | 45 | for (const image of images) { 46 | if (image.gpt !== null) countGpt++ 47 | 48 | const big = image.name === 'system' || image.name.startsWith('userdata_') 49 | describe(`${image.name} image`, async () => { 50 | test('xz archive', () => { 51 | expect(image.fileName, 'file to be uncompressed').not.toContain('.xz') 52 | if (image.name === 'system') { 53 | if (image.compressed) { 54 | expect(image.fileName, 'not to equal archive name').not.toEqual(image.archiveFileName) 55 | expect(image.archiveFileName, 'archive to be in xz format').toContain('.xz') 56 | expect(image.archiveUrl, 'archive url to be in xz format').toContain('.xz') 57 | } else { 58 | expect(image.fileName, 'to equal archive name').toEqual(image.archiveFileName) 59 | expect(image.archiveUrl, 'archive url to not be in xz format').not.toContain('.xz') 60 | } 61 | } else { 62 | expect(image.compressed, 'image to be compressed').toBe(true) 63 | expect(image.archiveFileName, 'archive to be in xz format').toContain('.xz') 64 | expect(image.archiveUrl, 'archive url to be in xz format').toContain('.xz') 65 | } 66 | }) 67 | 68 | test.skipIf(big && !MANIFEST_BRANCH)('download', { 69 | timeout: (big ? 11 * 60 : 20) * 1000, 70 | repeats: 1, 71 | }, async () => { 72 | await imageManager.downloadImage(image) 73 | }) 74 | }) 75 | } 76 | 77 | // There should be one GPT image for each LUN 78 | expect(countGpt).toBe(6) 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /src/utils/platform.js: -------------------------------------------------------------------------------- 1 | const platform = (() => { 2 | if ('userAgentData' in navigator && 'platform' in navigator.userAgentData && navigator.userAgentData.platform) { 3 | return navigator.userAgentData.platform 4 | } 5 | const userAgent = navigator.userAgent.toLowerCase() 6 | if (userAgent.includes('linux')) return 'Linux' // includes Android 7 | if (userAgent.includes('win32') || userAgent.includes('windows')) return 'Windows' 8 | return null 9 | })() 10 | 11 | export const isWindows = !platform || platform === 'Windows' 12 | export const isLinux = platform === 'Linux' 13 | -------------------------------------------------------------------------------- /src/utils/progress.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a set of callbacks that can be used to track progress of a multistep process. 3 | * 4 | * @param {(number[]|number)} steps 5 | * @param {progressCallback} onProgress 6 | * @returns {(progressCallback)[]} 7 | */ 8 | export function createSteps(steps, onProgress) { 9 | const stepWeights = typeof steps === 'number' ? Array(steps).fill(1) : steps 10 | 11 | const progressParts = Array(stepWeights.length).fill(0) 12 | const totalSize = stepWeights.reduce((total, weight) => total + weight, 0) 13 | 14 | function updateProgress() { 15 | const weightedAverage = stepWeights.reduce((acc, weight, idx) => { 16 | return acc + progressParts[idx] * weight 17 | }, 0) 18 | onProgress(weightedAverage / totalSize) 19 | } 20 | 21 | return stepWeights.map((weight, idx) => (progress) => { 22 | if (progressParts[idx] !== progress) { 23 | progressParts[idx] = progress 24 | updateProgress() 25 | } 26 | }) 27 | } 28 | 29 | /** 30 | * Step weight callback 31 | * 32 | * @template T 33 | * @callback weightCallback 34 | * @param {T} step 35 | * @returns {number} 36 | */ 37 | 38 | /** 39 | * Iterate over a list of steps while reporting progress. 40 | * @template T 41 | * @param {T[]} steps 42 | * @param {progressCallback} onProgress 43 | * @param {weightCallback} [getStepWeight] 44 | * @returns {([T, progressCallback])[]} 45 | */ 46 | export function withProgress(steps, onProgress, getStepWeight) { 47 | const callbacks = createSteps( 48 | steps.map(getStepWeight || (step => typeof step === 'number' ? step : (typeof step !== 'string' ? step.size || step.length || 1 : 1))), 49 | onProgress, 50 | ) 51 | return steps.map((step, idx) => [step, callbacks[idx]]) 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/stream.js: -------------------------------------------------------------------------------- 1 | /** @param {Response} response */ 2 | const getContentLength = (response) => { 3 | const total = response.headers.get('Content-Length') 4 | if (total) return parseInt(total, 10) 5 | throw new Error('Content-Length not found in response headers') 6 | } 7 | 8 | /** 9 | * @param {string|URL} url 10 | * @param {RequestInit} [requestOptions] 11 | * @param {object} [options] 12 | * @param {number} [options.maxRetries] 13 | * @param {number} [options.retryDelay] 14 | * @param {progressCallback} [options.onProgress] 15 | */ 16 | export async function fetchStream(url, requestOptions = {}, options = {}) { 17 | const maxRetries = options.maxRetries || 3 18 | const retryDelay = options.retryDelay || 1000 19 | 20 | /** 21 | * @param {number} startByte 22 | * @param {AbortSignal} signal 23 | */ 24 | const fetchRange = async (startByte, signal) => { 25 | const headers = { ...(requestOptions.headers || {}) } 26 | if (startByte > 0) { 27 | headers['range'] = `bytes=${startByte}-` 28 | } 29 | 30 | const response = await fetch(url, { 31 | ...requestOptions, 32 | headers, 33 | signal 34 | }) 35 | if (!response.ok || (response.status !== 206 && response.status !== 200)) { 36 | throw new Error(`Fetch error: ${response.status}`) 37 | } 38 | return response 39 | } 40 | 41 | const abortController = new AbortController() 42 | let startByte = 0 43 | let contentLength = null 44 | 45 | return new ReadableStream({ 46 | async pull(stream) { 47 | for (let attempt = 0; attempt <= maxRetries; attempt++) { 48 | try { 49 | const response = await fetchRange(startByte, abortController.signal) 50 | if (contentLength === null) { 51 | contentLength = getContentLength(response) 52 | } 53 | 54 | const reader = response.body.getReader() 55 | while (true) { 56 | const { done, value } = await reader.read() 57 | if (done) { 58 | stream.close() 59 | return 60 | } 61 | 62 | startByte += value.byteLength 63 | stream.enqueue(value) 64 | options.onProgress?.(startByte / contentLength) 65 | } 66 | } catch (err) { 67 | console.warn(`Attempt ${attempt + 1} failed:`, err) 68 | if (attempt === maxRetries) { 69 | abortController.abort() 70 | stream.error(new Error('Max retries reached', { cause: err })) 71 | return 72 | } 73 | await new Promise((res) => setTimeout(res, retryDelay)) 74 | } 75 | } 76 | }, 77 | 78 | cancel(reason) { 79 | console.warn('Stream canceled:', reason) 80 | abortController.abort() 81 | }, 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /src/utils/stream.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest' 2 | import { fetchStream } from './stream' 3 | 4 | const encoder = new TextEncoder() 5 | const decoder = new TextDecoder() 6 | 7 | function mockResponse({ 8 | body = [], 9 | headers = {}, 10 | status = 200, 11 | failAfter = -1, 12 | }) { 13 | const chunks = body.map(chunk => encoder.encode(chunk)) 14 | return { 15 | ok: status >= 200 && status < 300, 16 | status, 17 | headers: { 18 | get: (key) => headers[key.toLowerCase()] || null, 19 | }, 20 | body: { 21 | getReader() { 22 | let readCount = 0 23 | return { 24 | read() { 25 | if (failAfter >= 0 && readCount > failAfter) { 26 | return Promise.reject(new Error('Network error')) 27 | } 28 | if (readCount < chunks.length) { 29 | return Promise.resolve({ done: false, value: chunks[readCount++] }) 30 | } 31 | return Promise.resolve({ done: true }) 32 | }, 33 | } 34 | }, 35 | } 36 | } 37 | } 38 | 39 | async function readText(stream) { 40 | const reader = stream.getReader() 41 | const chunks = [] 42 | while (true) { 43 | const { done, value } = await reader.read() 44 | if (done) break 45 | chunks.push(decoder.decode(value)) 46 | } 47 | return chunks.join('') 48 | } 49 | 50 | describe('fetchStream', () => { 51 | const retryDelay = 1 52 | 53 | beforeEach(() => { 54 | global.fetch = vi.fn() 55 | }) 56 | 57 | it('downloads content and tracks progress', async () => { 58 | global.fetch.mockResolvedValueOnce(mockResponse({ 59 | body: ['Hello ', 'World'], 60 | headers: { 'content-length': '11' }, 61 | })) 62 | 63 | const progress = [] 64 | const stream = await fetchStream('https://example.com', {}, { 65 | onProgress: progress.push.bind(progress), 66 | }) 67 | 68 | expect(await readText(stream)).toBe('Hello World') 69 | expect(progress[progress.length - 1]).toBe(1) 70 | }) 71 | 72 | it('retries and uses Range header after failure', async () => { 73 | global.fetch 74 | .mockResolvedValueOnce( 75 | mockResponse({ 76 | body: ['partial'], 77 | headers: { 'content-length': '12' }, 78 | status: 500, 79 | }) 80 | ) 81 | .mockResolvedValueOnce( 82 | mockResponse({ 83 | body: [' data'], 84 | headers: { 85 | 'content-length': '12', 86 | 'content-range': 'bytes 7-11/12', 87 | }, 88 | }) 89 | ) 90 | 91 | const stream = await fetchStream('https://example.com', {}, { maxRetries: 1, retryDelay }) 92 | 93 | expect(await readText(stream)).toBe(' data') 94 | expect(global.fetch).toHaveBeenCalledTimes(2) 95 | }) 96 | 97 | it('resumes download when reader fails mid-stream', async () => { 98 | global.fetch 99 | .mockResolvedValueOnce( 100 | mockResponse({ 101 | body: ['First part'], 102 | headers: { 'content-length': '22' }, 103 | failAfter: 0, 104 | }) 105 | ) 106 | .mockResolvedValueOnce( 107 | mockResponse({ 108 | body: [' second part'], 109 | headers: { 110 | 'content-length': '12', 111 | 'content-range': 'bytes 10-21/22', 112 | }, 113 | }) 114 | ) 115 | 116 | const stream = await fetchStream('https://example.com', {}, { maxRetries: 1, retryDelay }) 117 | 118 | expect(await readText(stream)).toBe('First part second part') 119 | const { headers } = global.fetch.mock.calls[1][1] 120 | expect(headers['range']).toBe('bytes=10-') 121 | }) 122 | 123 | it('throws after max retries', async () => { 124 | global.fetch.mockRejectedValue(new Error('network error')) 125 | 126 | const stream = await fetchStream('https://example.com', {}, { maxRetries: 2, retryDelay }) 127 | 128 | await expect(stream.getReader().read()).rejects.toThrow('Max retries reached') 129 | expect(global.fetch).toHaveBeenCalledTimes(3) 130 | }) 131 | }) 132 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | './index.html', 5 | './src/**/*.{js,jsx}', 6 | ], 7 | theme: { 8 | extend: { 9 | backgroundImage: { 10 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 11 | 'gradient-conic': 12 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 13 | }, 14 | fontFamily: { 15 | sans: ['Inter Variable', 'sans-serif'], 16 | monospace: ['JetBrains Mono Variable', 'monospace'], 17 | }, 18 | }, 19 | }, 20 | plugins: [ 21 | require('@tailwindcss/typography'), 22 | ], 23 | } 24 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | globals: true, 9 | environment: 'jsdom', 10 | setupFiles: './src/test/setup.js', 11 | }, 12 | }) 13 | --------------------------------------------------------------------------------