Skip to main content

pyside6-webusb

Status: 0.0.4b2 — 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 support in particular has not been verified against real hardware — see CHANGELOG.

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.

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 signal this depends on (QWebEnginePage.navigationRequested), it automatically falls back to the old main-frame-only behavior rather than running with a broken security boundary.
  • 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.
  • 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.

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 — see LICENSE. qwebchannel.js is loaded at runtime from your own Qt installation (BSD-3-Clause, © The Qt Company) and is not redistributed in this repository.

Release files for pyside6-webusb 0.0.5.post1

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.post1
File Size Uploaded
pyside6_webusb-0.0.5.post1.tar.gz 173.2 kB Details

Built distribution (wheel)

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

Total release size: 275.7 kB

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

Download URL pyside6_webusb-0.0.5.post1.tar.gz
Size 173.2 kB
Tags Source
SHA-256 checksum
How to use checksums
6695e19c0323fdf278f5a25dedad3ae849cc87c5709d8890b7ada70004bba857
BLAKE2b-256 checksum
How to use checksums
774aab4466a438abf3ce0394195849b64e83886b264aa87acfc2146a6cb71e0a
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.post1-py3-none-any.whl

Download URL pyside6_webusb-0.0.5.post1-py3-none-any.whl
Size 102.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c12513e44550b7461de8e74c524d75cfe455b1f318f90c976d19b2c2b36e35e1
BLAKE2b-256 checksum
How to use checksums
d32f5ef1d9cd6711f12e203e90d36601404defd50c34aba24631f84f877f7954
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