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();