diff --git a/README.md b/README.md
index bdb6132a..bd8d9968 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 and deleting a temporary 5.25 GiB blank file.
+
## Development
```bash
diff --git a/src/app/App.test.jsx b/src/app/App.test.jsx
index 206de720..432f1063 100644
--- a/src/app/App.test.jsx
+++ b/src/app/App.test.jsx
@@ -1,10 +1,40 @@
import { Suspense } from 'react'
-import { expect, test } from 'vitest'
-import { render, screen } from '@testing-library/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,
+ runStorageProbe: vi.fn(({ onProgress }) => new Promise((resolve) => {
+ onProgress(0.5)
+ setTimeout(() => resolve({ writtenBytes: 1 }), 10)
+ })),
+ }
+})
test('renders without crashing', () => {
render()
expect(screen.getByText('flash.comma.ai')).toBeInTheDocument()
})
+
+test('runs the storage check in the existing progress screen', async () => {
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'Start' }))
+
+ expect(screen.getByText(/Checking available storage/)).toBeInTheDocument()
+ expect(await screen.findByText('Which device are you flashing?')).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 of space on this device/)).toBeInTheDocument()
+ expect(screen.getByText(/If you are using Incognito or InPrivate browsing/)).toBeInTheDocument()
+})
diff --git a/src/app/Flash.jsx b/src/app/Flash.jsx
index 18a650b6..ab84c9ff 100644
--- a/src/app/Flash.jsx
+++ b/src/app/Flash.jsx
@@ -3,7 +3,10 @@ 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 {
+ runStorageProbe,
+ useImageManager,
+} from '../utils/image'
import { isLinux, isWindows } from '../utils/platform'
import config from '../config'
@@ -223,9 +226,10 @@ const errors = {
hideRetry: true,
},
[ErrorCode.STORAGE_SPACE]: {
- description: 'Your system does not have enough space available to download the OS images. Your browser may be restricting' +
- ' the available space if you are in a private, incognito or guest session.',
- hideRetry: true,
+ status: 'Not enough storage',
+ description: 'Free at least 6 GiB of space on this device and retry. If you are using Incognito or InPrivate browsing, switch to a regular window.',
+ bgColor: 'bg-yellow-500',
+ icon: exclamation,
},
[ErrorCode.UNRECOGNIZED_DEVICE]: {
status: 'Unrecognized device',
@@ -542,6 +546,8 @@ function WebUSBConnect({ onConnect }) {
)
}
+const FORCE_STORAGE_PROBE_FAILURE = import.meta.env.DEV && new URLSearchParams(window.location.search).has('storageFail')
+
// Device picker component
function DevicePicker({ onSelect }) {
const [selected, setSelected] = useState(null)
@@ -623,7 +629,7 @@ export default function Flash() {
const [connected, setConnected] = useState(false)
const [serial, setSerial] = useState(null)
const [selectedDevice, setSelectedDevice] = useState(null)
- const [wizardScreen, setWizardScreen] = useState('landing') // 'landing', 'device', 'zadig', 'connect', 'unbind', 'webusb', 'flash'
+ const [wizardScreen, setWizardScreen] = useState('landing') // 'landing', 'storage', 'device', 'zadig', 'connect', 'unbind', 'webusb', 'flash'
const reportSentRef = useRef(false)
const qdlManager = useRef(null)
@@ -705,9 +711,25 @@ export default function Flash() {
}, [connected, wizardScreen])
// Handle user clicking start on landing page
- const handleStart = () => {
- setStep(StepCode.DEVICE_PICKER)
- setWizardScreen('device')
+ const handleStart = async () => {
+ setStep(StepCode.INITIALIZING)
+ setMessage('Checking available storage')
+ setProgress(0)
+ setWizardScreen('storage')
+
+ try {
+ if (FORCE_STORAGE_PROBE_FAILURE) throw new Error('Forced storage probe failure')
+ await runStorageProbe({ onProgress: setProgress })
+ setMessage('')
+ setProgress(-1)
+ setStep(StepCode.DEVICE_PICKER)
+ setWizardScreen('device')
+ } catch (storageError) {
+ console.error('[Storage] Storage check failed:', storageError)
+ setMessage('')
+ setProgress(-1)
+ setError(ErrorCode.STORAGE_SPACE)
+ }
}
// Handle device selection
@@ -848,8 +870,6 @@ export default function Flash() {
if (progress >= 0) {
title += ` (${(progress * 100).toFixed(0)}%)`
}
- } else if (error === ErrorCode.STORAGE_SPACE) {
- title = message
} else {
title = status
}
diff --git a/src/utils/image.js b/src/utils/image.js
index 98ac3c04..2a3c2b85 100644
--- a/src/utils/image.js
+++ b/src/utils/image.js
@@ -11,7 +11,42 @@ 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 async function runStorageProbe({
+ targetBytes = STORAGE_PROBE_BYTES,
+ chunkBytes = STORAGE_PROBE_CHUNK_BYTES,
+ onProgress = undefined,
+} = {}) {
+ if (!navigator.storage?.getDirectory) throw new Error('OPFS is unavailable in this browser')
+
+ const root = await navigator.storage.getDirectory()
+ await root.removeEntry(STORAGE_PROBE_FILE).catch(() => {})
+ const fileHandle = await root.getFileHandle(STORAGE_PROBE_FILE, { create: true })
+ const writable = await fileHandle.createWritable()
+ let writtenBytes = 0
+
+ try {
+ while (writtenBytes < targetBytes) {
+ 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) {
+ await writable.abort(error).catch(() => {})
+ throw error
+ } finally {
+ await root.removeEntry(STORAGE_PROBE_FILE).catch(() => {})
+ }
+}
export class ImageManager {
/** @type {FileSystemDirectoryHandle} */
@@ -31,12 +66,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`)
- }
}
/**
diff --git a/src/utils/image.test.js b/src/utils/image.test.js
new file mode 100644
index 00000000..2014da1a
--- /dev/null
+++ b/src/utils/image.test.js
@@ -0,0 +1,58 @@
+import { afterEach, describe, expect, test, vi } from 'vitest'
+
+import { runStorageProbe } from './image'
+
+const originalStorage = Object.getOwnPropertyDescriptor(navigator, 'storage')
+
+function mockOpfs(write = vi.fn()) {
+ const writable = {
+ write,
+ close: vi.fn(),
+ abort: vi.fn(),
+ }
+ const removeEntry = vi.fn()
+ .mockRejectedValueOnce(new DOMException('File not found', 'NotFoundError'))
+ .mockResolvedValue()
+ const root = {
+ removeEntry,
+ getFileHandle: vi.fn().mockResolvedValue({
+ createWritable: vi.fn().mockResolvedValue(writable),
+ }),
+ }
+ Object.defineProperty(navigator, 'storage', {
+ configurable: true,
+ value: { getDirectory: vi.fn().mockResolvedValue(root) },
+ })
+ return { removeEntry, writable }
+}
+
+afterEach(() => {
+ if (originalStorage) Object.defineProperty(navigator, 'storage', originalStorage)
+ else delete navigator.storage
+})
+
+describe('storage balloon test', () => {
+ test('writes blank data and deletes the temporary file', async () => {
+ const opfs = mockOpfs()
+ const progress = vi.fn()
+
+ await runStorageProbe({ targetBytes: 10, chunkBytes: 4, onProgress: progress })
+
+ expect(opfs.writable.write.mock.calls.map(([chunk]) => [...chunk])).toEqual([
+ [0, 0, 0, 0], [0, 0, 0, 0], [0, 0],
+ ])
+ expect(progress).toHaveBeenLastCalledWith(1, 10)
+ expect(opfs.removeEntry).toHaveBeenCalledTimes(2)
+ })
+
+ test('aborts and deletes the temporary file when the disk fills', async () => {
+ const write = vi.fn()
+ .mockResolvedValueOnce()
+ .mockRejectedValueOnce(new DOMException('Disk full', 'QuotaExceededError'))
+ const opfs = mockOpfs(write)
+
+ await expect(runStorageProbe({ targetBytes: 12, chunkBytes: 4 })).rejects.toThrow()
+ expect(opfs.writable.abort).toHaveBeenCalled()
+ expect(opfs.removeEntry).toHaveBeenCalledTimes(2)
+ })
+})