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