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) + }) +})