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 overQWebChannel. - A JavaScript polyfill that makes
navigator.usbbehave like the real thing — same classes, same method names, sameDOMExceptionnames, 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(orview.page()) to install onto. - browser_window: optional, but recommended if your
QWebEnginePagelives inside aQMainWindow/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:- Parenting the device-chooser dialog, if
browser_windowis an actualQWidget. Without this, the dialog falls back toQApplication.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 the0.0.4CHANGELOG entry for the reported symptom and full fallback chain:browser_window→activeWindow()→ any visible top-level widget → none). - Settings storage: if it (or a non-widget object you pass instead) has a
.settingsattribute (aQSettingsinstance), permission grants are stored there instead of a package-localQSettings. This half of the contract predates 1. and doesn't require aQWidget— a plain settings-holder object is still fine here, it just won't be used for dialog parenting.
- Parenting the device-chooser dialog, if
- settings_organization / settings_application: identify the fallback
QSettingsstore used whenbrowser_windowisn't given, or doesn't have a.settingsattribute. 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()readsqwebchannel.jsstraight 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), orNoneifQWebChannelitself 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.usbworks inside iframes again, safely.0.0.2b0disabled it entirely after finding that a cross-origin iframe could impersonate the top-level page's origin (no way existed to tell which frame aQWebChannelcall came from).0.0.3b0implements real per-frame origin attribution instead of just re-enabling the old, vulnerable check — seeframe_origin.pyand the0.0.3b0CHANGELOG entry for the full design and what it took to verify against a realQWebEnginePage. 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 plaintransferIn/transferOut/clearHalt/selectAlternateInterfaceeither.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'sUSBDevice::EnsureEndpointAvailable()/EnsureInterfaceClaimed()(confirmed by reading Blink's actual source), every transfer method andselectAlternateInterface()require their target interface to actually be claimed, in addition torequestType: '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 queuedQWebChannelmessage serviced mid-loop, etc.) is rejected immediately withInvalidStateErrorinstead of opening a second dialog on top of the first.requestDevice()requires a real user gesture (navigator.userActivation) and validatesoptions.filters/exclusionFiltersper spec before anything reaches the chooser.- Every
WebUSBBridgemethod 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 intoMainWorld— the same JS execution context the page's own scripts run in, which is what makesnavigator.usbvisible to the page at all — so any@Sloton the bridge object is reachable by any page opening its ownQWebChannelconnection directly, regardless of whetherpolyfill.py's own JS happens to call it.listKnownDevices()/forgetKnownDevice()/forgetAllKnownDevices()(data spanning every origin's known devices, not just the calling one) had@Slotand were reachable this way until this release, even though the file's ownlist_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 the0.0.4bCHANGELOG 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 anotherQWebChannelcall 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 withInvalidStateErrorinstead of disposing the device, so a same-handleclose()racing a largetransferIn()/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 — seesecurity_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 ownclaimInterface().claimInterface(), every transfer method,clearHalt(), and both theclass- andinterface-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 byselectAlternateInterface(), and invalidated byselectConfiguration()orresetDevice()) 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 bypolyfill.py's own JS (0.0.4b3). A page with its own directQWebChannelconnection could previously callrequestDeviceChooser()any time, with no user interaction at all, and with a structurally invalidUSBDeviceFilterthat widened the candidate list instead of matching nothing.polyfill.pynow mints a short-lived, single-use token at the moment it confirmsnavigator.userActivation.isActive, and the bridge itself independently validates every filter before ever constructing the chooser dialog — seemintGestureToken()andrequestDeviceChooser()inbridge.pyfor 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 everyQLabelshowing one toQt.TextFormat.PlainTextso an HTML-formatted product name can't restyle the dialog itself — the same category of issue as CVE-2020-16033. - Hotplug
connect/disconnectevents 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/deviceDisconnectedare 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.pynow re-checks, for its own frame's own origin specifically, whether it is actually granted the device before ever dispatching a JSconnect/disconnectevent or callingonconnect/ondisconnect(seeisGrantedToThisFrame()) — so the observable behavior is correctly scoped even though the underlying Qt signal still reaches every frame'spolyfill.pyinstance 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 callingdevice.open()in a loop without ever closing them (noQWebChanneltrickery required) could growWebUSBBridge'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/USBEndpointobject graph, built from real descriptors (not guessed), includingUSBConfiguration .configurationNameandUSBAlternateInterface.interfaceName(read from the device's owniConfiguration/iInterfacestring descriptors when defined). options.filtersandoptions.exclusionFiltersmatching, including the classCode-matches-via-any-interface rule (composite devices that report0xFFat 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 underlyingpyusb/libusb call actually requires.endpointslists never include Control-Transfer-Type descriptors, per the spec's note that noUSBEndpointobject 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'sLIBUSB_ERROR_PIPE(stall) andLIBUSB_ERROR_OVERFLOW(babble) viapyusb, on every IN-direction transfer method (transferIn,controlTransferIn,isochronousTransferIn— babble is specifically an IN-direction condition, per spec).USBInterface.alternatecorrectly resolves to the alternate setting numbered0, not whichever one happens to be first in the descriptor..configurationreflects the device's actual active configuration (get_active_configuration()), not always the first one in the list.- Correct
DOMExceptionnames:SecurityErroronly for protected-class/blocklist rejections,InvalidStateErrorfor arequestDevice()call made while a chooser is already open (or an unclaimed interface for control transfers),InvalidAccessErrorfor an endpoint whose actual transfer type doesn't match the method used on it (isochronous endpoints viatransferIn/Out, or non-isochronous endpoints viaisochronousTransferIn/Out),IndexSizeErrorfor an out-of-range endpoint number,DataErrorfor a mismatch betweenisochronousTransferOut'sdatalength and itspacketLengthssum (matching Blink'skBufferSizeMismatch, confirmed from source —0.0.4b2),NetworkErrorfor other transfer/claim failures,NotFoundErrorwhen the user cancels the chooser (or an endpoint/interface can't be found or isn't claimed),TypeErrorfor malformed filters orpacketLengths,NotSupportedErrorwhen 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 of0.0.4b2— several weren't before, meaning theDOMExceptionreaching page code had the wrong.namefor those cases; see the0.0.4b2CHANGELOG entry. selectConfiguration()resets which interfaces are considered "claimed" on success, per spec — claiming interface2under one configuration doesn't leave interface2treated 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 reacheslibusb1's backend directly through a privatepyusbattribute (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 toNotSupportedErrorif the backend/handle aren't available at all. SeeCHANGELOG.md's0.0.2b0entry 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 in0.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 inbridge.pyand the0.0.4b2CHANGELOG entry for the specific blockers (no synchronous libusb isochronous API to wrap;pyusbalready holds the device open via its own handle, and safely sharing that with an independent Rust-side handle would mean extractingpyusb'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
DOMExceptionmatrix isn't 100% reproduced — e.g. calling a method on an unopened device reportsNetworkErrorrather than the spec's more specificInvalidStateErrorin every case. The names that matter for the common claim/deny-then-retry flow (SecurityErrorvsNetworkError) are correct. - No Permissions-Policy
usb-unrestrictedbypass (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/transferOutstill take/return plainArrayBuffer/DataViewper spec either way). Hex was a 2x size expansion; base64 is ~1.33x. The JS-side encoder chunks the input (0x8000bytes at a time) before handing it toString.fromCharCode.apply()— calling that unchunked on a largeUint8ArraythrowsRangeError: Maximum call stack size exceededonce 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/OutandcontrolTransferIn/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 handpyusbsome 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 underlyingpyusbcall (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
kUsbTransferLengthLimitbecomes aconsole.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/transferOutno 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 singlepyusbcall that legitimately takes several seconds (a multi-hundred-KBWRTEpayload on a slow link, say) used to mean the entire app was unresponsive for that whole duration, with no way to even repaint. Transfers overBULK_TRANSFER_CHUNK_SIZE(256 KiB) are now split into sub-chunkpyusbcalls, withQCoreApplication.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 howlibusbitself decides a bulk IN transfer is complete) — it changes how the data is fetched, not what comes back. SinceprocessEvents()can dispatch another incomingQWebChannelcall while a chunked transfer for a given handle is still in flight, a same-handle reentranttransferIn/transferOutcall is rejected withInvalidStateErrorrather than risking interleavedpyusbcalls 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 insertingprocessEvents()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_messageexercises this against an actual ADBWRTE-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_dataruns 30 consecutive largetransferIncalls, 30 consecutivetransferOutcalls, 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 forconsole.table(). Exactly the same data asnavigator.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. Informationnavigator.usbitself 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:
- A faster base64 codec for the large-transfer wire encoding described above. Every value
it returns is cross-checked against Python's own
base64module for sizes from 0 bytes to 600 KB intests/test_rust_accel.py. - 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 thatdata_crc32is 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 innative/pyside6_webusb_accel/src/lib.rsfor exactly which files. - 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_jsondoes 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 the0.0.4b1CHANGELOG 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-escapesstatus, 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)
| File | Size | Uploaded | |
|---|---|---|---|
| pyside6_webusb-0.0.5.post1.tar.gz | 173.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|