From 209b7ef9740947ec30afc5ad4dea68b9899b8bdf Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sun, 25 Jan 2026 22:32:35 +0000 Subject: [PATCH] usb: show error message if opening device fails So far, we've just seen this on Linux when the user doesn't have the correct udev rules installed. We'll have to wait for more user feedback to see what other situations might cause this. --- src/usb/alerts/AccessDenied.test.tsx | 19 +++++++++++++++++++ src/usb/alerts/AccessDenied.tsx | 26 ++++++++++++++++++++++++++ src/usb/alerts/index.ts | 2 ++ src/usb/alerts/translations/en.json | 4 ++++ src/usb/sagas.ts | 20 +++++++++++++++++++- 5 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 src/usb/alerts/AccessDenied.test.tsx create mode 100644 src/usb/alerts/AccessDenied.tsx diff --git a/src/usb/alerts/AccessDenied.test.tsx b/src/usb/alerts/AccessDenied.test.tsx new file mode 100644 index 00000000..234ec44c --- /dev/null +++ b/src/usb/alerts/AccessDenied.test.tsx @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025-2026 The Pybricks Authors + +import { Toast } from '@blueprintjs/core'; +import { act } from '@testing-library/react'; +import React from 'react'; +import { testRender } from '../../../test'; +import { accessDenied } from './AccessDenied'; + +it('should dismiss when close is clicked', async () => { + const callback = jest.fn(); + const toast = accessDenied(callback, undefined as never); + + const [user, message] = testRender(); + + await act(() => user.click(message.getByRole('button', { name: /close/i }))); + + expect(callback).toHaveBeenCalledWith('dismiss'); +}); diff --git a/src/usb/alerts/AccessDenied.tsx b/src/usb/alerts/AccessDenied.tsx new file mode 100644 index 00000000..7a5e6a56 --- /dev/null +++ b/src/usb/alerts/AccessDenied.tsx @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025-2026 The Pybricks Authors + +import { Intent } from '@blueprintjs/core'; +import { Error } from '@blueprintjs/icons'; +import React from 'react'; +import type { CreateToast } from '../../toasterTypes'; +import { isLinux } from '../../utils/os'; +import { useI18n } from './i18n'; + +const AccessDenied: React.FunctionComponent = () => { + const i18n = useI18n(); + return ( + <> +

{i18n.translate('accessDenied.message')}

+ {isLinux() &&

{i18n.translate('accessDenied.linuxSuggestion')}

} + + ); +}; + +export const accessDenied: CreateToast = (onAction) => ({ + message: , + icon: , + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), +}); diff --git a/src/usb/alerts/index.ts b/src/usb/alerts/index.ts index f6d5c0fa..570bfea7 100644 --- a/src/usb/alerts/index.ts +++ b/src/usb/alerts/index.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2025-2026 The Pybricks Authors +import { accessDenied } from './AccessDenied'; import { alreadyInUse } from './AlreadyInUse'; import { newPybricksProfile } from './NewPybricksProfile'; import { noWebUsb } from './NoWebUsb'; @@ -8,6 +9,7 @@ import { oldFirmware } from './OldFirmware'; // gathers all of the alert creation functions for passing up to the top level export default { + accessDenied, alreadyInUse, newPybricksProfile, noWebUsb, diff --git a/src/usb/alerts/translations/en.json b/src/usb/alerts/translations/en.json index 2f0019a2..c3bdf101 100644 --- a/src/usb/alerts/translations/en.json +++ b/src/usb/alerts/translations/en.json @@ -4,6 +4,10 @@ "suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge.", "action": "More Info" }, + "accessDenied": { + "message": "Access to the USB device was denied.", + "linuxSuggestion": "On Linux, ensure that you have the correct udev rules installed." + }, "alreadyInUse": { "message": "This hub is already in use by another application." }, diff --git a/src/usb/sagas.ts b/src/usb/sagas.ts index 85f38a59..ca85962a 100644 --- a/src/usb/sagas.ts +++ b/src/usb/sagas.ts @@ -154,7 +154,25 @@ function* handleUsbConnectPybricks(hotPlugDevice?: USBDevice): Generator { continue; } - // TODO: show error message to user here + // Only show error to the user if they initiated the connection. + if (hotPlugDevice === undefined) { + if (openErr.name === 'SecurityError') { + // Known causes: + // - Linux without proper udev rules to allow access to USB devices + // - Trying to access a device on a host machine when the USB + // device is shared with a VM guest OS. + // Other suspected causes: + // - Issues with permissions in containerized apps (e.g. Snaps on Ubuntu) + yield* put(alertsShowAlert('usb', 'accessDenied')); + } else { + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { + error: openErr, + }), + ); + } + } + console.error('Failed to open USB device:', openErr); yield* put(usbDidFailToConnectPybricks()); yield* cleanup();