From 84533c0153d68de5e6b270c3ed7017217ffcbf5c Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sun, 25 Jan 2026 22:03:25 +0000 Subject: [PATCH 1/4] usb: implement AlreadyInUse alert Add an alert that notifies the user when the USB device is already in use by another application. Closes: https://github.com/pybricks/support/issues/2373 --- src/usb/alerts/AlreadyInUse.test.tsx | 19 +++++++++++++++++++ src/usb/alerts/AlreadyInUse.tsx | 24 ++++++++++++++++++++++++ src/usb/alerts/index.ts | 4 +++- src/usb/alerts/translations/en.json | 3 +++ src/usb/sagas.ts | 12 +++++++++++- 5 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/usb/alerts/AlreadyInUse.test.tsx create mode 100644 src/usb/alerts/AlreadyInUse.tsx 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..f6d5c0faa 100644 --- a/src/usb/alerts/index.ts +++ b/src/usb/alerts/index.ts @@ -1,12 +1,14 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2025 The Pybricks Authors +// Copyright (c) 2025-2026 The Pybricks Authors +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 { + alreadyInUse, newPybricksProfile, noWebUsb, oldFirmware, diff --git a/src/usb/alerts/translations/en.json b/src/usb/alerts/translations/en.json index 937325679..2f0019a26 100644 --- a/src/usb/alerts/translations/en.json +++ b/src/usb/alerts/translations/en.json @@ -4,6 +4,9 @@ "suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge.", "action": "More Info" }, + "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..85f38a596 100644 --- a/src/usb/sagas.ts +++ b/src/usb/sagas.ts @@ -205,7 +205,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(); From f0728d2d3c5049babb57ebc7e56d5da75476323f Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sun, 25 Jan 2026 22:32:35 +0000 Subject: [PATCH 2/4] 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 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/index.ts b/src/usb/alerts/index.ts index f6d5c0faa..570bfea78 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 2f0019a26..c3bdf101f 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 85f38a596..ca85962ad 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(); From 11a8436265b9ead65ab9c69aeff8fa75c09ddd5a Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sun, 25 Jan 2026 22:38:25 +0000 Subject: [PATCH 3/4] usb/sagas: show unexpected error alerts Add more error alerts when failing to connect to a Pybricks hub over USB. We will need user feedback on what triggered the error to be able to write a helpful error message, so we are using the unexpected error alert for now. --- src/usb/sagas.ts | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/usb/sagas.ts b/src/usb/sagas.ts index ca85962ad..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(); @@ -186,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(); @@ -257,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(); @@ -313,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(); @@ -340,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(); @@ -382,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(); From a114613eb328b3afa69dbe773a665776cf321e79 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sun, 25 Jan 2026 22:43:48 +0000 Subject: [PATCH 4/4] alerts/UnexpectedErrorAlert: expand the message Expand the unexpected error alert message to include instructions on what information to provide to make a useful bug report. --- src/alerts/UnexpectedErrorAlert.tsx | 8 ++++++-- src/alerts/translations/en.json | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) 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"