diff --git a/README.md b/README.md
index bdb6132a..0ca07a96 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/app/App.test.jsx b/src/app/App.test.jsx
index 206de720..08b7dfa8 100644
--- a/src/app/App.test.jsx
+++ b/src/app/App.test.jsx
@@ -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()
expect(screen.getByText('flash.comma.ai')).toBeInTheDocument()
})
+
+test('shows the storage check without private-browsing guidance', async () => {
+ render()
+ 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()
+ 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(
+
+
+ ,
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Start' }))
+
+ expect(await screen.findByText('Passed')).toBeInTheDocument()
+ expect(screen.queryByText('Canceled. Retry the storage pre-check.')).not.toBeInTheDocument()
+})
diff --git a/src/app/Flash.jsx b/src/app/Flash.jsx
index 18a650b6..871176de 100644
--- a/src/app/Flash.jsx
+++ b/src/app/Flash.jsx
@@ -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'
@@ -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 (
+
+
+
+
Storage check
+ {!probeFailed && (
+
+ {probePassed ? '5.25 GiB reserved for flashing.' : 'Reserving 5.25 GiB before flashing...'}
+
+ )}
+
+ {probePassed && (
+
Passed
+ )}
+ {probeStatus === 'running' && (
+
+ )}
+
+ {probeStatus === 'running' && (
+
+ )}
+ {probeFailed && (
+
+
{probeMessage}
+
+
+ )}
+
+ )
+}
+
// 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 (
-
+
Which device are you flashing?
Select your comma device
@@ -579,11 +725,16 @@ function DevicePicker({ onSelect }) {
+
+