Skip to content
Open
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
47 changes: 47 additions & 0 deletions src/app/Flash.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
<span className="relative block w-full max-w-2xl mx-auto mt-4 text-left">
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg text-sm overflow-x-auto font-mono">
{UDEV_FIX_SCRIPT}
</pre>
<button
onClick={handleCopy}
className="absolute top-2 right-2 px-3 py-1 bg-blue-600 hover:bg-blue-500 active:bg-blue-400 text-white text-sm rounded transition-colors"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</span>
</>
)
}

const errors = {
[ErrorCode.UNKNOWN]: {
status: 'Unknown error',
Expand Down Expand Up @@ -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: <AccessDeniedHelp />,
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.',
Expand Down
18 changes: 17 additions & 1 deletion src/utils/manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

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