Skip to content
Draft
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 and deleting a temporary 5.25 GiB blank file.

## Development

```bash
Expand Down
34 changes: 32 additions & 2 deletions src/app/App.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<Suspense fallback="loading"><App /></Suspense>)
expect(screen.getByText('flash.comma.ai')).toBeInTheDocument()
})

test('runs the storage check in the existing progress screen', async () => {
render(<Suspense fallback="loading"><App /></Suspense>)
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(<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 of space on this device/)).toBeInTheDocument()
expect(screen.getByText(/If you are using Incognito or InPrivate browsing/)).toBeInTheDocument()
})
40 changes: 30 additions & 10 deletions src/app/Flash.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
43 changes: 36 additions & 7 deletions src/utils/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand All @@ -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`)
}
}

/**
Expand Down
58 changes: 58 additions & 0 deletions src/utils/image.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading