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