Skip to main content

pyside6-webusb

Status: 0.0.5.post7 (informally v0.0.5b3) — early beta, working and tested (see CHANGELOG.md), but young. Treat pre-1.0 the way you'd treat any early-stage library. Isochronous transfer is implemented (bridge.py's isochronousTransferIn/Out), but per-packet fidelity on IN transfers has a known limitation traced to a pyusb API gap — see CHANGELOG. Not yet verified against real isochronous hardware.

A WebUSB API implementation for PySide6 / QtWebEngine apps.

QtWebEngine (the Chromium build PySide6 ships) does not implement navigator.usb — Chromium itself only ships WebUSB in the full Chrome/Chromium browser shell, not in the embeddable WebEngine component. If you're building a PySide6 app with a QWebEngineView and the page you're loading expects navigator.usb to exist (device-configuration tools, firmware flashers, hardware dashboards, etc.), it silently won't. This package fills that gap:

  • A Python bridge (WebUSBBridge) that talks to real hardware via pyusb/libusb, exposed to the page over QWebChannel.
  • A JavaScript polyfill that makes navigator.usb behave like the real thing — same classes, same method names, same DOMException names, same permission model.
  • A native device chooser dialog and per-origin permission store, so pages only ever see the one device the user explicitly picks — never a raw list of everything plugged in.
from PySide6.QtWebEngineWidgets import QWebEngineView
from pyside6_webusb import install

view = QWebEngineView()
install(view.page())      # <- that's the entire integration
view.load("https://your-site.example")

Why this exists / provenance

This was extracted and generalized from the WebUSB implementation inside openweb, a PySide6-based custom browser, where it went through a security audit and several rounds of hardening (protected device classes, a known-security-key blocklist, per-origin permission storage, and a line-by-line comparison against the WICG WebUSB spec and Chromium's blocklist source, and — from 0.0.4b2 onward — Blink's actual C++ implementation source, not just the spec text). This package is that same, already-tested implementation, with the browser-specific bits removed so it can be dropped into any PySide6 app. Its own repository is steck0714/Pyside6-webusb.

pyside6-webusb is the PySide6/QtWebEngine implementation under Mock-webusb, a small umbrella project for WebUSB-compatible implementations across different platforms — fox-webusb is the Firefox counterpart. Mock-webusb's own stated design goal (quoting its README): unlike Chrome's WebUSB, these are designed strictly as compatible APIs that may include original or custom features, while remaining usable as WebUSB-compatible APIs at their core — match real-world compatible behavior where that's what matters, but stay free to diverge deliberately, with the divergence always documented rather than silently hidden, where this implementation's own constraints, security posture, or extensions call for something different. The 0.0.4b2 policy change described under "Large transfers" below (real Chrome's 32 MiB cap becomes a warning here, not a hard rejection) is a direct example of that philosophy in practice, not a compatibility bug. (Mock-webusb's own README also carries a disclosure worth repeating here: this codebase includes AI-generated code and isn't guaranteed to behave correctly in every environment or condition — review it before relying on it, same as you would any other early-stage dependency.)

Installation

pip install pyside6-webusb

(or, from a clone of this repo: pip install -e .). Requires PySide6-Essentials, PySide6-Addons (for QtWebEngine/QtWebChannel) and pyusb, which are pulled in automatically. On Linux you'll also need libusb-1.0 installed at the OS level (apt install libusb-1.0-0 or equivalent) — pyusb links against it.

API

install(page, browser_window=None, settings_organization="pyside6-webusb", settings_application="WebUSBBridge", qwebchannel_js=None) -> WebUSBBridge | None

The only function most apps need. Wires up navigator.usb on the given QWebEnginePage.

  • page: the QWebEnginePage (or view.page()) to install onto.
  • browser_window: optional, but recommended if your QWebEnginePage lives inside a QMainWindow/QWidget — pass that window (e.g. install(view.page(), browser_window=self) from inside your window's __init__). It's used for two independent things:
    1. Parenting the device-chooser dialog, if browser_window is an actual QWidget. Without this, the dialog falls back to QApplication.activeWindow(), which some platforms/window managers don't keep reliably in sync with an async JS→Python call (the dialog still works, but can end up without a parent and easy to lose behind other windows — see the 0.0.4 CHANGELOG entry for the reported symptom and full fallback chain: browser_window → activeWindow() → any visible top-level widget → none).
    2. Settings storage: if it (or a non-widget object you pass instead) has a .settings attribute (a QSettings instance), permission grants are stored there instead of a package-local QSettings. This half of the contract predates 1. and doesn't require a QWidget — a plain settings-holder object is still fine here, it just won't be used for dialog parenting.
  • settings_organization / settings_application: identify the fallback QSettings store used when browser_window isn't given, or doesn't have a .settings attribute. Set these to your own app's identity so permission data lands in your app's settings file, not a generic one.
  • qwebchannel_js: almost never needed — by default install() reads qwebchannel.js straight out of your Qt installation's built-in resources (:/qtwebchannel/qwebchannel.js, the same mechanism Qt's own C++ examples use). Pass this explicitly only if that lookup fails in your environment.
  • Returns the created WebUSBBridge (handy for tests / introspection), or None if QWebChannel itself isn't available in your Qt install — WebUSB just won't work on that page, but your app won't crash.

Call install() once per page (e.g. in whatever function creates your QWebEngineView / QWebEnginePage).

Lower-level pieces

If you need more control than install() gives you, the pieces it wires together are all public: pyside6_webusb.bridge.WebUSBBridge, pyside6_webusb.polyfill.WEBUSB_POLYFILL_JS, pyside6_webusb.chooser_dialog.WebUsbDeviceChooserDialog, and the pure-logic helpers in pyside6_webusb.hardening (filter matching, blocklist checks, descriptor building — all independent of Qt, so you can unit test against them without a display).

Pre-authorizing devices without the chooser (0.0.5a0)

WebUSBBridge.grant_device_for_origin(origin, vendor_id, product_id) lets your host app grant an origin access to a specific device up front, without the user ever seeing the chooser dialog — the same idea as Chrome's enterprise WebUsbAllowDevicesForUrls policy, for kiosk/embedded deployments where the set of allowed origins and devices is decided by configuration rather than by an end user clicking "Connect":

bridge = install(view.page())
# e.g. read from your own kiosk config at startup
bridge.grant_device_for_origin("https://kiosk.example", vendor_id=0x2341, product_id=0x8036)

It shares the exact same storage as a grant obtained through the normal chooser flow, so a pre-authorized device shows up immediately in navigator.usb.getDevices() and can later be taken away again with the existing revoke_origin_grant() — the two are symmetric, and neither the polyfill nor the page can tell which path a given grant came from. Like every other management method in this section, it's deliberately not a @Slot: call it only from your own trusted Python code, never anything reachable from web content. Blocklisted devices (known security keys) are rejected here too, for the same reason openDevice() rejects them.

Diagnosing your environment (0.0.5a0)

Most "navigator.usb isn't working" reports turn out to be an environment problem (missing/old PySide6, or pyusb installed without an OS-level libusb backend) rather than a bug in this package. Check it in one line, in Python:

from pyside6_webusb import environment_report, format_environment_report
print(format_environment_report())        # human-readable text
report = environment_report()             # or the same data as a plain dict

...or from the command line, either after pip install:

$ pyside6-webusb-doctor

or without installing a console script at all:

$ python -m pyside6_webusb

Both print the same report and exit non-zero if a real problem (not just a missing optional Rust extension, see below) was found — handy as a one-line sanity check in CI or in your own app's bug-report tooling. Add --json (0.0.5.post3) to either command to get environment_report()'s dict as JSON on stdout instead of the human-readable text above — same exit-code meaning, but easier for a CI job to archive as an artifact or parse directly instead of scraping the text form:

$ pyside6-webusb-doctor --json

environment_report()["pyusb_backend_note"] (0.0.5.post3) is set when pyusb_backend resolved to "libusb0" — the older of pyusb's two backends, used only as a fallback when the newer libusb1 backend's shared library isn't found. Still fully functional, but worth knowing about: this project's own security audit (security_report/VULNERABILITY_REPORT.md, "Environment note") found that pyusb 1.3.1's libusb0.py backend emits a DeprecationWarning under Python 3.14 about a ctypes.Structure pattern scheduled to become an error in Python 3.19 — an upstream pyusb issue, not something this package's own code can fix, but the note points at installing an OS-level libusb1 shared library as the practical way to avoid depending on that path at all.

environment_report()["qtwebengine_importable"] (0.0.5.post2) checks QtWebEngineCore/QtWebEngineWidgets specifically, separately from bare PySide6 import success — worth knowing about because PySide6 6.12.0a1 (2026-09 development build) moved those two out of pyside6-addons into a new, separate pyside6-webengine wheel (not yet on PyPI under any released version as of this writing, confirmed by comparing wheel contents directly). If that split reaches a real release, PySide6-Essentials/PySide6-Addons being correctly installed will no longer guarantee QtWebEngineCore importability the way it always has — problems will name PySide6-WebEngine specifically if that happens to you.

Multi-language chooser dialog and diagnostics (0.0.5b3)

Built-in en/ja/zh (Simplified Chinese) translations for the device chooser dialog and the environment_report()/python -m pyside6_webusb diagnostics text, via a new pyside6_webusb.i18n module:

install(view.page(), locale="ja")                       # chooser dialog + this bridge's UI text
install(view.page(), locale="auto")                      # follow the OS locale (opt-in; see below)
install(view.page(), locale="en", chooser_strings={"trust_reminder": "Custom wording"})

from pyside6_webusb.diagnostics import environment_report, format_environment_report
print(format_environment_report(environment_report(locale="zh")))
$ python -m pyside6_webusb --lang en

Compatibility note: omitting locale= keeps producing the exact same Japanese text this package has always produced (DEFAULT_LOCALE is a fixed default, not OS auto-detection) — pass locale="auto" explicitly if you want OS-locale-following behavior instead.

extra_guard_js: a host-defined extra gate on requestDevice() (0.0.5b3)

install(view.page(), extra_guard_js="""
    window.__pysideWebUSBExtraGuard = function({ origin, filters, exclusionFilters }) {
        return origin === 'https://intranet.example';  // anything else: rejected, SecurityError
    };
""")

Runs client-side, before the chooser dialog ever opens and before the bridge is ever reached. Returning exactly false (or throwing) rejects with SecurityError; true/undefined changes nothing — this can only add restrictions on top of this package's own checks, never loosen them.

Testing hardware-free with virtual USB devices (0.0.5a3)

pyside6_webusb.virtual lets you exercise navigator.usb end-to-end — chooser, permissions, claimInterface(), transfers, hotplug — without any real USB hardware plugged in, and without the FakeDevice-style fixtures this project's own test suite uses being private to the test suite. It's a small duck-typed stand-in for pyusb's usb.core/usb.util that plugs into the exact same WebUSBBridge code path a real device does, so every security check in this README (protected interface classes, the blocklist, origin-scoped permissions) applies to a virtual device exactly as it would to real hardware:

from pyside6_webusb import (
    WebUSBBridge, VirtualUsbDevice, VirtualUsbConfiguration,
    VirtualUsbInterface, VirtualUsbEndpoint, make_virtual_usb_backend,
)

dev = VirtualUsbDevice(
    vendor_id=0x2341, product_id=0x8036,
    manufacturer="Acme", product="Virtual Widget", serial_number="SN-0001",
    configurations=[
        VirtualUsbConfiguration(value=1, interfaces=[
            VirtualUsbInterface(number=0, alternate=0, interface_class=0xFF, endpoints=[
                VirtualUsbEndpoint(number=1, direction="in", transfer_type="bulk"),
                VirtualUsbEndpoint(number=1, direction="out", transfer_type="bulk"),
            ]),
        ]),
    ],
)
backend = make_virtual_usb_backend([dev])
bridge = WebUSBBridge(usb_backend=backend)   # navigator.usb now talks to `dev`, no hardware needed

Bulk/interrupt/control transfers default to a zero-filled echo response; pass on_bulk_read=/on_bulk_write=/on_control_transfer= callables to a VirtualUsbDevice to simulate a real device's actual protocol instead. Hotplug is simulated too — backend[0].unplug() / .plug() — and is picked up by the same UsbHotplugWatcher polling loop the bridge already uses for real devices, so no separate code path needs testing.

Known limitations as of 0.0.5a3: transfers don't simulate isochronous timing/bandwidth (they're handled the same as bulk/interrupt); there's no kernel-driver concept, so is_kernel_driver_active() always reports False; and reset() keeps the same descriptor configuration rather than re-enumerating (the WebUSB spec itself leaves post-reset configuration state unspecified — Issue #36 — so this isn't a spec violation).

Record a real device once, replay it forever (0.0.5b2)

VirtualUsbDevice.from_descriptor() builds a virtual device straight from a device descriptor dict — the exact shape listDevices()/getDevices() already return to JS (vendor/product IDs, strings, every configuration/interface/alternate/endpoint). Plug the real device in once, capture its descriptor, and reuse it indefinitely without the hardware:

import json
from pyside6_webusb import VirtualUsbDevice, make_virtual_usb_backend, WebUSBBridge

# 1) With the real device attached, capture its descriptor once and save it.
descriptor = json.loads(bridge.listDevices())["devices"][0]
json.dump(descriptor, open("my_device.json", "w"))

# 2) Later, anywhere, with no hardware attached:
descriptor = json.load(open("my_device.json"))
dev = VirtualUsbDevice.from_descriptor(descriptor)
bridge = WebUSBBridge(usb_backend=make_virtual_usb_backend([dev]))

Missing fields fall back to harmless defaults, so a hand-written or partially-captured descriptor works too. Pass on_bulk_read=/on_bulk_write=/on_control_transfer= on top, same as a normal VirtualUsbDevice, if you also want to simulate the device's actual protocol rather than the default zero-filled echo.

Security model

  • navigator.usb works inside iframes again, safely. 0.0.2b0 disabled it entirely after finding that a cross-origin iframe could impersonate the top-level page's origin (no way existed to tell which frame a QWebChannel call came from). 0.0.3b0 implements real per-frame origin attribution instead of just re-enabling the old, vulnerable check — see frame_origin.py and the 0.0.3b0 CHANGELOG entry for the full design and what it took to verify against a real QWebEnginePage. On a PySide6/Qt version too old to have the QWebEngineFrame class this depends on — confirmed by installing 6.6.0/6.7.0/6.8.0 directly: absent in the first two, present from 6.8.0 (0.0.5.post2), meaning it is not available anywhere in this project's 6.5–6.7 floor — it automatically falls back to the old main-frame-only behavior rather than running with a broken security boundary. environment_report()'s frame_origin_isolation_available/frame_origin_isolation_note (0.0.5.post2, see "Diagnosing your environment," above) tell you which mode a given deployment is actually getting, rather than leaving it silent.
  • Base64 transfer payloads are checked for canonical encoding, not just decodability (0.0.5.post2). data_b64 arguments to controlTransferOut/bulkTransferOut/ isochronousTransferOut reach WebUSBBridge via QWebChannel, which exposes it as a plain JS object — a frame that skips WEBUSB_POLYFILL_JS entirely and calls these slots directly can pass any string it likes, not only what a real encoder would produce. Non-canonical base64 (RFC 4648 §3.5 non-zero padding bits — e.g. "Zh==", which decodes leniently to the same bytes as the correct "Zg==") is now rejected as DataError rather than silently accepted, using Python 3.15's native base64.b64decode(..., canonical=True) where available and an equivalent round-trip check on older Python.
  • The site never sees your device list. getDevices() only ever returns devices a user has explicitly picked for that origin before. requestDevice() always shows a native chooser dialog; there is no way for a page to silently enumerate or connect to hardware.
  • Origin binding is enforced by Qt, not by the page's word for it. The bridge reads the current origin from QWebEnginePage.url() — a page can't claim to be a different origin to read another site's granted devices.
  • 8 protected interface classes are blocked from claimInterface(), matching WebUSB's own protected classes list: Audio, HID, Mass Storage, Hub, Smart Card, Video, Audio/Video, and Wireless Controller. This is the same restriction real browsers apply so that WebUSB can't be used to drive your keyboard, webcam, external drive, or USB hub out from under the OS.
  • That protection can't be bypassed through controlTransferIn/controlTransferOut, or through plain transferIn/transferOut/clearHalt/selectAlternateInterface either. claimInterface() rejecting a protected class is meaningless if a page can just send a raw transfer (or change the alternate setting) at the same interface instead — so, per the spec's own control transfer validation algorithm and real Chrome's USBDevice::EnsureEndpointAvailable()/EnsureInterfaceClaimed() (confirmed by reading Blink's actual source), every transfer method and selectAlternateInterface() require their target interface to actually be claimed, in addition to requestType: 'class' control requests being checked against the protected-class list regardless of recipient.
  • Known security keys are blocklisted by vendor/product ID, mirroring Chromium's usb_blocklist.cc — these devices don't even appear in the chooser dialog.
  • requestDevice() guards against reentrancy. The chooser dialog runs a nested Qt event loop (QDialog.exec()); a second call arriving while one is already open for the same page (rapid re-invocation, a queued QWebChannel message serviced mid-loop, etc.) is rejected immediately with InvalidStateError instead of opening a second dialog on top of the first.
  • requestDevice() requires a real user gesture (navigator.userActivation) and validates options.filters/exclusionFilters per spec before anything reaches the chooser.
  • Every WebUSBBridge method is wrapped so a Python-side exception can never escape across the Qt meta-object boundary and crash your app — failures always come back to JS as a rejected promise instead.
  • Host-app-only management methods are genuinely unreachable from web content, not just unused by the polyfill's own JS (0.0.4b2). install() injects the polyfill into MainWorld — the same JS execution context the page's own scripts run in, which is what makes navigator.usb visible to the page at all — so any @Slot on the bridge object is reachable by any page opening its own QWebChannel connection directly, regardless of whether polyfill.py's own JS happens to call it. listKnownDevices()/forgetKnownDevice()/ forgetAllKnownDevices() (data spanning every origin's known devices, not just the calling one) had @Slot and were reachable this way until this release, even though the file's own list_granted_origins()/revoke_origin_grant()/revoke_all_for_origin() right next to them already documented the opposite principle for the same category of operation. All six are now plain Python methods — call them from your own trusted host-app code (a settings screen, say), never from web content. grant_device_for_origin() (0.0.5a0, see below) joins this same group and was designed as a plain Python method from the start.
  • closeDevice() can no longer dispose a device out from under an in-progress chunked bulk transfer (0.0.5a0, closing a gap the 0.0.4b CHANGELOG had explicitly left for a later release). bulkTransferIn()/bulkTransferOut() yield to the Qt event loop (processEvents()) between sub-chunks of a large transfer so the UI doesn't freeze, which means another QWebChannel call for the same handle can be serviced mid-transfer. Transfer-vs-transfer reentrancy was already guarded; closeDevice() now checks the same in-progress marker and rejects with InvalidStateError instead of disposing the device, so a same-handle close() racing a large transferIn()/transferOut() can no longer pull the device out from under the transfer loop.
  • The protected-interface-class check can't be bypassed by hiding behind an alternate setting either (0.0.4b3, an independent security audit — see security_report/ for the full writeup). A composite device is free to declare alternate setting 0 of an interface as an innocuous vendor-specific class while alternate setting 1 of that same interface number is actually HID or another protected class — the same category of boundary CVE-2018-6125 found Chrome missing for its own claimInterface(). claimInterface(), every transfer method, clearHalt(), and both the class- and interface-recipient branches of control-transfer validation now all agree on which alternate setting is actually selected (tracked from the moment an interface is claimed, updated by selectAlternateInterface(), and invalidated by selectConfiguration() or resetDevice()) rather than any of them trusting whichever alternate happens to be listed first in the descriptor.
  • requestDevice()'s user-gesture and filter-structure checks are enforced by Python, not only by polyfill.py's own JS (0.0.4b3). A page with its own direct QWebChannel connection could previously call requestDeviceChooser() any time, with no user interaction at all, and with a structurally invalid USBDeviceFilter that widened the candidate list instead of matching nothing. polyfill.py now mints a short-lived, single-use token at the moment it confirms navigator.userActivation.isActive, and the bridge itself independently validates every filter before ever constructing the chooser dialog — see mintGestureToken() and requestDeviceChooser() in bridge.py for exactly what this does and does not guarantee (PySide6/QtWebEngine has no public API to observe a page's real DOM user-activation state independently, so this raises the bar rather than closing it perfectly against a sufficiently determined scripted attacker).
  • Device-supplied strings can't spoof the chooser dialog or smuggle control characters into logs/descriptors (0.0.4b3). A USB device's manufacturer/product/serial/configuration/ interface name strings are entirely the device's own choice — nothing about connecting one requires the host's consent the way granting an origin access does. These strings are now stripped of control characters and bidirectional-override characters and length-capped before they reach anything (sanitize_device_string()), and the chooser dialog forces every QLabel showing one to Qt.TextFormat.PlainText so an HTML-formatted product name can't restyle the dialog itself — the same category of issue as CVE-2020-16033.
  • Hotplug connect/disconnect events don't leak a granted device's identity to unrelated cross-origin frames sharing the same page (0.0.4b3, mitigated but see the caveat below). deviceConnected/deviceDisconnected are plain Qt signals with no per-frame delivery mechanism, and whether one fires at all is decided from the top-level page's grants — so every frame on the page technically receives the broadcast regardless of its own origin. polyfill.py now re-checks, for its own frame's own origin specifically, whether it is actually granted the device before ever dispatching a JS connect/disconnect event or calling onconnect/ondisconnect (see isGrantedToThisFrame()) — so the observable behavior is correctly scoped even though the underlying Qt signal still reaches every frame's polyfill.py instance internally.
  • Devices already plugged in when the bridge starts no longer fire spurious connect events (0.0.5a3, UsbHotplugWatcher in hardening.py). Real browsers don't dispatch connect for devices that were already attached before the page loaded — they're just visible via getDevices() from the start. The previous implementation's first poll always diffed the current device set against an empty baseline, so every already-attached, already-granted device fired a connect event once the hotplug timer's first tick landed, on top of already being in getDevices()'s result. The first poll now only records a baseline. Known limitation, unchanged by this fix: device identity in the watcher is tracked by (vendor_id, product_id) only, so two physically distinct devices sharing the same IDs can't be told apart — unplugging one while the other stays connected won't fire a disconnect for either. This is a real constraint of not having a reliably stable per-device identifier available from pyusb across replugs, not something 0.0.5a3 set out to fix.
  • A single granted device can no longer be turned into unbounded host-process memory growth (0.0.4b3). openDevice() didn't cap how many simultaneous handles one origin could hold — an ordinary page calling device.open() in a loop without ever closing them (no QWebChannel trickery required) could grow WebUSBBridge's internal handle table without bound. Each origin is now capped at a generous number of simultaneously open handles; opening past the cap transparently releases that origin's oldest handle first rather than failing the call.

None of this is a substitute for judgment about what you expose to what pages — it's the same baseline model real browsers use, reproduced faithfully.

Spec compliance notes

This aims to be a faithful navigator.usb reproduction, not just "good enough." Specifically implemented per the spec text:

  • Full USBDevice/USBConfiguration/USBInterface/USBAlternateInterface/USBEndpoint object graph, built from real descriptors (not guessed), including USBConfiguration .configurationName and USBAlternateInterface.interfaceName (read from the device's own iConfiguration/iInterface string descriptors when defined).
  • options.filters and options.exclusionFilters matching, including the classCode-matches-via-any-interface rule (composite devices that report 0xFF at the device level but a real class per-interface) — not just vendor/product ID.
  • transferIn(endpointNumber, length) correctly targets the IN address for that endpoint number (endpointNumber | 0x80) rather than the raw number, matching both the spec's own algorithm and what the underlying pyusb/libusb call actually requires.
  • endpoints lists never include Control-Transfer-Type descriptors, per the spec's note that no USBEndpoint object should ever represent one.
  • USBTransferStatus: 'stall' and 'babble' are both surfaced as successful resolutions (per spec — this is how real USB protocols signal recoverable errors), not rejected promises. Detected from libusb's LIBUSB_ERROR_PIPE (stall) and LIBUSB_ERROR_OVERFLOW (babble) via pyusb, on every IN-direction transfer method (transferIn, controlTransferIn, isochronousTransferIn — babble is specifically an IN-direction condition, per spec).
  • USBInterface.alternate correctly resolves to the alternate setting numbered 0, not whichever one happens to be first in the descriptor.
  • .configuration reflects the device's actual active configuration (get_active_configuration()), not always the first one in the list.
  • Correct DOMException names: SecurityError only for protected-class/blocklist rejections, InvalidStateError for a requestDevice() call made while a chooser is already open (or an unclaimed interface for control transfers), InvalidAccessError for an endpoint whose actual transfer type doesn't match the method used on it (isochronous endpoints via transferIn/Out, or non-isochronous endpoints via isochronousTransferIn/Out), IndexSizeError for an out-of-range endpoint number, DataError for a mismatch between isochronousTransferOut's data length and its packetLengths sum (matching Blink's kBufferSizeMismatch, confirmed from source — 0.0.4b2), NetworkError for other transfer/claim failures, NotFoundError when the user cancels the chooser (or an endpoint/interface can't be found or isn't claimed), TypeError for malformed filters or packetLengths, NotSupportedError when this environment can't actually perform an isochronous transfer (see below). Every one of these is actually recognized end-to-end by the JS-side dispatcher (KNOWN_ERROR_PREFIXES) as of 0.0.4b2 — several weren't before, meaning the DOMException reaching page code had the wrong .name for those cases; see the 0.0.4b2 CHANGELOG entry.
  • selectConfiguration() resets which interfaces are considered "claimed" on success, per spec — claiming interface 2 under one configuration doesn't leave interface 2 treated as claimed after switching to a different configuration that happens to reuse that number.

Known, deliberate simplifications (things a from-scratch from-the-spec implementation would add, that didn't seem worth the complexity here):

  • Isochronous transfers (isochronousTransferIn/Out) are implemented, but best-effort and unverified against real hardware. pyusb's public API has no isochronous method, so this reaches libusb1's backend directly through a private pyusb attribute (dev._ctx.handle) — a real exception to the public-API-only approach used everywhere else in this codebase. It also only supports uniform packet lengths (pyusb's backend can't express per-packet lengths that differ), and falls back to NotSupportedError if the backend/handle aren't available at all. See CHANGELOG.md's 0.0.2b0 entry for the full reasoning. If you have an actual isochronous device (USB audio, a webcam's isochronous video endpoint, etc.), trying it and reporting back would materially increase confidence in this feature. The uniform-packet-length limitation was specifically investigated for a Rust workaround in 0.0.4b2 (libusb's C API does support per-packet lengths via its async transfer submission) and deliberately not implemented — see _validate_packet_lengths's docstring in bridge.py and the 0.0.4b2 CHANGELOG entry for the specific blockers (no synchronous libusb isochronous API to wrap; pyusb already holds the device open via its own handle, and safely sharing that with an independent Rust-side handle would mean extracting pyusb's undocumented internal raw pointer and passing it across an unsafe FFI boundary with no real hardware anywhere to validate the result against).
  • The full per-method DOMException matrix isn't 100% reproduced — e.g. calling a method on an unopened device reports NetworkError rather than the spec's more specific InvalidStateError in every case. The names that matter for the common claim/deny-then-retry flow (SecurityError vs NetworkError) are correct.
  • No Permissions-Policy usb-unrestricted bypass (the mechanism Isolated Web Apps can use to skip the protected-class/blocklist checks). This is deliberate — a general-purpose library shouldn't ship an easy way to disable its own hardening.

Large transfers (WebADB and similar)

WebUSB is commonly used to drive protocols that move substantial amounts of data over bulk endpoints — WebADB-style Android Debug Bridge clients being the best-known example, easily moving hundreds of KB to a few MB per transferIn/transferOut call (file pushes, logcat streams, etc.). As of 0.0.4a0/0.0.4b0 this is a first-class concern rather than an afterthought:

  • Wire encoding is base64, not hex, between the JS polyfill and the Python bridge (an internal implementation detail — transferIn/transferOut still take/return plain ArrayBuffer/DataView per spec either way). Hex was a 2x size expansion; base64 is ~1.33x. The JS-side encoder chunks the input (0x8000 bytes at a time) before handing it to String.fromCharCode.apply() — calling that unchunked on a large Uint8Array throws RangeError: Maximum call stack size exceeded once you're in WebADB-sized-payload territory (confirmed directly in Node: still fine at 100 KB, throws by 300 KB), so this isn't optional. An optional Rust extension can additionally speed up the Python-side base64 codec itself.
  • Transfer timeouts scale with payload size instead of a flat 5 seconds, for transferIn/Out and controlTransferIn/Out. The spec doesn't expose a timeout concept to JS at all for these methods (callers are entitled to expect a slow-but-legitimate transfer to just take as long as it takes), but this implementation still has to hand pyusb some finite value — a flat 5s could cut off a real large-payload transfer on a slow link. The compromise: 5s minimum, scaling at a conservative 100 KB/s, capped at 120s per underlying pyusb call (see the chunking point below for how that cap applies to a sub-chunk rather than to a whole multi-megabyte transfer).
  • Requested lengths are capped, but not at Chrome's own limit — see the dedicated "Transfer size limits: Chrome-compatible, not Chrome-identical" section right below for the full policy (short version: real Chrome's 32 MiB kUsbTransferLengthLimit becomes a console.warn() here, not a rejection; this implementation's own unrelated hard ceiling is 512 MiB, purely to keep a pathological request from forcing an unbounded host-side allocation).
  • transferIn/transferOut no longer risk freezing your whole app's UI on a large payload (0.0.4b0). These bridge methods run as @Slots on the Qt main thread — the same thread that paints the browser window and processes every other event — so a single pyusb call that legitimately takes several seconds (a multi-hundred-KB WRTE payload on a slow link, say) used to mean the entire app was unresponsive for that whole duration, with no way to even repaint. Transfers over BULK_TRANSFER_CHUNK_SIZE (256 KiB) are now split into sub-chunk pyusb calls, with QCoreApplication.processEvents() called between them so the event loop gets a chance to breathe; each sub-chunk gets its own size-scaled timeout rather than one calculated for the whole transfer. This preserves the exact short-packet-terminates semantics a single large call would have had (a sub-chunk read for less than requested ends the transfer there, matching how libusb itself decides a bulk IN transfer is complete) — it changes how the data is fetched, not what comes back. Since processEvents() can dispatch another incoming QWebChannel call while a chunked transfer for a given handle is still in flight, a same-handle reentrant transferIn/transferOut call is rejected with InvalidStateError rather than risking interleaved pyusb calls against the same device — transfers to different handles are unaffected. Isochronous transfers are deliberately not chunked this way: they're inherently real-time (audio/video), and inserting processEvents() pauses into that path could itself introduce the glitches this change is trying to avoid elsewhere; ADB-style large-payload use only ever needs bulk transfers anyway. tests/test_bridge.py::test_bulk_transfer_round_trips_realistic_adb_wrte_message exercises this against an actual ADB WRTE-message-shaped payload (24-byte header + 256 KB body) rather than just an arbitrary blob, so the chunking logic is checked against the shape of data a real WebADB client would actually send.
  • Tested under sustained, repeated load, not just a single large transfer (0.0.4b2) — tests/test_bridge.py::test_sustained_large_transfers_do_not_leak_state_or_corrupt_data runs 30 consecutive large transferIn calls, 30 consecutive transferOut calls, then 50 alternating IN/OUT round trips resembling an ADB request/response pattern, checking after every single iteration (not just the last) that data hasn't been corrupted and that internal per-handle bookkeeping (_busy_handles, _open_devices) hasn't grown or leaked.

Transfer size limits: Chrome-compatible, not Chrome-identical (0.0.4b2)

Real Chrome enforces a 32 MiB cap on any single transferIn/transferOut/ isochronousTransferIn/isochronousTransferOut call (kUsbTransferLengthLimit, confirmed by reading Blink's actual source — this isn't a WebUSB spec requirement, it's Chrome's own operational choice). Per this project's stated design goal — WebUSB-compatible, not a byte-for-byte Chrome clone — that cap is not enforced here. A transfer over 32 MiB still succeeds, as long as it's under this implementation's own, much larger, and entirely unrelated hard ceiling (HOST_SAFETY_MAX_TRANSFER_LENGTH, 512 MiB — purely to stop a pathological request from forcing an unbounded host-side memory allocation; it has nothing to do with matching or not matching Chrome).

This divergence is never silent. Exceeding 32 MiB adds a console.warn() — visible in DevTools (F12) — on that specific transfer, quoting Blink's actual rejection text and then explicitly stating that this is pyside6-webusb, not Chrome, and that it doesn't enforce that limit. Call window.__pysideWebUSB.explainTransferLimits() (see below) any time for the same explanation on demand, independent of any particular transfer.

F12 / DevTools debug helpers

window.__pysideWebUSB is injected alongside navigator.usb (0.0.4b2) — a small set of console-friendly utilities for debugging a page's WebUSB usage from DevTools, deliberately scoped so nothing here discloses more than the calling page could already see via the standard API:

  • listGrantedDevices() — the calling origin's already-granted devices, formatted for console.table(). Exactly the same data as navigator.usb.getDevices(), just easier to skim.
  • bridgeInfo() — this bridge's version, whether the Rust acceleration is actually active, and the current transfer-size-limit values. Information navigator.usb itself has no way to expose.
  • explainTransferLimits() — logs the same Chrome-compatible-not-Chrome-identical reasoning described above.

None of these repeat the mistake fixed in 0.0.4b2's Security section below: nothing here is scoped across origins (no full device list, no other origins' permission state) — see that section for why that distinction matters given how install() injects this polyfill.

Rust acceleration (optional)

native/pyside6_webusb_accel/ is a small, optional PyO3 extension. pyside6-webusb works completely without it — it's a pure performance/tooling add-on, not a dependency (bridge.py tries import pyside6_webusb_accel and falls back to the standard-library base64 module if that fails; see HAVE_RUST_ACCEL there). It provides three independent things:

  1. A faster base64 codec for the large-transfer wire encoding described above. Every value it returns is cross-checked against Python's own base64 module for sizes from 0 bytes to 600 KB in tests/test_rust_accel.py.
  2. ADB (Android Debug Bridge) wire-protocol message-framing helpers — packing/unpacking the 24-byte header (command, arg0, arg1, data_length, data_crc32, magic) real ADB traffic uses, plus the command constants (CNXN, OPEN, WRTE, OKAY, CLSE, AUTH, STLS). This is not an ADB client or server — no auth handshake, no shell sessions, nothing that talks to a real device — it exists so tests (and anyone experimenting with a WebADB-style client against this bridge) can build realistic ADB-message-shaped fixtures instead of arbitrary blobs. The field layout and command values, and the fact that data_crc32 is actually just a wrapping byte sum rather than a real CRC32 despite the name, were confirmed by reading a real, working open-source Rust ADB client (tth0704/adb_client) rather than from memory — see the module docstring in native/pyside6_webusb_accel/src/lib.rs for exactly which files.
  3. One-pass transfer-response JSON construction (0.0.4b1) — bulkTransferIn/ controlTransferIn's success response ({"success":true,"status":"ok","data":"<base64>"}) used to mean three separate Python-level allocations for a large payload (join → base64-encode → json.dumps); format_transfer_in_success_json does it in one Rust pass with a pre-sized buffer. Measured at ~2.9x faster than the old approach for a 1 MB payload (timeit, 200 iterations: 5.46ms → 1.85ms) — see the 0.0.4b1 CHANGELOG entry for the full numbers, including confirmation that this is specifically a Rust-availability speedup (the pure-Python fallback path measures the same as before) and that both paths produce byte-identical JSON. It's deliberately narrow-purpose (documented in its own doc comment): it never JSON-escapes status, so it's only safe for this one fixed, known-shape response — not a general JSON builder.

Build it with maturin into your existing virtualenv:

pip install maturin
cd native/pyside6_webusb_accel
maturin develop --release   # or: maturin build --release, then pip install the wheel
cargo test --release        # pure-Rust unit tests (base64 vectors, ADB header round-trips, ...)

The crate's own logic lives in a plain logic module with no PyO3 types in it at all, so cargo test runs as ordinary Rust — the #[pyfunction]-wrapped bindings around it are thin enough that there's not much left to get wrong there. It's versioned independently from the Python package (currently 0.1.0) since it's a genuinely separate, separately-buildable artifact, not something released in lockstep.

Building against very new Python versions (0.0.5.post2)

Cargo.toml now builds this crate against Python's stable ABI (pyo3's abi3-py39 feature) instead of a version-specific extension. Found the hard way while verifying this release in a real Python 3.15.0rc2 environment: maturin build failed outright with error: the configured Python version (3.15) is newer than PyO3's maximum supported version (3.14) — the pinned pyo3 =0.27.2 (see "Why pyo3 is pinned" below) predates 3.15 support, and without abi3 there's no fallback for a Python release PyO3 doesn't recognize yet. Adding abi3-py39 and building with PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 set (only needed for a Python version newer than this pyo3 release knows about — a plain maturin build is enough once your pyo3 version's own release notes confirm it supports your Python version) produced a working cp39-abi3 wheel that imports and round-trips correctly under 3.15.0rc2 — confirmed by re-running this package's own tests/test_rust_accel.py against it (see "Project metadata," below). This is a net improvement independent of the 3.15 issue that surfaced it: one abi3 wheel now covers this package's entire requires-python = ">=3.9" range instead of needing a separate version-specific build per interpreter.

# Only when your pyo3 version's release notes don't yet list your Python version as supported
# (e.g. building against a pre-release Python ahead of pyo3 catching up):
PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 maturin build --release

Why pyo3 is pinned

Cargo.toml pins pyo3 =0.27.2 rather than a plain "0.27" or newer range. pyo3 0.28+ raises its MSRV to Rust 1.83; environments that only have an older rustc available (this project's own dev sandbox included — see "Project metadata" entries below for exactly which rustc was used at each release) can't build anything newer, so the pin keeps the crate buildable there. If your own rustc is 1.83+, a newer pyo3 (with native, non-abi3 Python 3.15 support once its release notes confirm it) is a reasonable thing to move to; this project just hasn't had a Rust toolchain new enough to verify that path itself yet.

TypeScript

types/webusb-polyfill.d.ts provides ambient (declare global) type declarations for the navigator.usb surface this polyfill installs, transcribed directly from the WebIDL in the spec source rather than written from memory. Since this is a PyPI package rather than an npm one, there's no node_modules resolution to hook into — copy the file into your own TypeScript project (e.g. src/types/) and make sure it's covered by your tsconfig.json's include. types/sample-usage.ts exercises the full surface (device selection, open/configure/claim, control/bulk/isochronous transfers including a WebADB-sized 300 KB transferOut and the 'babble' status, connection events) and compiles clean under tsc --strict; types/negative-check.ts uses @ts-expect-error to confirm invalid usage (a made-up USBTransferStatus, a bad clearHalt direction, a non-number vendorId) is actually rejected, not just nominally typed — both are real files in this repo, not just a claim, and both are checked as part of this project's own test run (see Testing below).

If you'd rather use the plain spec types without any polyfill-specific framing, DefinitelyTyped's @types/w3c-web-usb covers the same surface generically.

Testing

On a headless machine (CI, a server without a display, this package's own dev sandbox), set QT_QPA_PLATFORM=offscreen before running anything that imports PySide6.QtWidgets — without it, creating a QApplication aborts the process outright rather than raising a catchable exception. test_bridge.py needs a QApplication (constructing a WebUSBBridge/ WebUsbDeviceChooserDialog requires one); test_hardening.py and test_polyfill.js don't.

pip install -e ".[dev]"
export QT_QPA_PLATFORM=offscreen   # only needed on a headless machine
python tests/test_hardening.py     # or: pytest tests/test_hardening.py
python tests/test_bridge.py        # or: pytest tests/test_bridge.py
python tests/test_frame_origin.py  # or: pytest tests/test_frame_origin.py
python tests/test_install.py       # or: pytest tests/test_install.py
python tests/test_errors.py        # or: pytest tests/test_errors.py
python tests/extract_polyfill_js.py && node tests/test_polyfill.js

To check the TypeScript definitions compile and actually constrain usage (not shipped as an automated pytest/Node test, since it needs a separate typescript install — see the TypeScript section above):

npm install -g typescript   # or any local install
cd types
tsc --strict --noEmit --lib es2020,dom webusb-polyfill.d.ts sample-usage.ts    # should exit 0
tsc --strict --noEmit --lib es2020,dom webusb-polyfill.d.ts negative-check.ts  # should also exit 0

test_hardening.py, test_bridge.py, and test_errors.py run without any GUI or real USB hardware (fake pyusb-shaped objects stand in for both). test_polyfill.js runs the actual polyfill JS in Node with a mocked QWebChannel bridge. test_install.py and most of test_frame_origin.py use a lightweight fake page (no real browser engine needed); one test in test_frame_origin.py does spin up a real QWebEnginePage (with --no-sandbox, since this sandbox runs as root) to load actual HTML with a cross-origin iframe and confirm it gets correctly attributed its own origin — that one test alone takes several seconds due to Chromium startup, which is why it's the only one of its kind rather than a whole suite of them.

All of the above was additionally verified against a real QWebEngineView + real pyusb/libusb during development (not just mocks) — loading a page, confirming navigator.usb/navigator.usb.getDevices exist in its JS context, round-tripping an actual getDevices() call through the real QWebChannel bridge end to end, and (as of 0.0.3b0) loading a page containing a cross-origin iframe and confirming the iframe gets a distinct, correctly-attributed origin token separate from the main frame's. The one thing that still isn't covered by automated tests is the native device-chooser dialog itself (it's a modal QDialog — exercising it needs a real display and a human, or a UI-automation layer neither this environment nor CI typically has). If you're integrating this into a project with headed test infrastructure, that's the one gap worth closing.

What's not verified yet: real-world site compatibility

Everything above tests this codebase's own logic (against fakes, or a bare page confirming the API surface exists). It does not answer the actually-important question: does a real, unmodified site written against Chrome's WebUSB — an Arduino/micro:bit/DFU flashing tool, a device manufacturer's config page, one of the official WebUSB samples — actually work when loaded through this bridge against real hardware. That needs a real device, a real display, and a human clicking through the chooser dialog, none of which this sandboxed environment (or most CI) has. examples/compatibility_test.html is a page you can load through examples/minimal_browser.py (or your own app with install() wired up) against a real device to walk through requestDevice() → open() → selectConfiguration() → claimInterface() → transfers → forget(), plus watching connect/disconnect fire on a real unplug/replug, with pass/fail shown inline. It's a tool for you to run this verification, not a substitute for having run it — treat real-site compatibility as genuinely unverified until someone does.

Example

See examples/minimal_browser.py — a ~60-line runnable app with an address bar and a self-contained demo page (no external site required) that calls getDevices()/requestDevice() and shows the results.

python examples/minimal_browser.py

Point its address bar at examples/compatibility_test.html (or a real WebUSB site) with a device plugged in to manually verify end-to-end compatibility — see the section above.

License

MIT AND BSD-3-Clause AND W3C-20150513 — see LICENSE for the full text of all three plus the itemized list of what each covers (Chromium-derived blocklist data, W3C-derived TypeScript WebIDL definitions, and this project's own original code). qwebchannel.js is loaded at runtime from your own Qt installation and is not redistributed in this repository; Qt typically ships it under BSD-3-Clause, but this project makes no independent licensing assertion about that runtime-provided Qt resource — see LICENSE §2.1 and confirm against the exact Qt/PySide6 distribution you're using.

Release files for pyside6-webusb 0.0.5.post7

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyside6-webusb 0.0.5.post7
File Size Uploaded
pyside6_webusb-0.0.5.post7.tar.gz 361.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pyside6-webusb 0.0.5.post7
File Interpreter ABI Platform
pyside6_webusb-0.0.5.post7-py3-none-any.whl Python 3 none any Details

Total release size: 505.0 kB

Release files / pyside6_webusb-0.0.5.post7.tar.gz

Download URL pyside6_webusb-0.0.5.post7.tar.gz
Size 361.3 kB
Tags Source
SHA-256 checksum
How to use checksums
848ceee0e4b26d4c7eb13a31c1c15f7b0553cc5f2327458a170d21bcd23617a7
BLAKE2b-256 checksum
How to use checksums
64398c5b7e3f6981b8efc1cd98f7ba61854c71aa50859ed4357984fbfea64930
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14

Release files / pyside6_webusb-0.0.5.post7-py3-none-any.whl

Download URL pyside6_webusb-0.0.5.post7-py3-none-any.whl
Size 143.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2d97c27ab989cf46ed0b274ffd739216213293d718c50fc069b94c9c99b52619
BLAKE2b-256 checksum
How to use checksums
56e2b5c6a7d0e05fe381add811141b3b96bc845c69d9c8e834f0242d77d20dbf
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page