Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

This tool allows you to flash AGNOS onto your comma device. Uses [qdl.js](https://github.com/commaai/qdl.js).

Before flashing, the tool verifies local disk space by writing a temporary 5.25 GiB blank file. The file is held as a reservation until flashing begins, then deleted before images are downloaded and staged for A/B partitions.

## Development

```bash
Expand Down
54 changes: 51 additions & 3 deletions src/app/App.test.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,58 @@
import { Suspense } from 'react'
import { expect, test } from 'vitest'
import { render, screen } from '@testing-library/react'
import { StrictMode, Suspense } from 'react'
import { expect, test, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'

import App from '.'
import { runStorageProbe } from '../utils/image'

vi.mock('../utils/image', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
cleanupStorageProbe: vi.fn().mockResolvedValue(undefined),
runStorageProbe: vi.fn(({ signal }) => new Promise((resolve, reject) => {
const passTimer = setTimeout(() => resolve({ writtenBytes: 1 }), 10)
signal.addEventListener('abort', () => {
clearTimeout(passTimer)
reject(new DOMException('Storage test canceled', 'AbortError'))
}, { once: true })
})),
}
})

test('renders without crashing', () => {
render(<Suspense fallback="loading"><App /></Suspense>)
expect(screen.getByText('flash.comma.ai')).toBeInTheDocument()
})

test('shows the storage check without private-browsing guidance', async () => {
render(<Suspense fallback="loading"><App /></Suspense>)
fireEvent.click(screen.getByRole('button', { name: 'Start' }))

expect(screen.getByText('Storage check')).toBeInTheDocument()
expect(screen.queryByText(/Do not use Incognito or InPrivate browsing/)).not.toBeInTheDocument()
expect(await screen.findByText('Passed')).toBeInTheDocument()
})

test('shows private-browsing guidance after a storage failure', async () => {
vi.mocked(runStorageProbe).mockRejectedValueOnce(new Error('Quota exceeded'))
render(<Suspense fallback="loading"><App /></Suspense>)
fireEvent.click(screen.getByRole('button', { name: 'Start' }))

expect(screen.queryByText(/Do not use Incognito or InPrivate browsing/)).not.toBeInTheDocument()
expect(await screen.findByText(/Free at least 6 GiB on this device/)).toBeInTheDocument()
expect(screen.getByText(/chrome:\/\/settings\/content\/siteData/)).toBeInTheDocument()
expect(screen.getByText(/Make sure this page is open in a regular browser window—not an Incognito, InPrivate, or Private window/)).toBeInTheDocument()
})

test('does not cancel the storage pre-check during the Strict Mode effect cycle', async () => {
render(
<StrictMode>
<Suspense fallback="loading"><App /></Suspense>
</StrictMode>,
)
fireEvent.click(screen.getByRole('button', { name: 'Start' }))

expect(await screen.findByText('Passed')).toBeInTheDocument()
expect(screen.queryByText('Canceled. Retry the storage pre-check.')).not.toBeInTheDocument()
})
172 changes: 165 additions & 7 deletions src/app/Flash.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import posthog from 'posthog-js'
import * as Sentry from '@sentry/react'

import { FlashManager, StepCode, ErrorCode, DeviceType } from '../utils/manager'
import { useImageManager } from '../utils/image'
import {
cleanupStorageProbe,
runStorageProbe,
useImageManager,
} from '../utils/image'
import { isLinux, isWindows } from '../utils/platform'
import config from '../config'

Expand Down Expand Up @@ -542,12 +546,154 @@ function WebUSBConnect({ onConnect }) {
)
}

const STORAGE_PROBE_MARKER = 'comma-flash-storage-probe-active'
const FORCE_STORAGE_PROBE_FAILURE = import.meta.env.DEV && new URLSearchParams(window.location.search).has('storageFail')
const STORAGE_PROBE_FAILURE_MESSAGE = 'Storage check failed. Make sure this page is open in a regular browser window—not an Incognito, InPrivate, or Private window. If you are already in a regular window, the device may not have at least 6 GiB free, or the browser may be limiting persistent site storage. Free at least 6 GiB on this device first. If there is enough space, enter chrome://settings/content/siteData in the address bar. Choose "Allow sites to save data on your device" and turn off any setting that deletes site data when the browser closes. Fully quit and reopen the browser, not just this tab, then retry.'

function StoragePreCheck({ storageCleanupComplete, onProbeStatusChange }) {
const [probeStatus, setProbeStatus] = useState('idle')
const [probeProgress, setProbeProgress] = useState(0)
const [probeMessage, setProbeMessage] = useState('')
const probeStatusRef = useRef('idle')
const probeAbortRef = useRef(null)
const probeRunRef = useRef(0)

const updateProbeStatus = (nextStatus, nextMessage = '') => {
probeStatusRef.current = nextStatus
setProbeStatus(nextStatus)
setProbeMessage(nextMessage)
onProbeStatusChange(nextStatus)
}

const startProbe = async () => {
if (probeStatusRef.current === 'running') return
const runId = ++probeRunRef.current
const abortController = new AbortController()
probeAbortRef.current = abortController
setProbeProgress(0)
updateProbeStatus('running')

try {
localStorage.setItem(STORAGE_PROBE_MARKER, '1')
} catch {
// The OPFS write itself remains authoritative if localStorage is unavailable.
}

try {
if (FORCE_STORAGE_PROBE_FAILURE) throw new Error('Forced storage probe failure')
await runStorageProbe({
signal: abortController.signal,
onProgress: (progress) => {
if (probeRunRef.current !== runId) return
setProbeProgress(progress)
},
})
if (probeRunRef.current !== runId) return
try { localStorage.removeItem(STORAGE_PROBE_MARKER) } catch { /* ignored */ }
updateProbeStatus('passed', 'Passed')
} catch (error) {
if (probeRunRef.current !== runId) return
try { localStorage.removeItem(STORAGE_PROBE_MARKER) } catch { /* ignored */ }
if (error?.name === 'AbortError') {
updateProbeStatus('failed', 'Canceled. Retry the storage pre-check.')
} else {
updateProbeStatus('failed', STORAGE_PROBE_FAILURE_MESSAGE)
}
} finally {
if (probeRunRef.current === runId) probeAbortRef.current = null
}
}

useEffect(() => {
if (!storageCleanupComplete || probeStatusRef.current !== 'idle') return

// Deferring one task prevents React Strict Mode's development-only effect
// cleanup from starting and immediately aborting the real storage probe.
const startTimer = setTimeout(() => {
let previousProbeInterrupted = false
try {
previousProbeInterrupted = localStorage.getItem(STORAGE_PROBE_MARKER) === '1'
localStorage.removeItem(STORAGE_PROBE_MARKER)
} catch {
// Continue with a new probe when localStorage is unavailable.
}
if (previousProbeInterrupted) {
updateProbeStatus('failed', STORAGE_PROBE_FAILURE_MESSAGE)
return
}

startProbe()
}, 0)

return () => clearTimeout(startTimer)
}, [storageCleanupComplete])

useEffect(() => () => {
if (probeStatusRef.current === 'running') probeAbortRef.current?.abort()
}, [])

const probePassed = probeStatus === 'passed'
const probeFailed = probeStatus === 'failed'

return (
<div className={`w-full max-w-2xl rounded-xl border-2 px-5 py-4 transition-colors ${
probePassed
? 'border-[#51ff00] bg-[#51ff00]/10'
: probeFailed
? 'border-red-300 bg-red-50'
: 'border-gray-300 bg-white'
}`}>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-lg font-semibold">Storage check</p>
{!probeFailed && (
<p className="mt-0.5 text-sm text-gray-600">
{probePassed ? '5.25 GiB reserved for flashing.' : 'Reserving 5.25 GiB before flashing...'}
</p>
)}
</div>
{probePassed && (
<span className="rounded-full bg-[#51ff00] px-3 py-1 text-sm font-semibold text-black">Passed</span>
)}
{probeStatus === 'running' && (
<button
type="button"
onClick={() => probeAbortRef.current?.abort()}
className="rounded-full border border-gray-300 px-3 py-1 text-sm text-gray-600 transition-colors hover:border-gray-400 hover:text-black"
>
Cancel
</button>
)}
</div>
{probeStatus === 'running' && (
<div className="mt-4 h-2 overflow-hidden rounded-full bg-gray-200">
<div className="h-full rounded-full bg-[#51ff00] transition-all" style={{ width: `${probeProgress * 100}%` }} />
</div>
)}
{probeFailed && (
<div className="mt-2 text-sm text-red-700">
<p>{probeMessage}</p>
<button
type="button"
onClick={startProbe}
className="mt-3 rounded-full bg-[#51ff00] px-4 py-2 font-semibold text-black transition-colors hover:bg-[#45e000] active:bg-[#3acc00]"
>
Retry storage check
</button>
</div>
)}
</div>
)
}

// Device picker component
function DevicePicker({ onSelect }) {
function DevicePicker({ onSelect, storageCleanupComplete }) {
const [selected, setSelected] = useState(null)
const [probeStatus, setProbeStatus] = useState('idle')
const storageReady = probeStatus === 'passed'

return (
<div className="wizard-screen flex flex-col items-center justify-center h-full gap-8 p-8">
<div className="wizard-screen flex flex-col items-center justify-center h-full gap-6 p-8 overflow-y-auto">
<div className="text-center">
<h2 className="text-3xl font-bold mb-2">Which device are you flashing?</h2>
<p className="text-xl text-gray-600">Select your comma device</p>
Expand Down Expand Up @@ -579,11 +725,16 @@ function DevicePicker({ onSelect }) {
</button>
</div>

<StoragePreCheck
storageCleanupComplete={storageCleanupComplete}
onProbeStatusChange={setProbeStatus}
/>

<button
onClick={() => selected && onSelect(selected)}
disabled={!selected}
onClick={() => selected && storageReady && onSelect(selected)}
disabled={!selected || !storageReady}
className={`px-8 py-3 text-xl font-semibold rounded-full transition-colors ${
selected
selected && storageReady
? 'bg-[#51ff00] hover:bg-[#45e000] active:bg-[#3acc00] text-black'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
Expand Down Expand Up @@ -623,6 +774,7 @@ export default function Flash() {
const [connected, setConnected] = useState(false)
const [serial, setSerial] = useState(null)
const [selectedDevice, setSelectedDevice] = useState(null)
const [storageCleanupComplete, setStorageCleanupComplete] = useState(false)
const [wizardScreen, setWizardScreen] = useState('landing') // 'landing', 'device', 'zadig', 'connect', 'unbind', 'webusb', 'flash'
const reportSentRef = useRef(false)

Expand All @@ -633,6 +785,12 @@ export default function Flash() {
const wizardSteps = getWizardSteps(selectedDevice)
const wizardStep = screenToStep[wizardScreen] ? wizardSteps.indexOf(screenToStep[wizardScreen]) : -1

useEffect(() => {
cleanupStorageProbe()
.catch((error) => console.warn('[Storage] Could not clean up a previous storage test:', error))
.finally(() => setStorageCleanupComplete(true))
}, [])

useEffect(() => {
if (!imageManager.current) return

Expand Down Expand Up @@ -779,7 +937,7 @@ export default function Flash() {
return (
<div className="relative h-full">
<Stepper steps={wizardSteps} currentStep={wizardStep} onStepClick={handleWizardBack} />
<DevicePicker onSelect={handleDeviceSelect} />
<DevicePicker onSelect={handleDeviceSelect} storageCleanupComplete={storageCleanupComplete} />
</div>
)
}
Expand Down
88 changes: 81 additions & 7 deletions src/utils/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,86 @@ import { fetchStream } from './stream'
* @returns {void}
*/

const MIN_QUOTA_GB = 5.25
export const MIN_STORAGE_GB = 5.25
export const STORAGE_PROBE_BYTES = MIN_STORAGE_GB * (2 ** 30)
const STORAGE_PROBE_FILE = '.comma-flash-storage-probe'
const STORAGE_PROBE_CHUNK_BYTES = 8 * 1024 * 1024

export class StorageProbeError extends Error {
constructor(message, writtenBytes, cause = undefined) {
super(message, cause ? { cause } : undefined)
this.name = 'StorageProbeError'
this.writtenBytes = writtenBytes
}
}

async function removeProbeFile(root) {
if (typeof root.removeEntry !== 'function') return
try {
await root.removeEntry(STORAGE_PROBE_FILE)
} catch (error) {
if (error?.name !== 'NotFoundError') throw error
}
}

export async function cleanupStorageProbe() {
if (!navigator.storage?.getDirectory) return
const root = await navigator.storage.getDirectory()
await removeProbeFile(root)
}

export async function runStorageProbe({
targetBytes = STORAGE_PROBE_BYTES,
chunkBytes = STORAGE_PROBE_CHUNK_BYTES,
onProgress = undefined,
signal = undefined,
} = {}) {
if (!navigator.storage?.getDirectory) throw new Error('OPFS is unavailable in this browser')

const root = await navigator.storage.getDirectory()
try {
const existingHandle = await root.getFileHandle(STORAGE_PROBE_FILE, { create: false })
const existingFile = await existingHandle.getFile()
if (existingFile.size === targetBytes) {
onProgress?.(1, targetBytes)
return { writtenBytes: targetBytes, reused: true }
}
} catch (error) {
if (error?.name !== 'NotFoundError') throw error
}

await removeProbeFile(root)
const fileHandle = await root.getFileHandle(STORAGE_PROBE_FILE, { create: true })
const writable = await fileHandle.createWritable()
let writtenBytes = 0

try {
while (writtenBytes < targetBytes) {
if (signal?.aborted) throw new DOMException('Storage test canceled', 'AbortError')
const writeLength = Math.min(chunkBytes, targetBytes - writtenBytes)
const chunk = new Uint8Array(writeLength)
await writable.write(chunk)
writtenBytes += writeLength
onProgress?.(writtenBytes / targetBytes, writtenBytes)
}
await writable.close()
onProgress?.(1, writtenBytes)
return { writtenBytes }
} catch (error) {
try {
await writable.abort(error)
} catch {
// The browser may have already closed the stream after a quota failure.
}
try {
await removeProbeFile(root)
} catch (cleanupError) {
console.warn('[Storage] Could not remove failed storage probe:', cleanupError)
}
if (error?.name === 'AbortError') throw error
throw new StorageProbeError('The storage write test could not reserve enough space', writtenBytes, error)
}
}

export class ImageManager {
/** @type {FileSystemDirectoryHandle} */
Expand All @@ -20,6 +99,7 @@ export class ImageManager {
async init() {
if (!this.root) {
this.root = await navigator.storage.getDirectory()
await removeProbeFile(this.root)
// Clean up any leftover files from previous sessions
try {
await this.root.remove({ recursive: true })
Expand All @@ -31,12 +111,6 @@ export class ImageManager {
this.root = await navigator.storage.getDirectory()
console.info('[ImageManager] Initialized')
}

const estimate = await navigator.storage.estimate()
const quotaGB = (estimate.quota || 0) / (1024 ** 3)
if (quotaGB < MIN_QUOTA_GB) {
throw new Error(`Not enough storage: ${quotaGB.toFixed(1)}GB free, need ${MIN_QUOTA_GB.toFixed(1)}GB`)
}
}

/**
Expand Down
Loading
Loading