Metadata-Version: 2.4
Name: pyside6-webusb
Version: 0.0.5.post1
Summary: A WebUSB API implementation for PySide6/QtWebEngine apps (QWebChannel bridge + pyusb backend + JS polyfill)
Author: pyside6-webusb contributors
License-Expression: MIT
Project-URL: Homepage, https://github.com/steck0714/Pyside6-webusb
Project-URL: Issues, https://github.com/steck0714/Pyside6-webusb/issues
Project-URL: Mock-APIs (parent project), https://github.com/steck0714/Mock-webusb
Project-URL: Specification, https://wicg.github.io/webusb/
Keywords: pyside6,qt,qtwebengine,webusb,usb,pyusb,qwebchannel,polyfill
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: System :: Hardware
Classifier: Environment :: X11 Applications :: Qt
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: PySide6-Essentials>=6.5
Requires-Dist: PySide6-Addons>=6.5
Requires-Dist: pyusb>=1.2.1
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Dynamic: license-file

# pyside6-webusb

**Status:** `0.0.4b2` — early beta, working and tested (see [CHANGELOG.md](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](https://wicg.github.io/webusb/) 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](https://github.com/pyusb/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.

```python
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](https://wicg.github.io/webusb/) 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`](https://github.com/steck0714/Pyside6-webusb).

`pyside6-webusb` is the PySide6/QtWebEngine implementation under
[**Mock-webusb**](https://github.com/steck0714/Mock-webusb), a small umbrella project for
WebUSB-*compatible* implementations across different platforms — [`fox-webusb`](https://github.com/steck0714/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

```bash
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`](https://chromeenterprise.google/policies/#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":

```python
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:

```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`:

```console
$ pyside6-webusb-doctor
```

or without installing a console script at all:

```console
$ 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`](src/pyside6_webusb/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](https://wicg.github.io/webusb/#h-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](https://wicg.github.io/webusb/#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](https://chromium.googlesource.com/chromium/src/+/main/services/device/usb/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/`](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`](src/pyside6_webusb/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()`](src/pyside6_webusb/hardening.py)), 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](https://wicg.github.io/webusb/):

- 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](https://github.com/yume-chan/ya-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](#rust-acceleration-optional) 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 `@Slot`s 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](#rust-acceleration-optional)
  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/`](native/pyside6_webusb_accel/) is a small, optional
[PyO3](https://pyo3.rs/) 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`](https://github.com/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`](https://www.maturin.rs/) into your existing virtualenv:

```bash
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`](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](https://github.com/WICG/webusb/blob/main/index.bs)
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`](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`](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`](https://www.npmjs.com/package/@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.

```bash
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):

```bash
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`](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.

```bash
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](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.
