diff --git a/src/alerts/UnexpectedErrorAlert.tsx b/src/alerts/UnexpectedErrorAlert.tsx index 7ff563c38..e5832e2e1 100644 --- a/src/alerts/UnexpectedErrorAlert.tsx +++ b/src/alerts/UnexpectedErrorAlert.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2026 The Pybricks Authors import './UnexpectedErrorAlert.scss'; import { @@ -29,7 +29,11 @@ const UnexpectedErrorAlert: React.FunctionComponent = return ( <> -

{i18n.translate('message')}

+

+ {i18n.translate('message', { + copyErrorMessage: i18n.translate('copyErrorMessage'), + })} +

{error.message}

{error.stack && ( <> diff --git a/src/alerts/translations/en.json b/src/alerts/translations/en.json index b348134d4..fda28ba6f 100644 --- a/src/alerts/translations/en.json +++ b/src/alerts/translations/en.json @@ -1,5 +1,5 @@ { - "message": "An unexpected error occurred. Please consider reporting this so we can make a better error message.", + "message": "An unexpected error occurred. Please consider reporting this so we can make a better error message. Click the {copyErrorMessage} button below to copy the technical details and paste it in the bug report. Also include the operating system you are using, the web browser you are using and the actions you performed that led to this error.", "technicalInfo": "Expand for detailed technical information", "copyErrorMessage": "Copy Error Message", "reportBug": "Report Bug" diff --git a/src/usb/alerts/AccessDenied.test.tsx b/src/usb/alerts/AccessDenied.test.tsx new file mode 100644 index 000000000..234ec44c7 --- /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 000000000..7a5e6a56a --- /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/AlreadyInUse.test.tsx b/src/usb/alerts/AlreadyInUse.test.tsx new file mode 100644 index 000000000..91a788073 --- /dev/null +++ b/src/usb/alerts/AlreadyInUse.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 { alreadyInUse } from './AlreadyInUse'; + +it('should dismiss when close is clicked', async () => { + const callback = jest.fn(); + const toast = alreadyInUse(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/AlreadyInUse.tsx b/src/usb/alerts/AlreadyInUse.tsx new file mode 100644 index 000000000..834b48bbf --- /dev/null +++ b/src/usb/alerts/AlreadyInUse.tsx @@ -0,0 +1,24 @@ +// 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 { useI18n } from './i18n'; + +const AlreadyInUse: React.FunctionComponent = () => { + const i18n = useI18n(); + return ( + <> +

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

+ + ); +}; + +export const alreadyInUse: 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 c8f30b510..570bfea78 100644 --- a/src/usb/alerts/index.ts +++ b/src/usb/alerts/index.ts @@ -1,12 +1,16 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2025 The Pybricks Authors +// Copyright (c) 2025-2026 The Pybricks Authors +import { accessDenied } from './AccessDenied'; +import { alreadyInUse } from './AlreadyInUse'; import { newPybricksProfile } from './NewPybricksProfile'; import { noWebUsb } from './NoWebUsb'; import { oldFirmware } from './OldFirmware'; // gathers all of the alert creation functions for passing up to the top level export default { + accessDenied, + alreadyInUse, newPybricksProfile, noWebUsb, oldFirmware, diff --git a/src/usb/alerts/translations/en.json b/src/usb/alerts/translations/en.json index 937325679..c3bdf101f 100644 --- a/src/usb/alerts/translations/en.json +++ b/src/usb/alerts/translations/en.json @@ -4,6 +4,13 @@ "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." + }, "oldFirmware": { "message": "A new firmware version is available for this hub. Please install the latest version to use all new features.", "flashFirmware": { diff --git a/src/usb/sagas.ts b/src/usb/sagas.ts index eb9a1c96f..d10d51337 100644 --- a/src/usb/sagas.ts +++ b/src/usb/sagas.ts @@ -128,7 +128,9 @@ function* handleUsbConnectPybricks(hotPlugDevice?: USBDevice): Generator { return; } - // TODO: show unexpected error message to user here + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { error: reqDeviceErr }), + ); console.error('Failed to request USB device:', reqDeviceErr); yield* put(usbDidFailToConnectPybricks()); yield* cleanup(); @@ -154,7 +156,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(); @@ -168,7 +188,7 @@ function* handleUsbConnectPybricks(hotPlugDevice?: USBDevice): Generator { const [, selectErr] = yield* call(() => maybe(usbDevice.selectConfiguration(1))); if (selectErr) { - // TODO: show error message to user here + yield* put(alertsShowAlert('alerts', 'unexpectedError', { error: selectErr })); console.error('Failed to select USB device configuration:', selectErr); yield* put(usbDidFailToConnectPybricks()); yield* cleanup(); @@ -205,7 +225,17 @@ function* handleUsbConnectPybricks(hotPlugDevice?: USBDevice): Generator { maybe(usbDevice.claimInterface(iface.interfaceNumber)), ); if (claimErr) { - // TODO: show error message to user here + // Only show error to the user if they initiated the connection. + if (hotPlugDevice === undefined) { + if (claimErr.name === 'NetworkError') { + yield* put(alertsShowAlert('usb', 'alreadyInUse')); + } else { + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { error: claimErr }), + ); + } + } + console.error('Failed to claim USB interface:', claimErr); yield* put(usbDidFailToConnectPybricks()); yield* cleanup(); @@ -229,7 +259,11 @@ function* handleUsbConnectPybricks(hotPlugDevice?: USBDevice): Generator { ), ); if (fwVerError || fwVerResult?.status !== 'ok') { - // TODO: show error message to user here + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { + error: fwVerError || ensureError(fwVerResult?.status), + }), + ); console.error('Failed to get firmware version:', fwVerError); yield* put(usbDidFailToConnectPybricks()); yield* cleanup(); @@ -285,7 +319,11 @@ function* handleUsbConnectPybricks(hotPlugDevice?: USBDevice): Generator { ), ); if (nameError || nameResult?.status !== 'ok') { - // TODO: show error message to user here + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { + error: nameError || ensureError(nameResult?.status), + }), + ); console.error('Failed to get device name:', nameError); yield* put(usbDidFailToConnectPybricks()); yield* cleanup(); @@ -312,7 +350,11 @@ function* handleUsbConnectPybricks(hotPlugDevice?: USBDevice): Generator { ), ); if (swVerError || swVerResult?.status !== 'ok') { - // TODO: show error message to user here + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { + error: swVerError || ensureError(swVerResult?.status), + }), + ); console.error('Failed to get software version:', swVerError); yield* put(usbDidFailToConnectPybricks()); yield* cleanup(); @@ -354,7 +396,11 @@ function* handleUsbConnectPybricks(hotPlugDevice?: USBDevice): Generator { ), ); if (hubCapErr || hubCapResult?.status !== 'ok') { - // TODO: show error message to user here + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { + error: hubCapErr || ensureError(hubCapResult?.status), + }), + ); console.error('Failed to get hub capabilities:', hubCapErr); yield* put(usbDidFailToConnectPybricks()); yield* cleanup();