diff --git a/src/app/Flash.jsx b/src/app/Flash.jsx index 18a650b6..1f63ed1c 100644 --- a/src/app/Flash.jsx +++ b/src/app/Flash.jsx @@ -203,6 +203,46 @@ const steps = { }, } +// Shown when the OS refuses to open the device (WebUSB SecurityError), e.g. on +// Linux when the user lacks write access to the /dev/bus/usb device node. +const UDEV_FIX_SCRIPT = `sudo tee /etc/udev/rules.d/99-comma-flash.rules > /dev/null <<'EOF' +SUBSYSTEM=="usb", ATTR{idVendor}=="05c6", ATTR{idProduct}=="9008", TAG+="uaccess" +SUBSYSTEM=="usb", ATTR{idVendor}=="3801", ATTR{idProduct}=="9008", TAG+="uaccess" +EOF +sudo udevadm control --reload-rules && sudo udevadm trigger` + +function AccessDeniedHelp() { + const [copied, setCopied] = useState(false) + + if (!isLinux) { + return <>Your browser was not allowed to open the device. Close any other program that may be using the device, unplug it, and try again. + } + + const handleCopy = () => { + navigator.clipboard.writeText(UDEV_FIX_SCRIPT) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + <> + Your user does not have permission to access the device. Run this command in a terminal + to grant access, then retry: + +
+          {UDEV_FIX_SCRIPT}
+        
+ +
+ + ) +} + const errors = { [ErrorCode.UNKNOWN]: { status: 'Unknown error', @@ -239,6 +279,13 @@ const errors = { description: 'The connection to your device was lost. Unplug your device and try again.', icon: cable, }, + [ErrorCode.ACCESS_DENIED]: { + status: 'Access denied', + description: , + bgColor: 'bg-yellow-500', + icon: deviceExclamation, + showDiscordHelp: true, + }, [ErrorCode.REPAIR_PARTITION_TABLES_FAILED]: { status: 'Repairing partition tables failed', description: 'Your device\'s partition tables could not be repaired. Try using a different cable, USB port, or computer.', diff --git a/src/utils/manager.js b/src/utils/manager.js index 618ff8db..0eb97987 100644 --- a/src/utils/manager.js +++ b/src/utils/manager.js @@ -41,6 +41,22 @@ export const ErrorCode = { ERASE_FAILED: 6, FLASH_SYSTEM_FAILED: 7, FINALIZING_FAILED: 8, + ACCESS_DENIED: 9, +} + +/** + * Check if an error was caused by the OS denying access to the device, e.g. on + * Linux when the user lacks write access to the /dev/bus/usb device node. + * Chrome throws a SecurityError ("Access denied.") from USBDevice.open(), + * which qdl.js wraps as the cause of its own connection error. + * @param {any} err + * @returns {boolean} + */ +export function isAccessDeniedError(err) { + for (let e = err; e; e = e.cause) { + if (e.name === 'SecurityError') return true + } + return false } /** @@ -232,7 +248,7 @@ export class FlashManager { return } console.error('[Flash] Connection error', err) - this.#setError(ErrorCode.LOST_CONNECTION) + this.#setError(isAccessDeniedError(err) ? ErrorCode.ACCESS_DENIED : ErrorCode.LOST_CONNECTION) this.#setConnected(false) return } diff --git a/src/utils/manager.test.js b/src/utils/manager.test.js new file mode 100644 index 00000000..f9709e0e --- /dev/null +++ b/src/utils/manager.test.js @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest' + +import { isAccessDeniedError } from './manager' + +describe('isAccessDeniedError', () => { + test('detects a SecurityError', () => { + expect(isAccessDeniedError(new DOMException('Access denied.', 'SecurityError'))).toBe(true) + }) + + test('detects a SecurityError wrapped as a cause', () => { + const cause = new DOMException('Access denied.', 'SecurityError') + const wrapped = new Error('Error while connecting to device', { cause }) + expect(isAccessDeniedError(wrapped)).toBe(true) + }) + + test('ignores other connection errors', () => { + expect(isAccessDeniedError(new Error('Connection lost'))).toBe(false) + expect(isAccessDeniedError(new DOMException('A transfer error has occurred.', 'NetworkError'))).toBe(false) + expect(isAccessDeniedError(undefined)).toBe(false) + }) +})